create-agent-rig 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +152 -1
  2. package/README.md +99 -4
  3. package/package.json +4 -3
  4. package/packages/cli/dist/commands/init.js +43 -7
  5. package/packages/cli/dist/commands/memory.js +182 -0
  6. package/packages/cli/dist/commands/setup.js +45 -0
  7. package/packages/cli/dist/commands/upgrade.js +22 -4
  8. package/packages/cli/dist/index.js +110 -3
  9. package/packages/cli/dist/lib/manifest.js +23 -5
  10. package/packages/cli/dist/lib/subsystems.js +269 -0
  11. package/packages/cli/dist/lib/version.js +15 -0
  12. package/packages/cli/dist/policy/benchmark/corpus.js +165 -0
  13. package/packages/cli/dist/policy/core/coverage.js +253 -0
  14. package/packages/cli/dist/policy/core/decision-record.js +130 -44
  15. package/packages/cli/dist/policy/core/declaration.js +58 -17
  16. package/packages/cli/dist/policy/core/evidence-matrix.js +94 -0
  17. package/packages/cli/dist/policy/core/probe.js +442 -0
  18. package/packages/cli/dist/policy/core/validation.js +194 -1
  19. package/packages/cli/dist/policy/core/vocabulary.js +70 -3
  20. package/packages/cli/dist/policy/harness/claude.js +9 -1
  21. package/packages/cli/dist/policy/harness/codex.js +48 -1
  22. package/packages/cli/dist/policy/harness/shared-hooks.js +18 -0
  23. package/packages/cli/dist/policy/index.js +9 -2
  24. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +2 -0
  25. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +2 -0
  26. package/templates/agent-os/subagent-routing.json +32 -0
  27. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +41 -4
  28. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +2 -0
  29. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +2 -0
  30. package/templates/agent-os/universal/.claude/agents/security-scanner.md +2 -0
  31. package/templates/agent-os/universal/.claude/agents/test-writer.md +2 -0
  32. package/templates/agent-os/universal/.claude/hooks/guard-subagent-model.mjs +234 -0
  33. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +75 -32
  34. package/templates/agent-os/universal/.claude/hooks/warn-subagent-routing.mjs +120 -0
  35. package/templates/agent-os/universal/.claude/rules/workflow.md +5 -0
  36. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -3
  37. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +43 -0
  38. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +70 -2
  39. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +12 -4
  40. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +18 -1
  41. package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +269 -32
  42. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +64 -1
  43. package/templates/agent-os/universal/.claude/settings.json +16 -0
  44. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +41 -4
  45. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -0
  46. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +2 -0
  47. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +2 -0
  48. package/templates/agent-os/universal/.codex/agents/test-writer.toml +2 -0
  49. package/templates/agent-os/universal/.codex/config.toml +3 -0
  50. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +31 -5
  51. package/templates/agent-os/universal/docs/decisions/subagent-routing.md +142 -0
  52. package/templates/agent-os/universal/layers.json +4 -0
  53. package/templates/hash-history.json +71 -22
  54. package/templates/release-ledger.json +3 -1
  55. package/templates/skeleton/node-service/services/api/test/artifact.test.ts +3 -4
  56. package/templates/skeleton/node-service/services/api/test/package-manager.test.ts +40 -0
  57. package/templates/skeleton/node-service/services/api/test/package-manager.ts +51 -0
  58. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +9 -8
@@ -16,9 +16,13 @@
16
16
  const closed = (values) => Object.freeze(values);
17
17
  /**
18
18
  * Whether a declared policy can actually be enforced on a given harness
19
- * surface. `UNSUPPORTED` and `INTEGRATION-FAILED` never yield a silent pass:
20
- * a decision record carrying either must qualify its verdict `UNVERIFIABLE`
21
- * (`./decision-record.ts`). The four states are defined here.
19
+ * surface. `UNSUPPORTED` and `INTEGRATION-FAILED` must never yield a silent
20
+ * pass: a decision record carrying either has to qualify its verdict
21
+ * `UNVERIFIABLE` (`./decision-record.ts`). The four states are defined here.
22
+ *
23
+ * ⚠ That is the rule, and the enforcement behind it is narrower than the rule
24
+ * — see `UNENFORCEABLE_STATES` below, which states the gap once for every
25
+ * reader of this file.
22
26
  */
23
27
  export const CAPABILITY_STATES = closed([
24
28
  'SUPPORTED',
@@ -26,6 +30,69 @@ export const CAPABILITY_STATES = closed([
26
30
  'UNSUPPORTED',
27
31
  'INTEGRATION-FAILED',
28
32
  ]);
33
+ /**
34
+ * The states under which no question was actually put to a working mechanism.
35
+ *
36
+ * One spelling of one fact (`rules/invariants.md`, "One mechanism, one
37
+ * implementation"): `./decision-record.ts` refuses an unqualified verdict
38
+ * carrying one of these, and `./coverage.ts` › `qualifierFor` returns
39
+ * `UNVERIFIABLE` for exactly the same set. Two copies would disagree, and the
40
+ * one nobody is looking at would be the one that let a silent pass through.
41
+ *
42
+ * ⚠ **"Refuses" now holds against a hand-built prototype too** — this note used
43
+ * to say the opposite, and this is the file a reader auditing that question
44
+ * lands on first, so it is corrected here rather than only where the fix landed.
45
+ * `./decision-record.ts` used to decide whether a verdict carries a qualifier
46
+ * with the `in` operator, so a verdict INHERITING one satisfied the check and
47
+ * then serialised without it. RP-153 closed that: every field of both
48
+ * `./decision-record.ts` and `./declaration.ts` is read through
49
+ * `carriesField`/`ownField`, the way `./probe.ts` and `./evidence-matrix.ts`
50
+ * already read theirs, and both uncarried shapes are pinned in
51
+ * `packages/cli/test/policy-declaration.test.ts` › "refuses an UNSUPPORTED
52
+ * record whose verdict qualifier is %s, because what it writes out is a silent
53
+ * pass" — `%s` as the `it.each` case declares it, so one grep lands on it.
54
+ *
55
+ * What remains is narrower and is NOT this sentence's subject: a value carrying
56
+ * a live accessor is validated on one read and serialised from another (RP-157),
57
+ * and an array HOLE serialises as `null` while the two `forEach` loops in
58
+ * `./decision-record.ts` skip it (RP-161) — those two only, since `members` in
59
+ * `./validation.ts` and `./probe.ts` iterate with `for…of`, which sees a hole as
60
+ * `undefined` and refuses it. `docs/decisions/capability-coverage.md`, "What
61
+ * this does NOT do", carries the current limits at length.
62
+ *
63
+ * It is deliberately NOT derived from a rank or an ordering. `coverage.ts`
64
+ * carries an enforcement ordering for deciding what counts as a downgrade;
65
+ * keying verdict qualification off that would mean a future re-rank silently
66
+ * changed which verdicts are unverifiable.
67
+ */
68
+ export const UNENFORCEABLE_STATES = closed(['UNSUPPORTED', 'INTEGRATION-FAILED']);
69
+ /**
70
+ * The two ways a capability status is established, and the only two.
71
+ *
72
+ * `probe` is one active read of the surface's own wiring, taken when the
73
+ * surface changes (`PROBE_TRIGGERS`). `traffic` is passive: an operation that
74
+ * was expected to produce an observable signal, and what it actually produced.
75
+ * There is deliberately no third source meaning "time passed" — silence is
76
+ * absence of evidence, not evidence of absence.
77
+ */
78
+ export const VERIFICATION_SOURCES = closed(['probe', 'traffic']);
79
+ /**
80
+ * The occasions on which a surface is probed. Every member is an event on the
81
+ * surface; none of them is an interval.
82
+ *
83
+ * What this vocabulary does, exactly: `./coverage.ts` › `coverageFromProbe`
84
+ * takes a trigger as a required argument and refuses a word outside this list,
85
+ * so a probe must NAME its occasion and cannot name a schedule — ›
86
+ * "refuses the trigger %j, because the coverage contract accepts only a
87
+ * declared surface-change trigger".
88
+ *
89
+ * ⚠ What it does NOT do, stated because an earlier draft of this comment
90
+ * claimed it: nothing here stops a caller passing `'upgrade'` on a timer. The
91
+ * check refuses a LABEL outside the vocabulary, not the practice of probing
92
+ * periodically. `docs/decisions/capability-coverage.md` §1 says the same, and
93
+ * this file used to contradict it.
94
+ */
95
+ export const PROBE_TRIGGERS = closed(['install', 'upgrade', 'registration', 'reconnect']);
29
96
  /** The autonomy tiers of `rules/autonomy.md`; `never` is the tier the guards enforce. */
30
97
  export const AUTONOMY_TIERS = closed(['tier-0', 'tier-1', 'tier-2', 'never']);
31
98
  /** The operations a policy can apply to, named by what the agent does, not by a tool. */
@@ -19,7 +19,7 @@
19
19
  * shipped scripts, and `test/template/shell-tools.test.ts` holds that
20
20
  * correspondence.
21
21
  */
22
- import { SHARED_HOOKS_DIR } from './shared-hooks.js';
22
+ import { SHARED_HOOK_ROOT_ENV, SHARED_HOOKS_DIR } from './shared-hooks.js';
23
23
  const EVENT_OF = {
24
24
  'before-operation': 'PreToolUse',
25
25
  };
@@ -31,6 +31,14 @@ export const nativeSurfaceOf = (policy) => ({
31
31
  event: EVENT_OF[policy.timing],
32
32
  matcher: policy.operations.map((operation) => MATCHER_OF[operation]).join('|'),
33
33
  hookPath: `${SHARED_HOOKS_DIR}/${policy.mechanism}.mjs`,
34
+ // The exact command this harness generates for a hook. The probe compares
35
+ // against this rather than parsing what it finds, so this string and the one
36
+ // in the shipped snapshot must agree — pinned in both directions by
37
+ // `test/template/policy-coverage.test.ts` (absent in a generated rig) ›
38
+ // "the %s snapshot wires %s with exactly the command that adapter generates".
39
+ commands: {
40
+ command: [`node "$${SHARED_HOOK_ROOT_ENV}/${SHARED_HOOKS_DIR}/${policy.mechanism}.mjs"`],
41
+ },
34
42
  });
35
43
  export const claudeAdapter = Object.freeze({
36
44
  harness: 'claude',
@@ -20,7 +20,7 @@
20
20
  * restated, for the same reason the snapshot is derived rather than
21
21
  * hand-written: one spelling of one fact.
22
22
  */
23
- import { SHARED_HOOKS_DIR } from './shared-hooks.js';
23
+ import { SHARED_HOOK_ROOT_ENV, SHARED_HOOKS_DIR } from './shared-hooks.js';
24
24
  const EVENT_OF = {
25
25
  'before-operation': 'PreToolUse',
26
26
  };
@@ -28,10 +28,57 @@ const MATCHER_OF = {
28
28
  'file-edit': 'Write|Edit|MultiEdit|NotebookEdit|apply_patch',
29
29
  'shell-command': 'Bash|PowerShell',
30
30
  };
31
+ /**
32
+ * The Windows spelling this harness generates for a hook.
33
+ *
34
+ * ⚠ A SECOND implementation of the wrapper that `scripts/sync-codex-adapter.mjs`
35
+ * writes into the shipped file, and deliberately so: that script is a build tool
36
+ * outside the published package, and this module may not import it. The two are
37
+ * held equal by a correspondence check that goes red in BOTH directions —
38
+ * `test/template/policy-coverage.test.ts` (absent in a generated rig) › "the %s
39
+ * snapshot wires %s with exactly the spelling that adapter generates, in every
40
+ * field it generates one for" — which is what `rules/invariants.md` ("One
41
+ * mechanism, one implementation") requires of a copy that has to stay.
42
+ *
43
+ * The wrapper exists because PowerShell owns its stdin, so the hook would
44
+ * receive an empty stream; it copies the original bytes into the child instead
45
+ * of re-encoding them.
46
+ */
47
+ const windowsCommand = (hookPath) => {
48
+ const script = [
49
+ "$ErrorActionPreference = 'Stop'",
50
+ '$repoRoot = git rev-parse --show-toplevel',
51
+ 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }',
52
+ `$env:${SHARED_HOOK_ROOT_ENV} = $repoRoot`,
53
+ `$hookPath = Join-Path $repoRoot '${hookPath}'`,
54
+ '$startInfo = New-Object System.Diagnostics.ProcessStartInfo',
55
+ "$startInfo.FileName = 'node'",
56
+ "$startInfo.Arguments = '\"' + $hookPath + '\"'",
57
+ '$startInfo.UseShellExecute = $false',
58
+ '$startInfo.RedirectStandardInput = $true',
59
+ '$child = [System.Diagnostics.Process]::Start($startInfo)',
60
+ '[Console]::OpenStandardInput().CopyTo($child.StandardInput.BaseStream)',
61
+ '$child.StandardInput.Close()',
62
+ '$child.WaitForExit()',
63
+ 'exit $child.ExitCode',
64
+ ].join('; ');
65
+ const encoded = Buffer.from(script, 'utf16le').toString('base64');
66
+ return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encoded}`;
67
+ };
31
68
  export const nativeSurfaceOf = (policy) => ({
32
69
  event: EVENT_OF[policy.timing],
33
70
  matcher: policy.operations.map((operation) => MATCHER_OF[operation]).join('|'),
34
71
  hookPath: `${SHARED_HOOKS_DIR}/${policy.mechanism}.mjs`,
72
+ // Both commands this harness generates. It runs the first on POSIX and the
73
+ // second on Windows, so the probe has to know both: replacing only the
74
+ // Windows spelling in the shipped file used to leave every policy reading
75
+ // SUPPORTED while the guard no longer ran there.
76
+ commands: {
77
+ command: [
78
+ `repoRoot="$(git rev-parse --show-toplevel)" && ${SHARED_HOOK_ROOT_ENV}="$repoRoot" node "$repoRoot/${SHARED_HOOKS_DIR}/${policy.mechanism}.mjs"`,
79
+ ],
80
+ commandWindows: [windowsCommand(`${SHARED_HOOKS_DIR}/${policy.mechanism}.mjs`)],
81
+ },
35
82
  });
36
83
  export const codexAdapter = Object.freeze({
37
84
  harness: 'codex',
@@ -8,3 +8,21 @@
8
8
  * implementation").
9
9
  */
10
10
  export const SHARED_HOOKS_DIR = '.claude/hooks';
11
+ /**
12
+ * The environment variable a shared hook reads when it needs the repository
13
+ * root, and therefore the one each harness sets when it runs one.
14
+ *
15
+ * Not every hook needs it: of the eight this rig ships, `guard-rulebook` and
16
+ * `guard-secret-file` read it, both falling back to the working directory.
17
+ * The variable is still part of the wiring contract, because the harness sets
18
+ * it for whichever hook it runs.
19
+ *
20
+ * It carries a harness's name for the same historical reason `.claude/hooks`
21
+ * does — the hooks are shared, so both harnesses speak this one variable — and
22
+ * this module is the one adapter-side file allowed to spell that name for both
23
+ * (`test/template/policy-declaration.test.ts` › "claude is named only by its
24
+ * own adapter, the shared hooks directory and the adapter index"). Stating it
25
+ * here is what lets the other harness's adapter build its generated command
26
+ * without naming a harness that is not its own.
27
+ */
28
+ export const SHARED_HOOK_ROOT_ENV = 'CLAUDE_PROJECT_DIR';
@@ -1,10 +1,17 @@
1
1
  /**
2
2
  * The policy declaration, registry, decision-record schema and harness
3
- * adapters (RP-76). Library surface only — nothing here is reached by the CLI
4
- * commands yet; emitting decision records at runtime is a separate task.
3
+ * adapters (RP-76), plus the capability & degradation contract that says what
4
+ * each of them is worth on a given surface (RP-36).
5
+ *
6
+ * Library surface only — nothing here is reached by the CLI commands yet;
7
+ * emitting decision records at runtime, and rendering the coverage report in
8
+ * `doctor`, are separate tasks.
5
9
  */
6
10
  export * from './core/vocabulary.js';
7
11
  export * from './core/declaration.js';
8
12
  export * from './core/registry.js';
9
13
  export * from './core/decision-record.js';
14
+ export * from './core/probe.js';
15
+ export * from './core/coverage.js';
16
+ export * from './core/evidence-matrix.js';
10
17
  export * from './harness/index.js';
@@ -2,6 +2,8 @@
2
2
  name: cdk-diff-reviewer
3
3
  description: Reviews an infrastructure change via `cdk diff` BEFORE any deploy. MUST run on every change under infra/ — a deploy without this review is a Never-tier action. Read-only; findings gate the deploy.
4
4
  tools: Read, Grep, Glob, Bash
5
+ model: claude-opus-5
6
+ effort: high
5
7
  ---
6
8
 
7
9
  You review what a deploy would actually do to running infrastructure. Your
@@ -1,4 +1,6 @@
1
1
  name = "cdk-diff-reviewer"
2
2
  description = "Reviews an infrastructure change via `cdk diff` BEFORE any deploy. MUST run on every change under infra/ — a deploy without this review is a Never-tier action. Read-only; findings gate the deploy."
3
+ model = "gpt-5.6-sol"
4
+ model_reasoning_effort = "high"
3
5
  sandbox_mode = "read-only"
4
6
  developer_instructions = "You review what a deploy would actually do to running infrastructure. Your\ninput is the change under `infra/` **and** the synthesized diff (`cdk diff`,\nrun it yourself); your output is a verdict. You never fix and never deploy.\n\n## How you work\n\n1. Run `cdk diff` (and read the changed `infra/` sources for intent). The diff\n is the truth: review what CloudFormation will do, not what the TypeScript\n looks like it does.\n2. Walk every resource change and flag it **by named rule** (below). Findings\n come as **BLOCKERS first, then nits** — one list each, with the resource\n and the rule it violates.\n3. Your message IS the review, not a summary of it: every finding carries the\n resource, the change, the rule, and the smallest fix. End with the verdict\n block below — `SHIP` where `DEPLOY: OK` used to be, `HOLD` where\n `DEPLOY: BLOCKED` did.\n\n## Named rules — blockers\n\n- **IAM broadening.** Any policy gaining actions, resources widening to `*`,\n or a grant that outruns what a usecase does today. Least privilege is added\n in the same PR as the need, never \"for later\".\n- **Data loss paths.** A stateful resource (table, bucket, queue) being\n replaced, deleted, or flipping its RemovalPolicy toward DESTROY.\n Logical-id renames on stateful resources are replacements in disguise.\n- **Safety-net removal.** A DLQ detached, an alarm deleted or loosened, a\n retry budget widened to infinity, a dead-letter retention shortened.\n- **Blast-radius growth.** New public surface (endpoints, permissions to\n external principals), broadened network access, cross-stack exports that\n make future changes harder to reverse.\n- **Cost-relevant flips.** On-demand → provisioned capacity, log retention to\n \"forever\", memory/timeout jumps with no stated reason.\n\n## Nits (report, do not block)\n\nNaming drift, missing descriptions, constructs that could use the narrower\ngrant helper, duplication between stacks.\n\n## Boundaries\n\n- Read-only: you run `cdk diff` and read code; you never run `cdk deploy`,\n never edit files, never mutate AWS state.\n- An empty diff is a real finding too — say \"no infrastructure change\" and\n return `SHIP`, so the gate leaves a trace either way.\n\n## The verdict block\n\nEnd your report with **exactly one** fenced `json` block of this shape, and\nnothing after it. It is what the calling gate reads.\n\n```json\n{\n \"gate\": \"cdk-diff-reviewer\",\n \"verdict\": \"HOLD\",\n \"blockers\": [\n {\n \"file\": \"infra/lib/api-stack.ts\",\n \"line\": 88,\n \"rule\": \"data loss\",\n \"note\": \"the table's RemovalPolicy went to DESTROY — replacement drops it\"\n }\n ],\n \"advisories\": [],\n \"evidence\": [\"cdk diff against the deployed stage\"],\n \"headSha\": \"9c1f0a7d4b3e2c5a8f6d0b9e7c4a1f2d3e5b6c70\"\n}\n```\n\n- `verdict` is `SHIP` (nothing blocking, including an empty diff), `HOLD`, or\n `NOT_APPLICABLE` when the change touches no infrastructure at all.\n- Every blocker names the `rule` it violates, with `file` and `line` when it has\n a location and neither when it does not.\n- A `HOLD` naming no blocker is **refused**, and so is a `SHIP` carrying one:\n `node .claude/scripts/verdict.mjs check <report> cdk-diff-reviewer` is what\n refuses them.\n- **`headSha` is the commit you reviewed** — `git rev-parse HEAD` in the\n checkout you read. It is what lets `node .claude/scripts/verdict.mjs coverage\n <commit>` tell \"this gate answered for the commit being merged\" from \"it\n answered two pushes ago\". A verdict naming no commit is counted as neither\n covered nor missing, so `pr-ship` holds on it — and only `pr-ship`: no hook\n runs that check, so a session that skips the gate skips this with it."
@@ -0,0 +1,32 @@
1
+ {
2
+ "claudeModels": {
3
+ "claude-opus-5": ["low", "medium", "high", "xhigh", "max"],
4
+ "claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"]
5
+ },
6
+ "unnamed": {
7
+ "claude": { "model": "claude-sonnet-5" },
8
+ "codex": { "model": "gpt-5.6-terra", "effort": "medium" }
9
+ },
10
+ "roles": {
11
+ "test-writer": {
12
+ "claude": { "model": "claude-sonnet-5", "effort": "high" },
13
+ "codex": { "model": "gpt-5.6-terra", "effort": "high" }
14
+ },
15
+ "prose-reviewer": {
16
+ "claude": { "model": "claude-sonnet-5", "effort": "high" },
17
+ "codex": { "model": "gpt-5.6-terra", "effort": "high" }
18
+ },
19
+ "code-reviewer": {
20
+ "claude": { "model": "claude-opus-5", "effort": "high" },
21
+ "codex": { "model": "gpt-5.6-sol", "effort": "high" }
22
+ },
23
+ "security-scanner": {
24
+ "claude": { "model": "claude-opus-5", "effort": "high" },
25
+ "codex": { "model": "gpt-5.6-sol", "effort": "high" }
26
+ },
27
+ "cdk-diff-reviewer": {
28
+ "claude": { "model": "claude-opus-5", "effort": "high" },
29
+ "codex": { "model": "gpt-5.6-sol", "effort": "high" }
30
+ }
31
+ }
32
+ }
@@ -79,16 +79,23 @@ state-vs-queue split exists to prevent.
79
79
  node .claude/scripts/preflight.mjs
80
80
  ```
81
81
 
82
- Five items are scripted (kill switch absent · `RIG_RUN_DIR` not already
83
- exported · the versioned revalidation detection contract is supported · local
84
- default branch matches the remote · the last deploy concluded successfully)
82
+ Scripted checks cover the kill switch, inherited `RIG_RUN_DIR`, the versioned
83
+ revalidation detection contract, queue readability through its configured adapter,
84
+ default-branch freshness, and the last deploy result,
85
85
  and the script **prints the ones it did not check, every time**. Paste the block into the journal: a checklist that
86
86
  leaves no record cannot tell you it was skipped.
87
87
 
88
88
  Verdicts: **STOP** → do not start, deal with the cause. **CAUTION** → start,
89
- knowing which ground is soft. **GO** → the scripted five are clean; the rest are
89
+ knowing which ground is soft. **GO** → the scripted checks are clean; the rest are
90
90
  still yours.
91
91
 
92
+ The queue probe reads one adapter listing without selecting or claiming an item.
93
+ A readable empty queue passes this probe; a configuration, adapter, or queue-read
94
+ failure produces **STOP**. See the generator's `test/template/preflight-queue.test.ts`
95
+ (absent in a generated rig) › "reads exactly one adapter listing without selecting, claiming, or writing queue and run files",
96
+ › "passes a readable empty queue without changing the other preflight verdict or queue state",
97
+ and › "stops when %s cannot be read".
98
+
92
99
  **An `unknown` never becomes a `pass`.** A probe that could not run tells you
93
100
  nothing.
94
101
 
@@ -149,8 +156,38 @@ What a hook CAN see is a file, so the unattended signal is one:
149
156
  # at claim time, from the paths the item names (repo-relative prefixes, with
150
157
  # their trailing slash); the guard refuses every other rulebook edit while it is on
151
158
  node .claude/scripts/unattended-flag.mjs on --root "$PWD" --item <item-id> --run-dir "$RIG_RUN_DIR" --allow <prefix> [<prefix>…]
159
+
160
+ # 🔴 THEN READ IT BACK, and stop the run if it is not armed. Not optional.
161
+ node .claude/scripts/unattended-flag.mjs verify --root "$PWD" --item <item-id>
152
162
  ```
153
163
 
164
+ 🔴 **The second command is the one that makes the first one's failure
165
+ visible, and skipping it inverts the whole mechanism.** `on` refuses an allow
166
+ entry that *widens* the rulebook — and the refusal leaves **no flag on disk**
167
+ (pinned in the generator's `test/template/unattended-flag.test.ts`, absent in a
168
+ generated rig, › "does not change `on`: a widening --allow still exits 1 and
169
+ still writes no flag"), while `guard-rulebook` reads an absent flag as an
170
+ attended session and refuses nothing. So a run that armed with a widening entry and did not check is the
171
+ **least** constrained run this project can produce: every rule, hook, skill and
172
+ settings path editable, with nothing downstream saying so. The failure is loud
173
+ for one line at claim time and silent for the rest of the session.
174
+
175
+ `verify` exits non-zero when no usable flag is armed for this item — absent,
176
+ unreadable, or naming a different item — and its message says the run is
177
+ unguarded rather than merely that a file is missing. **A non-zero exit here ends
178
+ the run**; it does not get retried with a wider allow-list. The natural way to
179
+ hit this is not exotic: an item touching the queue adapter invites `--allow
180
+ .claude/scripts/`, and that entry is refused outright. Pinned in the
181
+ generator's `test/template/unattended-flag.test.ts` (absent in a generated rig)
182
+ › "refuses when no flag is armed, naming the item and the unguarded rulebook"
183
+ and › "refuses when the armed flag names a different item, naming both".
184
+
185
+ ⚠ **What this does not close.** `verify` is mechanical where it runs; that it
186
+ runs is this sentence. It removes the silence, not the possibility that a run
187
+ ignores an exit status — and the hook-enforced version is not available, because
188
+ `guard-rulebook` cannot tell "attended" from "unattended but unarmed": absence of
189
+ a flag is all it sees.
190
+
154
191
  `guard-rulebook` reads it (`.claude/rules/autonomy.md`, "Never"): with the flag
155
192
  on, a Write/Edit/MultiEdit/NotebookEdit/`apply_patch` under the generated
156
193
  rulebook is refused unless its
@@ -2,6 +2,8 @@
2
2
  name: code-reviewer
3
3
  description: Reviews a completed change against the checklist before a PR is opened or merged. Use after any non-trivial implementation work, and always before opening a PR the decision-router puts on its `model` lane, which is everything its two cheap lanes did not claim — code, a rulebook document, an unclassifiable path, a derived artifact git does not report as drift, or anything a risk flag escalated. Blocking findings must be resolved, not argued with.
4
4
  tools: Read, Grep, Glob, Bash
5
+ model: claude-opus-5
6
+ effort: high
5
7
  ---
6
8
 
7
9
  You review changes. You do not fix them — you report, with file:line
@@ -2,6 +2,8 @@
2
2
  name: prose-reviewer
3
3
  description: Reviews the documents that instruct agents — rule files, skills, agent specs, CLAUDE.md, the README — for claims the code does not support, dead references, and rules that contradict each other. Use when a change touches any of them, before the PR.
4
4
  tools: Read, Grep, Glob, Bash
5
+ model: claude-sonnet-5
6
+ effort: high
5
7
  ---
6
8
 
7
9
  In this project the prose **is** the implementation. A rule file is what an agent
@@ -2,6 +2,8 @@
2
2
  name: security-scanner
3
3
  description: Scans a change for security issues. MUST be used when a change touches authentication, authorization, secrets or configuration, input parsing, file handling, or any new outbound call. Findings gate the PR.
4
4
  tools: Read, Grep, Glob, Bash
5
+ model: claude-opus-5
6
+ effort: high
5
7
  ---
6
8
 
7
9
  You are the security gate. You run on changes in sensitive territory and your
@@ -2,6 +2,8 @@
2
2
  name: test-writer
3
3
  description: Writes the failing test BEFORE any implementation exists. Use at the start of every feature, bug fix, or behavior change — the Red step of TDD. Also use to reproduce a reported bug as a test.
4
4
  tools: Read, Grep, Glob, Write, Edit, Bash
5
+ model: claude-sonnet-5
6
+ effort: high
5
7
  ---
6
8
 
7
9
  You write tests that define behavior which does not exist yet. You are the Red
@@ -0,0 +1,234 @@
1
+ // PreToolUse hook (Claude Code's Agent tool): a subagent whose project
2
+ // definition pins its model is never dispatched with a call-site `model`.
3
+ //
4
+ // Why it exists. Claude Code takes a subagent's model from the per-invocation
5
+ // `model` parameter FIRST, and only then from the definition's `model:` line
6
+ // (Claude Code's sub-agents documentation, "model resolution"). A pin is
7
+ // therefore only as strong as every call site: the generator's
8
+ // docs/capability-evidence.json (absent in a generated rig) records a pinned
9
+ // agent that ran the call-site model instead — mechanism `subagent-model-pin`,
10
+ // surface `Agent tool call-site model, without guard-subagent-model`. Which
11
+ // model a role reads with is the routing policy's decision
12
+ // (docs/decisions/subagent-routing.md), not one dispatch's.
13
+ //
14
+ // What "pinned" means here: `<project>/.claude/agents/<subagent_type>.md` is a
15
+ // regular file whose leading frontmatter carries a `model:` line with a value
16
+ // other than `inherit`. The project root is CLAUDE_PROJECT_DIR, else the working
17
+ // directory. One leading byte-order mark is not content and is skipped.
18
+ //
19
+ // The three outcomes (.claude/rules/invariants.md):
20
+ // - allow — nothing it can read, another event, no call-site model, or an
21
+ // ad-hoc subagent: no `subagent_type`, a built-in, a name that is not a plain
22
+ // file name (`plugin:agent`, `../x`), no such file, a path that is not a
23
+ // regular file (a directory, a FIFO — opened without waiting, never read), or
24
+ // no pin in it;
25
+ // - block — a call-site model for a pinned agent;
26
+ // - refuse to inspect — `model` or `subagent_type` PRESENT in a shape other
27
+ // than a string (resend it as one), or a frontmatter that does not close
28
+ // within MAX_HEAD_BYTES (a bound crossed).
29
+ // An error opening or reading the agent file fails open, like every guard here.
30
+ //
31
+ // Bounded work: one non-blocking open, one fstat, one read of at most
32
+ // MAX_HEAD_BYTES + 1 bytes, one frontmatter match, and one pass over its lines
33
+ // with a prefix test per line. Nothing recurses or rescans.
34
+ //
35
+ // Limits, stated so nobody relies on cover that is not here:
36
+ // - project agents only. A user-level or plugin agent that pins a model is not
37
+ // a role of this project, and its call-site model is allowed;
38
+ // - the file is found by the dispatched name, so a project agent whose
39
+ // frontmatter `name` differs from its file name is not matched;
40
+ // - `model:` is read as a frontmatter line, not through a YAML parser, so a pin
41
+ // spelled another YAML way (a quoted key) is not seen.
42
+ // Pinned in the generator's test/template/subagent-routing-hooks.test.ts
43
+ // (absent in a generated rig) › "blocks a call-site model on a project agent
44
+ // that pins one, and says to re-dispatch without it", › "never resolves %s to a
45
+ // project agent file, so the call is allowed", › "refuses an agent file whose
46
+ // frontmatter does not close within the read bound, and names the bound", ›
47
+ // "allows a call-site model without waiting when the agent path is not a regular
48
+ // file", › "reads a pin in an agent file that starts with a byte-order mark", ›
49
+ // "echoes a model pinned in the agent file bounded and escaped" (the bound) and
50
+ // › "escapes a control byte inside the echoed part of both model names".
51
+ import { closeSync, constants, fstatSync, openSync, readSync, realpathSync } from 'node:fs';
52
+ import path from 'node:path';
53
+ import { fileURLToPath } from 'node:url';
54
+ import { readHookInput } from './lib/hook-input.mjs';
55
+
56
+ /** How much of an agent file is read to find its frontmatter. */
57
+ export const MAX_HEAD_BYTES = 64 * 1024;
58
+
59
+ /** How much of a model name a refusal repeats, from either side of the comparison. */
60
+ const MAX_ECHOED_MODEL = 64;
61
+
62
+ /** A name that can only ever be a file directly under `.claude/agents/`. */
63
+ const AGENT_FILE_NAME = /^[A-Za-z0-9_-]{1,128}$/;
64
+
65
+ /**
66
+ * Open without blocking: a FIFO at the agent path would otherwise hold the open
67
+ * until a writer appears. Windows has no such flag and no such file there.
68
+ */
69
+ const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
70
+
71
+ const ALLOW = Object.freeze({ outcome: 'allow' });
72
+
73
+ /** The shape word for a value, bounded: never the value itself. */
74
+ const shapeOf = (value) => {
75
+ if (Array.isArray(value)) return 'an array';
76
+ const type = typeof value;
77
+ return type === 'object' ? 'an object' : `a ${type}`;
78
+ };
79
+
80
+ /**
81
+ * A model name as a refusal may print it: at most MAX_ECHOED_MODEL characters,
82
+ * through JSON.stringify — which escapes C0 control bytes such as ESC, and
83
+ * leaves other non-ASCII characters (C1 controls, bidi overrides) as they are.
84
+ */
85
+ const echoed = (model) => JSON.stringify(model.slice(0, MAX_ECHOED_MODEL));
86
+
87
+ const unreadable = (field, value, expected) => ({
88
+ outcome: 'refuse',
89
+ message:
90
+ `BLOCKED — ${field} is present as ${shapeOf(value)}, and this guard reads ${expected}. ` +
91
+ 'An input it cannot read is refused, never allowed: whether this dispatch overrides a ' +
92
+ 'pinned model is decided by reading it (.claude/rules/invariants.md, "Refusing to ' +
93
+ `inspect is a third outcome"). Resend the Agent call with ${field} as ${expected}.`,
94
+ });
95
+
96
+ /**
97
+ * The first MAX_HEAD_BYTES of a regular file, and whether it went on past them;
98
+ * null when the path is absent, is not a regular file, or cannot be read.
99
+ */
100
+ function readHead(file) {
101
+ let fd;
102
+ try {
103
+ fd = openSync(file, OPEN_FLAGS);
104
+ if (!fstatSync(fd).isFile()) return null;
105
+ const buffer = Buffer.alloc(MAX_HEAD_BYTES + 1);
106
+ let filled = 0;
107
+ while (filled < buffer.length) {
108
+ const read = readSync(fd, buffer, filled, buffer.length - filled, null);
109
+ if (read === 0) break;
110
+ filled += read;
111
+ }
112
+ return {
113
+ text: buffer.subarray(0, Math.min(filled, MAX_HEAD_BYTES)).toString('utf8'),
114
+ cut: filled > MAX_HEAD_BYTES,
115
+ };
116
+ } catch {
117
+ return null;
118
+ } finally {
119
+ if (fd !== undefined) {
120
+ try {
121
+ closeSync(fd);
122
+ } catch {
123
+ // nothing left to release
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ /**
130
+ * `{ kind: 'pinned', model }`, `{ kind: 'none' }`, or `{ kind: 'unbounded' }`
131
+ * when a frontmatter opens and does not close inside the bytes that were read.
132
+ */
133
+ function pinOf({ text: raw, cut }) {
134
+ const text = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
135
+ if (!/^---\r?\n/.test(text)) return { kind: 'none' };
136
+ // A head that was cut may end in the middle of a line, so only a closer
137
+ // followed by a newline counts there; an uncut head may end at the closer.
138
+ const closed = (
139
+ cut
140
+ ? /^---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n/
141
+ : /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/
142
+ ).exec(text);
143
+ if (!closed) return cut ? { kind: 'unbounded' } : { kind: 'none' };
144
+ for (const line of closed[1].split(/\r?\n/)) {
145
+ if (!line.startsWith('model:')) continue;
146
+ const value = line.slice('model:'.length).trim();
147
+ const quoted =
148
+ value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.at(-1) === value[0];
149
+ const model = quoted ? value.slice(1, -1) : value;
150
+ return model !== '' && model !== 'inherit' ? { kind: 'pinned', model } : { kind: 'none' };
151
+ }
152
+ return { kind: 'none' };
153
+ }
154
+
155
+ /** The verdict for one hook payload, judged against the project at `root`. */
156
+ export function judge(input, root) {
157
+ if (input === null || typeof input !== 'object' || input.hook_event_name !== 'PreToolUse') {
158
+ return ALLOW;
159
+ }
160
+ const toolInput = input.tool_input;
161
+ if (toolInput === undefined || toolInput === null) return ALLOW;
162
+ if (typeof toolInput !== 'object' || Array.isArray(toolInput)) {
163
+ return unreadable('tool_input', toolInput, 'an object');
164
+ }
165
+
166
+ const model = toolInput.model;
167
+ if (model === undefined || model === null) return ALLOW;
168
+ if (typeof model !== 'string') return unreadable('tool_input.model', model, 'a string');
169
+ if (model.trim() === '') return ALLOW;
170
+
171
+ const name = toolInput.subagent_type;
172
+ if (name === undefined || name === null) return ALLOW;
173
+ if (typeof name !== 'string') return unreadable('tool_input.subagent_type', name, 'a string');
174
+ if (!AGENT_FILE_NAME.test(name)) return ALLOW;
175
+
176
+ const relative = `.claude/agents/${name}.md`;
177
+ const head = readHead(path.join(root, '.claude', 'agents', `${name}.md`));
178
+ if (head === null) return ALLOW;
179
+ const pin = pinOf(head);
180
+ if (pin.kind === 'unbounded') {
181
+ return {
182
+ outcome: 'refuse',
183
+ message:
184
+ `BLOCKED — ${relative} opens a frontmatter that does not close within the first ` +
185
+ `${MAX_HEAD_BYTES} bytes, the limit this guard reads, so it cannot tell whether ` +
186
+ `\`${name}\` pins its model. Close the frontmatter near the top of the file, or ` +
187
+ 're-dispatch without `model`.',
188
+ };
189
+ }
190
+ if (pin.kind !== 'pinned') return ALLOW;
191
+ return {
192
+ outcome: 'block',
193
+ message:
194
+ `BLOCKED — \`${name}\` pins its model in ${relative} (${echoed(pin.model)}), and this ` +
195
+ `dispatch passes model ${echoed(model)}, which Claude Code would run instead.\n` +
196
+ 'Re-dispatch without `model`: the agent definition decides which model a role runs on. ' +
197
+ "Changing a role's model is a policy change — make it in the agent definition, in a " +
198
+ 'reviewed change, never in one call (docs/decisions/subagent-routing.md).',
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Whether this file is being run as a script rather than imported — the realpath
204
+ * on both sides, as `inject-rules.mjs` explains, so a symlinked checkout still runs.
205
+ */
206
+ function invokedDirectly() {
207
+ if (!process.argv[1]) return false;
208
+ const real = (p) => {
209
+ try {
210
+ return realpathSync(p);
211
+ } catch {
212
+ return p;
213
+ }
214
+ };
215
+ return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
216
+ }
217
+
218
+ function main() {
219
+ let verdict;
220
+ try {
221
+ const input = readHookInput();
222
+ if (input === null) return 0;
223
+ verdict = judge(input, process.env.CLAUDE_PROJECT_DIR || process.cwd());
224
+ } catch {
225
+ return 0;
226
+ }
227
+ if (verdict.outcome === 'allow') return 0;
228
+ process.stderr.write(`${verdict.message}\n`);
229
+ return 2;
230
+ }
231
+
232
+ if (invokedDirectly()) {
233
+ process.exit(main());
234
+ }