diff --git a/docs/skill-routing-and-contracts.md b/docs/skill-routing-and-contracts.md index 021a985eb..1aa1d67af 100644 --- a/docs/skill-routing-and-contracts.md +++ b/docs/skill-routing-and-contracts.md @@ -74,8 +74,8 @@ Every item has an `id` (lowercase letters, digits, `.`, `_`, `-`) and a `kind`, |---|---|---| | `report_labels` | `heading`, `labels[]` | The last assistant message containing the heading has a `Label: value` line for every label, and no value is empty or a placeholder (`[...]`, `<...>`, TBD, TODO, `...`, or a template like `DONE \| BLOCKED`) | | `check_after_last_edit` | optional `match` (regular expression) | No Edit, Write, MultiEdit or NotebookEdit happened, or a Bash test or repro command ran after the last one. By default a command counts when, with quoted text removed, one of its parts (split on `&&`, `\|\|`, `;`, `\|`) starts with a test runner, build tool or interpreter (bun, npm, pytest, go, cargo, make, node, python, curl, ...) or a script whose name says test, spec, repro, check, verify or lint, directly or behind a wrapper (`bundle exec rspec`, `uv run pytest`, `docker compose run app pytest`, `xvfb-run npm test`, `bash scripts/test.sh`). `git commit --no-verify`, `cat test/a.test.ts` and the shell's `test -f` do not count | -| `selected_specialists_dispatched` | optional `marker`, `empty_marker` | The selection line was printed in an assistant message or a Bash result (never a fetched page or file), its names are plain (letters, digits, spaces, `_`, `.`, `-`, at most 40 characters), and each specialist it names has an Agent dispatch that mentions it. The marker is literal text in which a whole-word N stands for a number; the default is `Dispatching N specialists:`. N = 0 (`Dispatching 0 specialists: (small diff)`) or a "specialists skipped" line means zero specialists were selected, which passes | -| `agent_dispatch` | `match` (regular expression, case-insensitive) | An Agent dispatch's description or prompt matches | +| `selected_specialists_dispatched` | optional `marker`, `empty_marker` | The selection line was printed in an assistant message or a Bash result (never a fetched page or file), its names are plain (letters, digits, spaces, `_`, `.`, `-`, at most 40 characters), and each specialist it names has an Agent dispatch that mentions it. The marker is literal text in which a whole-word N stands for a number; the default is `Dispatching N specialists:`. N = 0 (`Dispatching 0 specialists: (small diff)`) or a "specialists skipped" line means zero specialists were selected, which passes. Text in parentheses is not a name (`Dispatching 1 specialists: State Machines (small diff)` selects State Machines) | +| `agent_dispatch` | `match` (regular expression, case-insensitive), optional `marker` | The `marker` line (literal text, like /review's `Dispatching adversarial pass: native`) was printed and an Agent dispatch follows it; otherwise, as the fallback, an Agent dispatch's description or prompt matches `match` | The checker's default marker strings live in one table, `DEFAULT_MATCHERS` in `lib/skill-contract.ts`; a skill's own `marker`, `empty_marker` or `match` overrides them. diff --git a/lib/skill-contract.ts b/lib/skill-contract.ts index dac655d36..9963a6756 100644 --- a/lib/skill-contract.ts +++ b/lib/skill-contract.ts @@ -307,7 +307,7 @@ export function isPlaceholder(value: string): boolean { function parseNames(raw: string): string[] { const end = raw.search(/\.\s|\.$|\s(?:Skipped|Gated)\s*:/i); - return (end < 0 ? raw : raw.slice(0, end)).split(/,|;|\band\b/) + return (end < 0 ? raw : raw.slice(0, end)).replace(/\([^)]*\)/g, ' ').split(/,|;|\band\b/) .map(n => n.replace(/[[\]()"'`*]/g, '').trim()).filter(n => n && !/^none$/i.test(n)); } @@ -366,6 +366,14 @@ function checkItem(item: ContractItem, run: TranscriptEvent[]): ItemCheck { : { met: true, detail: `dispatched ${names.join(', ')}`, fix: '' }; } case 'agent_dispatch': { + if (item.marker) { + const marker = markerPattern(item.marker); + let markedAt = -1; + run.forEach((e, i) => { if (e.kind === 'text' && marker.test(e.text)) markedAt = i; }); + if (markedAt >= 0 && run.slice(markedAt + 1).some(e => isToolUse(e) && AGENT_TOOLS.test(e.name))) { + return { met: true, detail: `printed "${item.marker}", then an Agent dispatch`, fix: '' }; + } + } const re = new RegExp(item.match as string, 'i'); return agents.some(a => re.test(agentText(a))) ? { met: true, detail: `an Agent dispatch matches /${item.match}/`, fix: '' } diff --git a/test/helpers/skill-contract-fixture.ts b/test/helpers/skill-contract-fixture.ts index 505583801..b8a834480 100644 --- a/test/helpers/skill-contract-fixture.ts +++ b/test/helpers/skill-contract-fixture.ts @@ -44,7 +44,7 @@ description: Fixture copy for the contract tests. (gstack) triggers: - review this pr - check my diff -contract: { version: 1, items: [ { id: specialists, kind: selected_specialists_dispatched, marker: "Dispatching N specialists:" }, { id: adversarial, kind: agent_dispatch, match: "Think like an attacker and a chaos engineer" }, { id: report, kind: report_labels, heading: Pre-Landing Review, labels: [Gate edits] } ] } +contract: { version: 1, items: [ { id: specialists, kind: selected_specialists_dispatched, marker: "Dispatching N specialists:" }, { id: adversarial, kind: agent_dispatch, marker: "Dispatching adversarial pass: native", match: "Think like an attacker and a chaos engineer" }, { id: report, kind: report_labels, heading: Pre-Landing Review, labels: [Gate edits] } ] } --- # /review (fixture) `, diff --git a/test/skill-contract.test.ts b/test/skill-contract.test.ts index bc1cd0e22..0ccdad147 100644 --- a/test/skill-contract.test.ts +++ b/test/skill-contract.test.ts @@ -110,9 +110,16 @@ describe('checkContract is a pure function of the transcript', () => { expect(r2.met).toBe(true); expect(check([slash('review'), say('Small diff (12 lines) — specialists skipped.'), ...red, report], 'review').met).toBe(true); expect(check([slash('review'), say('Dispatching 0 specialists: (small diff)'), ...red, report], 'review').met).toBe(true); + const sm = say('Dispatching 1 specialists: State Machines (small diff)'); + expect(check([slash('review'), sm, ...red, report], 'review').items[0].detail).toBe('selected State Machines; no Agent dispatch for State Machines'); + expect(check([slash('review'), sm, ...agent('s', 'State Machines specialist'), ...red, report], 'review').met).toBe(true); expect(check([slash('review'), say('Dispatching 2\nspecialists: Testing, Security'), ...red, report], 'review').items[0].detail).toBe('selected Testing, Security; no Agent dispatch for Testing, Security'); expect(DEFAULT_MATCHERS).toEqual({ specialistSelection: 'Dispatching N specialists:', specialistsSkipped: 'specialists skipped' }); expect(check([slash('review'), ...red, report], 'review').items[0].detail).toBe('no specialist selection line found'); + const fixedLine = say('Dispatching adversarial pass: native'); + expect(check([slash('review'), fixedLine, ...agent('d', 'Adversarial pass', 'Find the ways this diff fails.')], 'review').items[1]).toMatchObject({ status: 'met', detail: 'printed "Dispatching adversarial pass: native", then an Agent dispatch' }); + expect(check([slash('review'), ...agent('d', 'Adversarial pass', 'Find the ways this diff fails.'), fixedLine], 'review').items[1].status).toBe('missing'); + expect(check([slash('review'), ...red], 'review').items[1].status).toBe('met'); expect(check([slash('review'), sel, say('Pre-Landing Review: done')], 'review').items[2]).toMatchObject({ status: 'missing', detail: 'Pre-Landing Review lacks a real value for Gate edits' }); });