Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,24 @@ by hand.

`trigger → match → target → execution → outcome`. `rule-engine.ts` is pure — no I/O,
no clock — so the whole "which agent starts, and when" decision is exhaustively
unit-testable.
unit-testable. `matchesRule` is *defined as* `explainRule(...) === undefined` rather
than written beside it: a route that silently does nothing is this system's hardest
failure, the cycle summary's `no-route` is equally consistent with a typo'd login and a
transition that was never recorded, and two implementations would drift. `sentinel0
explain` (`GET /routes/explain`) re-collects live triggers and reports the one clause
that rejected each, reading history via `changesSince` and never `observe` — a
diagnostic that advanced the baseline would consume the transition being asked about.

**Transition history belongs to the item, not to the trigger type.** One pull request
raises a `pr_event` and, while a review is outstanding, a `pr_review_requested`;
observations are keyed on `ref` alone and the poll loop observes once per item per
cycle. Keying per type gave the narrower event a baseline that began at the instant of
the transition it existed to detect — first sight reports no changes, and by the next
cycle the reviewer is no longer new — so `pr_review_requested` + `reviewersAdded` could
never fire, the shipped reviewer-agent template included. `pr_event` is emitted for
every open PR regardless, which is what makes its baseline the right one to share.
`migrateObservationKeys` promotes existing `<type>:<ref>` rows so an upgrade does not
reseed a history the install already had.

Two invariants the dispatcher enforces:

Expand Down
35 changes: 32 additions & 3 deletions docs/routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ every label would look newly added, and creating a route would fire it across yo
entire backlog. A new route starts quiet and acts on what happens next — so to test
one, add and remove the label once while the runner is up.

"First seen" means the *item*, not the trigger type. One pull request raises both a
`pr_event` and — while a review is outstanding — a `pr_review_requested`, and both read
the same history. This matters because `pr_review_requested` only exists once a
reviewer does: were it to keep its own baseline, that baseline would begin at the very
moment of the request, first sight would suppress it, and a `reviewersAdded` route on
that trigger could never fire at all. It used to work exactly that way.

`labels` asks *does it have this now*. `labelsAdded` asks *was it just added*. Pick the
second when the act is the signal.

Expand Down Expand Up @@ -267,7 +274,28 @@ pause, unlabel to resume.

## Troubleshooting

**Nothing fired.** Watch one poll cycle — the runner prints a summary line:
**Nothing fired.** Ask the runner directly:

```bash
sentinel0 explain # every visible trigger, every route
sentinel0 explain --ref acme/www#42 # just this one
```

It re-collects triggers and prints, per item, the one clause that rejected it:

```
- acme/www#42 pr_review_requested Fix the export
changed nothing changed since the last poll
skips Reviewer agent: match.reviewersAdded wants any of [EomiAIBot]; nothing
changed there this cycle (nothing)
```

It reads history without recording any, so asking does not consume the transition you
are asking about, and the answer is the same every time until something really changes.
A *matching* route can still not run — the dedupe ledger, the one-run-per-agent rule and
agent resolution all come after matching, and the cycle summary reports those.

For the cycle's own view, the runner prints a summary line:

```
poll: 12 event(s) (taplands 12) · dispatched 1 · skipped 11 (no-route 10, duplicate 1)
Expand All @@ -281,8 +309,9 @@ matched. `unknown-agent` means the route names a profile absent from `sentinel0
`sentinel0:done` — remove it to re-arm, or switch to `per-change` with a transition
clause.

**A transition route never fires.** It needs a prior observation. Add and remove the
label once while the runner is up.
**A transition route never fires.** It needs a prior observation. `sentinel0 explain`
says `never been observed before` when that is the reason; add and remove the label once
while the runner is up.

**Everything fired at once when I created a route.** A state clause (`labels`) matches
everything currently carrying the label. Use `labelsAdded` if you meant the act.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sentinel0-monorepo",
"version": "0.0.4",
"version": "0.0.5",
"private": true,
"type": "module",
"scripts": {
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sentinel0",
"version": "0.0.4",
"version": "0.0.5",
"description": "Trigger Hermes agents from your tickets and pull requests.",
"type": "module",
"keywords": [
Expand Down Expand Up @@ -35,8 +35,8 @@
"dependencies": {
"@clack/prompts": "1.4.0",
"@fastify/cors": "11.2.0",
"@sentinel0/common": "0.0.4",
"@sentinel0/orchestrator": "0.0.4",
"@sentinel0/common": "0.0.5",
"@sentinel0/orchestrator": "0.0.5",
"chalk": "4",
"fastify": "5.7.4",
"p-limit": "6.1.0",
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
LogsCommandOptions,
RunCommandOptions,
RunnerCommandOptions,
ExplainCommandOptions,
RunsCommandOptions,
StartCommandOptions,
} from './types.js'
Expand Down Expand Up @@ -110,6 +111,11 @@ export function parseRunsOptions(args: string[]): RunsCommandOptions {
}
}

export function parseExplainOptions(args: string[]): ExplainCommandOptions {
assertKnownFlags(args, ['--project', '--ref'], 'explain')
return { projectId: valueOf(args, '--project'), ref: valueOf(args, '--ref') }
}

export function parseRunOptions(args: string[]): RunCommandOptions {
assertKnownFlags(args, ['--agent', '--prompt', '--timeout'], 'run')
const agent = valueOf(args, '--agent')
Expand Down
107 changes: 107 additions & 0 deletions packages/cli/src/commands/explain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import chalk from 'chalk'
import type { TriggerChanges, TriggerType } from '@sentinel0/common'
import { ApiError, getJson, runnerUnreachable } from '../api.js'
import type { CliContext, ExplainCommandOptions } from '../types.js'

interface Verdict {
routeId: string
routeName: string
matched: boolean
reason?: string
}

interface ExplainItem {
ref: string
type: TriggerType
projectId: string
title: string
url?: string
labels: string[]
assignees: string[]
requestedReviewers: string[]
changes?: TriggerChanges
verdicts: Verdict[]
}

/** Only the transitions that actually happened, so an empty cycle stays quiet. */
function describeChanges(changes: TriggerChanges | undefined): string {
if (!changes) {
return chalk.yellow('never polled before — transition rules cannot match yet')
}
const parts = [
['+label', changes.labelsAdded],
['-label', changes.labelsRemoved],
['+assignee', changes.assigneesAdded],
['-assignee', changes.assigneesRemoved],
['+reviewer', changes.reviewersAdded],
] as const

const moved = parts
.filter(([, values]) => values.length > 0)
.map(([name, values]) => `${name} ${values.join(', ')}`)

return moved.length > 0 ? moved.join(' ') : chalk.dim('nothing changed since the last poll')
}

export async function runExplain(
context: CliContext,
options: ExplainCommandOptions
): Promise<void> {
const apiBase = await context.resolveDefaultApiBase()
const query = new URLSearchParams()
if (options.projectId) {
query.set('project', options.projectId)
}
if (options.ref) {
query.set('ref', options.ref)
}

const suffix = query.size > 0 ? `?${query}` : ''
const { items } = await getJson<{ items: ExplainItem[] }>(
`${apiBase}/routes/explain${suffix}`
).catch((error: unknown) => {
// An unknown project, or a tracker that will not answer, is the runner
// explaining itself and worth passing through verbatim. Anything else means
// it is not there at all, which needs the other sentence entirely.
if (error instanceof ApiError) {
throw error
}
throw runnerUnreachable(apiBase)
})

if (items.length === 0) {
console.log(chalk.yellow('\n No triggers are visible right now.'))
console.log(chalk.dim(' sentinel0 projects check what this runner is polling\n'))
return
}

console.log('')
for (const item of items) {
const matched = item.verdicts.filter((verdict) => verdict.matched)
const mark = matched.length > 0 ? chalk.green('*') : chalk.dim('-')

console.log(` ${mark} ${chalk.bold(item.ref)} ${chalk.dim(item.type)} ${item.title}`)
console.log(chalk.dim(` changed ${describeChanges(item.changes)}`))
if (item.requestedReviewers.length > 0) {
console.log(chalk.dim(` reviewers ${item.requestedReviewers.join(', ')}`))
}

for (const verdict of item.verdicts) {
if (verdict.matched) {
console.log(
chalk.green(` matches ${verdict.routeName} ${chalk.dim(`(${verdict.routeId})`)}`)
)
} else {
console.log(chalk.dim(` skips ${verdict.routeName}: ${verdict.reason}`))
}
}
console.log('')
}

// A matching route is not a dispatched one, and conflating the two sends
// people back to the rule engine to debug a busy agent.
console.log(
chalk.dim(' A matching route still defers if its agent is busy, or is a no-op if it')
)
console.log(chalk.dim(' already fired for this revision. sentinel0 logs shows which.\n'))
}
4 changes: 4 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
parseLogsOptions,
parseRunOptions,
parseRunnerOptions,
parseExplainOptions,
parseRunsOptions,
parseStartOptions,
resolvePath,
Expand All @@ -37,6 +38,7 @@ import { runRestart } from './commands/restart.js'
import { runRoutes } from './commands/routes.js'
import { runRunner } from './commands/runner.js'
import { runRuns } from './commands/runs.js'
import { runExplain } from './commands/explain.js'
import { runSmokeTest } from './commands/run.js'
import { runStart } from './commands/start.js'
import { runStatus } from './commands/status.js'
Expand Down Expand Up @@ -160,6 +162,8 @@ async function dispatch(command: string | undefined, args: string[]): Promise<vo
return runRoutes(context)
case 'runs':
return runRuns(context, parseRunsOptions(args))
case 'explain':
return runExplain(context, parseExplainOptions(args))
case 'logs':
return runLogs(context, parseLogsOptions(args))
case 'cancel':
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type StartCommandOptions = {

export type LogsCommandOptions = { runId?: string; follow: boolean }
export type RunsCommandOptions = { status?: string; limit: number }
export type ExplainCommandOptions = { projectId?: string; ref?: string }
export type RunCommandOptions = { agent: string; prompt: string; timeoutSeconds: number }
export type CancelCommandOptions = { runId: string }
export type ApproveCommandOptions = { runId: string; choice: ApprovalChoice }
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export function printUsage(version: string): void {
' approve <run-id> [--deny] answer an agent waiting for permission',
'',
chalk.bold(' Debugging'),
' explain [--project <id>] [--ref <owner/repo#n>]',
chalk.dim(' why each route did or did not match'),
' run --agent <profile> --prompt "..."',
chalk.dim(' send one prompt straight to Hermes'),
'',
Expand Down
2 changes: 1 addition & 1 deletion packages/cloud-api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@sentinel0/cloud-api",
"version": "0.0.4",
"version": "0.0.5",
"private": true,
"type": "module",
"main": "dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/cloud-dashboard/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@sentinel0/cloud-dashboard",
"version": "0.0.4",
"version": "0.0.5",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/common/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@sentinel0/common",
"version": "0.0.4",
"version": "0.0.5",
"private": true,
"type": "module",
"main": "./dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/orchestrator/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@sentinel0/orchestrator",
"version": "0.0.4",
"version": "0.0.5",
"private": true,
"type": "module",
"scripts": {
Expand Down
Loading
Loading