create-agent-rig 0.4.0 → 0.6.0

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 (118) hide show
  1. package/CHANGELOG.md +316 -11
  2. package/README.md +58 -19
  3. package/package.json +2 -1
  4. package/packages/cli/dist/commands/create.js +8 -3
  5. package/packages/cli/dist/commands/init.js +85 -34
  6. package/packages/cli/dist/commands/upgrade.js +112 -30
  7. package/packages/cli/dist/index.js +81 -19
  8. package/packages/cli/dist/lib/copy-tree.js +35 -6
  9. package/packages/cli/dist/lib/init-settings.js +12 -0
  10. package/packages/cli/dist/lib/install-set.js +6 -8
  11. package/packages/cli/dist/lib/manifest.js +31 -9
  12. package/packages/cli/dist/lib/safe-path.js +30 -0
  13. package/scripts/prepare.mjs +1 -1
  14. package/templates/agent-os/init/AGENTS.md +199 -0
  15. package/templates/agent-os/init/CLAUDE.md +69 -9
  16. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +105 -0
  17. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
  18. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +41 -3
  19. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
  20. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +41 -0
  21. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
  22. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
  23. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +236 -0
  24. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +958 -0
  25. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
  26. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
  27. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  28. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +396 -0
  29. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
  30. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +40 -1
  31. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +71 -1
  32. package/templates/agent-os/universal/.claude/agents/security-scanner.md +40 -0
  33. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +301 -26
  34. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
  35. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
  36. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
  37. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
  38. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
  39. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
  40. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +503 -0
  41. package/templates/agent-os/universal/.claude/rules/autonomy.md +69 -1
  42. package/templates/agent-os/universal/.claude/rules/invariants.md +104 -19
  43. package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
  44. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1260 -0
  45. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
  46. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
  47. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
  48. package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
  49. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
  50. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
  51. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +462 -0
  52. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +33 -17
  53. package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
  54. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +149 -0
  55. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +809 -28
  56. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
  57. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +95 -16
  58. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +543 -15
  59. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +457 -47
  60. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +289 -25
  61. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
  62. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
  63. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
  64. package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
  65. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +539 -0
  66. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
  67. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
  68. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +198 -0
  69. package/templates/agent-os/universal/.claude/settings.json +11 -2
  70. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +118 -7
  71. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +667 -38
  72. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +359 -26
  73. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
  74. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
  75. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
  76. package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
  77. package/templates/agent-os/universal/.codex/hooks.json +70 -0
  78. package/templates/agent-os/universal/AGENTS.md +166 -0
  79. package/templates/agent-os/universal/CLAUDE.md +49 -14
  80. package/templates/agent-os/universal/PLAN.md +7 -40
  81. package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
  82. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
  83. package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
  84. package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
  85. package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
  86. package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
  87. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
  88. package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
  89. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +74 -0
  90. package/templates/agent-os/universal/journal/README.md +101 -0
  91. package/templates/agent-os/universal/layers.json +45 -2
  92. package/templates/hash-history.json +310 -49
  93. package/templates/release-ledger.json +9 -0
  94. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
  95. package/templates/skeleton/aws-serverless/README.md +91 -9
  96. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
  97. package/templates/skeleton/aws-serverless/gitignore +37 -0
  98. package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
  99. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
  100. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
  101. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
  102. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
  103. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
  104. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
  105. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
  106. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
  107. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
  108. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
  109. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
  110. package/templates/skeleton/node-service/README.md +11 -1
  111. package/templates/skeleton/node-service/gitignore +34 -0
  112. package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
  113. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
  114. package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
  115. package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
  116. package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
  117. package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
  118. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
@@ -0,0 +1,102 @@
1
+ ---
2
+ name: new-invariant
3
+ description: Turn a project rule into a mechanically enforced invariant — one stated rule, one PreToolUse hook that blocks its violation, one test for the hook. Use when a rule keeps being broken, when a review finding recurs, or when a post-mortem ends in "nothing stopped us doing that".
4
+ allowed-tools: Read, Grep, Glob, Write, Edit, Bash
5
+ argument-hint: [the invariant, in one sentence]
6
+ ---
7
+
8
+ You add **one** invariant to this project's enforced set, using the pattern in
9
+ `.claude/rules/invariants.md`: a stated rule, a hook that blocks its violation,
10
+ and a test for the hook. All three land in the same change.
11
+
12
+ ## Step 0 — get the invariant from the project, not from your own judgement
13
+
14
+ 🔴 **Do not invent the invariant.** If the argument did not name one, ask. The
15
+ whole point of this layer is that the project chooses what is load-bearing; an
16
+ agent that supplies its own answer has handed the project a rule nobody agreed
17
+ to, and that rule will be obeyed without thought.
18
+
19
+ Ask for, or find, three things:
20
+
21
+ 1. **The rule, in one sentence**, in the form "X never happens in Y".
22
+ 2. **What it cost the last time it was broken.** If nobody can finish that
23
+ sentence, stop and say so: an invariant with no incident behind it is a guess,
24
+ and a guessed invariant fires on honest work.
25
+ 3. **The compliant form.** What the code should look like instead — you need it
26
+ for the allow case, and a rule with no stated alternative is a dead end.
27
+
28
+ Then check the fit against the table in `.claude/rules/invariants.md`. If it
29
+ cannot be decided from a single edit fragment — "this is too complex", "the
30
+ naming is off" — say so and stop: it belongs to `code-reviewer`, a lint rule or a
31
+ type, and forcing it into a hook produces a guard people fight.
32
+
33
+ Also check it is not already enforced. `grep` the existing hooks first; a second
34
+ guard for the same rule is two places to keep in step.
35
+
36
+ ## Step 1 — write the failing test
37
+
38
+ TDD is not suspended here (`.claude/rules/workflow.md`). Copy
39
+ `guard-invariant.example.test.mjs` from this skill's directory as the shape, and
40
+ write the cases **before** the hook exists:
41
+
42
+ - **blocks** the violation — exit code 2, and the reason names the rule;
43
+ - **allows** the compliant form — exit code 0;
44
+ - **allows** a file outside the guarded scope — a guard that polices the whole
45
+ repo will be disabled by lunchtime;
46
+ - **allows** prose that merely mentions the violation. This case is not optional.
47
+ A guard that fires on a commit message, a doc line or a test fixture *about* the
48
+ rule produces **false positives**, and false positives are how guards get
49
+ switched off. Every real hook in this project strips or scopes text before
50
+ matching for exactly this reason.
51
+
52
+ Run it. Watch it fail for the right reason (the hook does not exist yet).
53
+
54
+ ## Step 2 — write the hook
55
+
56
+ Copy `guard-invariant.example.mjs` from this skill's directory to
57
+ `.claude/hooks/guard-<invariant-name>.mjs` and replace the parts the comments
58
+ mark. Name the file after **the invariant**, never after the tool it intercepts —
59
+ `guard-core-purity`, not `guard-write`.
60
+
61
+ Hold the contract:
62
+
63
+ - JSON payload on stdin; **exit 0 = allow, exit 2 = block**; stderr is the reason
64
+ the agent reads, so write it as an instruction — what to do instead, and where
65
+ the rule is stated.
66
+ - **Scope first, match second.** Return 0 immediately for files the invariant does
67
+ not cover.
68
+ - **Fail open** on a malformed payload or any internal error. A crashed guard that
69
+ blocks everything gets deleted within the hour.
70
+ - Zero dependencies, `node:` builtins only.
71
+
72
+ Run the test. Make it pass. Do not weaken a case to get there.
73
+
74
+ ## Step 3 — wire it, or it enforces nothing
75
+
76
+ Add the hook to `.claude/settings.json` under `PreToolUse`, in the block whose
77
+ `matcher` covers the tools the invariant needs (`Write|Edit` for file content,
78
+ `Bash` for commands). An unwired hook is a file that passes its own test and
79
+ guards nothing — verify by triggering the violation once, for real, and watching
80
+ it be refused.
81
+
82
+ ## Step 4 — state the rule where a reader will look
83
+
84
+ Add the sentence to the relevant file in `.claude/rules/` (or
85
+ `.claude/rules/invariants.md` if it fits nowhere else), next to a line saying
86
+ which hook enforces it and what the hook cannot see. A check with no stated rule
87
+ is a booby trap: someone will hit it, not understand it, and route around it.
88
+
89
+ ## Done when
90
+
91
+ - [ ] The test failed before the hook existed, and passes now
92
+ - [ ] The violation is refused in a real session, not only in the test
93
+ - [ ] The hook is wired in `settings.json`
94
+ - [ ] The rule is written down, with the hook named next to it
95
+ - [ ] The hook guards **one** invariant, and its scope is as narrow as the rule
96
+
97
+ ## A candidate that is already sitting there
98
+
99
+ The stack rules say service code logs through the shared structured logger, never
100
+ `console.log` — and nothing enforces it. That is the invariant the shipped example
101
+ encodes, so if the rule matters in your project, promoting the example into a real
102
+ hook is a copy, a rename and a wiring line.
@@ -0,0 +1,78 @@
1
+ // TEMPLATE — copy to .claude/hooks/guard-<invariant-name>.mjs and edit the three
2
+ // marked parts. It is real, working code, not a sketch: the `new-invariant` skill
3
+ // tests it, so what you copy is known to work before you change anything.
4
+ //
5
+ // The invariant it encodes as the example:
6
+ //
7
+ // Service code logs through the shared structured logger, never `console.log`.
8
+ //
9
+ // That one is a genuine candidate — the stack rules state it and nothing enforces
10
+ // it — but it is here to show the SHAPE. Replace it with your project's invariant.
11
+ //
12
+ // Contract (Claude Code): JSON payload on stdin; exit 0 = allow, exit 2 = block,
13
+ // and stderr is shown to the agent as the reason. Zero dependencies.
14
+ import { readFileSync } from 'node:fs';
15
+
16
+ // ── PART 1: the scope ────────────────────────────────────────────────────────
17
+ // Which files the invariant covers. Scope FIRST, match second: a guard that
18
+ // polices the whole repo fires on honest work and gets switched off by lunchtime.
19
+ const inScope = (filePath) => /^services\/[^/]+\/src\//.test(filePath);
20
+
21
+ // ── PART 2: the violation ────────────────────────────────────────────────────
22
+ // What the invariant forbids, decided from the text of THIS edit alone.
23
+ // Return a reason string, or null to allow.
24
+ const violation = (text) => {
25
+ // Strip line comments and string literals before matching: a doc line or a
26
+ // fixture that merely MENTIONS the forbidden form is prose, not a violation.
27
+ // Skipping this step is the single most common way a guard earns false
28
+ // positives — and false positives are how guards get disabled.
29
+ const code = text
30
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
31
+ .replace(/(^|[^:])\/\/.*$/gm, '$1')
32
+ .replace(/`(?:[^`\\]|\\.)*`|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '""');
33
+
34
+ const match = /\bconsole\.(log|info|warn|error|debug)\s*\(/.exec(code);
35
+ if (!match) return null;
36
+ return (
37
+ `BLOCKED — service code logs through the shared structured logger, never ` +
38
+ `console.${match[1]}(). Structured JSON lines are what makes a production ` +
39
+ `incident searchable; a bare console call is invisible the moment it matters. ` +
40
+ `Import the logger from the shared package instead. See .claude/rules/ for the rule.`
41
+ );
42
+ };
43
+
44
+ // ── PART 3: nothing below here normally changes ──────────────────────────────
45
+
46
+ /** The text an edit is about to introduce, across the tool shapes that carry one. */
47
+ const incomingText = (toolInput) =>
48
+ [toolInput?.content, toolInput?.new_string, toolInput?.new_str]
49
+ .filter((value) => typeof value === 'string')
50
+ .join('\n');
51
+
52
+ function main() {
53
+ let input;
54
+ try {
55
+ input = JSON.parse(readFileSync(0, 'utf8'));
56
+ } catch {
57
+ return 0; // malformed payload → fail open, never make the session unusable
58
+ }
59
+
60
+ const filePath = String(input?.tool_input?.file_path ?? '').replaceAll('\\', '/');
61
+ if (!filePath || !inScope(filePath)) return 0;
62
+
63
+ const text = incomingText(input?.tool_input);
64
+ if (!text) return 0;
65
+
66
+ try {
67
+ const reason = violation(text);
68
+ if (reason) {
69
+ process.stderr.write(`${reason}\n`);
70
+ return 2;
71
+ }
72
+ } catch {
73
+ return 0; // a guard that crashes must not block the work
74
+ }
75
+ return 0;
76
+ }
77
+
78
+ process.exit(main());
@@ -0,0 +1,89 @@
1
+ // TEMPLATE — the third of the three parts (.claude/rules/invariants.md).
2
+ //
3
+ // Copy alongside your hook and rewrite the cases for your invariant. Run with:
4
+ //
5
+ // node --test .claude/hooks/guard-<invariant-name>.test.mjs
6
+ //
7
+ // The four cases below are not a suggestion — they are the minimum set. The last
8
+ // two are the ones people skip, and they are exactly the ones that decide whether
9
+ // the guard survives contact with real work: a guard that polices out-of-scope
10
+ // files, or that fires on prose about its own rule, gets switched off.
11
+ import assert from 'node:assert/strict';
12
+ import { execFile } from 'node:child_process';
13
+ import { describe, it } from 'node:test';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { dirname, join } from 'node:path';
16
+
17
+ const hook = join(dirname(fileURLToPath(import.meta.url)), 'guard-invariant.example.mjs');
18
+
19
+ /** Feed a synthetic PreToolUse payload to the hook, exactly as Claude Code does. */
20
+ const runHook = (filePath, content) =>
21
+ new Promise((resolve, reject) => {
22
+ const child = execFile(process.execPath, [hook], (error, stdout, stderr) => {
23
+ resolve({ code: error ? (error.code ?? 1) : 0, stderr });
24
+ });
25
+ if (!child.stdin) return reject(new Error('no stdin'));
26
+ child.stdin.write(
27
+ JSON.stringify({
28
+ hook_event_name: 'PreToolUse',
29
+ tool_name: 'Write',
30
+ tool_input: { file_path: filePath, content },
31
+ }),
32
+ );
33
+ child.stdin.end();
34
+ });
35
+
36
+ const IN_SCOPE = 'services/api/src/handlers/create-note.ts';
37
+
38
+ describe('the example invariant: service code logs through the shared logger', () => {
39
+ it('blocks the violation, and the reason names what to do instead', async () => {
40
+ const result = await runHook(IN_SCOPE, 'console.log("created", id);');
41
+ assert.equal(result.code, 2);
42
+ assert.match(result.stderr, /logger/i);
43
+ });
44
+
45
+ it('blocks the other console methods too, not just log', async () => {
46
+ for (const method of ['info', 'warn', 'error', 'debug']) {
47
+ const result = await runHook(IN_SCOPE, `console.${method}("x");`);
48
+ assert.equal(result.code, 2, method);
49
+ }
50
+ });
51
+
52
+ it('allows the compliant form', async () => {
53
+ const result = await runHook(IN_SCOPE, 'logger.info({ msg: "created", id });');
54
+ assert.equal(result.code, 0);
55
+ });
56
+
57
+ it('allows files outside the guarded scope', async () => {
58
+ // A guard with repo-wide reach fires on honest work and gets disabled.
59
+ for (const path of ['scripts/one-off.mjs', 'apps/web/src/app/page.tsx', 'README.md']) {
60
+ const result = await runHook(path, 'console.log("fine here");');
61
+ assert.equal(result.code, 0, path);
62
+ }
63
+ });
64
+
65
+ it('allows prose that merely mentions the violation', async () => {
66
+ // The false-positive case. Skipping it is how a guard ends up switched off.
67
+ for (const content of [
68
+ '// never use console.log here — use the logger',
69
+ '/* console.error is banned in service code */',
70
+ 'const banned = "console.log(";',
71
+ 'const help = `do not call console.warn(...)`;',
72
+ ]) {
73
+ const result = await runHook(IN_SCOPE, content);
74
+ assert.equal(result.code, 0, content);
75
+ }
76
+ });
77
+
78
+ it('fails open on a payload it does not understand', async () => {
79
+ const result = await new Promise((resolve, reject) => {
80
+ const child = execFile(process.execPath, [hook], (error) => {
81
+ resolve({ code: error ? (error.code ?? 1) : 0 });
82
+ });
83
+ if (!child.stdin) return reject(new Error('no stdin'));
84
+ child.stdin.write('{not json');
85
+ child.stdin.end();
86
+ });
87
+ assert.equal(result.code, 0);
88
+ });
89
+ });
@@ -0,0 +1,396 @@
1
+ ---
2
+ name: pr-ship
3
+ description: The pre-merge gate. MUST run before a PR is opened or merged — runs the full check suite, fans out the reviewer gates, walks the DoD, and returns a SHIP / HOLD verdict with named blockers.
4
+ allowed-tools: Read, Grep, Glob, Bash, Task
5
+ argument-hint: [branch-or-pr]
6
+ ---
7
+
8
+ You are the last gate before a change ships. You verify and report; you do not
9
+ fix — a HOLD goes back to the author (usually the main session) with named
10
+ blockers.
11
+
12
+ ## Steps
13
+
14
+ 0. **Count this round before you spend on it.** Run it on the branch **under
15
+ review** — if the PR is not checked out, do that first (step 2's warning covers
16
+ why); on a detached checkout the command refuses rather than counting under
17
+ `HEAD`:
18
+
19
+ ```sh
20
+ node .claude/scripts/queue/index.mjs gate-round --branch "$(git rev-parse --abbrev-ref HEAD)"
21
+ ```
22
+
23
+ **Read the exit code, not just its sign.**
24
+
25
+ - **0** — proceed to step 1.
26
+ - **2** — the rounds are spent. Return `HOLD` with one blocker whose `rule`
27
+ is *gate rounds exhausted*, quoting the round count in its `note`. Do not
28
+ run the fan-out.
29
+ - **1** — the command itself failed (unreadable config, unreadable counter,
30
+ detached checkout), **or the checkout cannot ship**: a dirty working tree,
31
+ a branch with no upstream, or commits the upstream has not seen. Nothing
32
+ was counted. This is **not** an exhausted cap: fix the cause — commit and
33
+ push — and run step 0 again. Treating it as exhaustion escalates a healthy
34
+ item. A rig with no remote can never pass this step — by design: the PR
35
+ flow, and this gate with it, begins when the project has a remote
36
+ (`workflow.md`, "PR flow"). The refusal exists because two rounds were once counted ahead of a
37
+ commit pre-commit then refused, so the counter and the fan-out's verdicts
38
+ named a head that never shipped (AR-141) — pinned in the generator's
39
+ `test/template/gate-rounds.test.ts` — absent in a generated rig — ›
40
+ "refuses to count a round on a dirty tree, and counts nothing".
41
+
42
+ The cap is **2 by default**, and no shipped `.claude/queue.json` carries the key
43
+ — the default lives in `core.mjs` as `DEFAULT_MAX_GATE_ROUNDS`. A project that
44
+ wants a different cap sets `options.maxGateRounds` there, which in a rig whose
45
+ `queue.json` is composed means changing what composes it, not editing the file.
46
+ Rounds are counted per branch in `.claude/gate-rounds.json`, so the count outlives
47
+ the session that spent them.
48
+
49
+ ⚠ **Nothing forces this call.** No hook launches the gate, so step 0 holds
50
+ because it is written here — the same standing as every other step. What it
51
+ removes is the honest failure mode, a run that keeps re-reviewing because no
52
+ check ever went red; it does not stop a session that skips it.
53
+
54
+ 1. **The diff first — and the branch's own premises next.** Establish what is actually shipping: fetch, then diff
55
+ against the **remote** default branch (`origin/<default>`), not a local
56
+ copy that may be behind — diagnosing from stale local code produces
57
+ confidently-wrong reviews. Everything below is scoped to this diff.
58
+
59
+ Then, on the fetched ref, ask whether the branch is still the branch the run
60
+ took up (AR-134):
61
+
62
+ ```sh
63
+ node .claude/scripts/revalidate.mjs --point BEFORE_PR --ticket <item-id> --base origin/<default>
64
+ ```
65
+
66
+ It compares two sources and names each one that moved: the item's `updatedAt`
67
+ against the take-up snapshot `next` recorded (`task:updatedAt`), and what the
68
+ default branch changed since this branch forked, on the paths the branch
69
+ touches or a `check-premises` record in this run cited (`main:<path>`). It
70
+ journals one `revalidation` event at `point: BEFORE_PR`; **exit code 2 is a HOLD**, with one blocker per named source: re-read the item, or the default
71
+ branch on that path, record what the re-read concluded —
72
+ `node .claude/scripts/revalidate.mjs outcome --point BEFORE_PR --ticket <item-id> --action-changed <true | false> --note '…'`
73
+ — and come back through step 0. A hold with no outcome is counted by the
74
+ report as a re-read the run skipped. Exit 0 with
75
+ `unverifiable` means the task side could not be compared — no take-up
76
+ snapshot in this run, or no marker — and is stated in the evidence, not read
77
+ as a pass. Exit 1 is the command refusing (unknown point, no ticket, a base
78
+ that is not a revision): fix the call. Its limits are its own header's; the
79
+ cited-path set is a labelled assumption, not a recorded fact.
80
+ 2. **Route the diff before you spend on it.** This gate always ran its most
81
+ expensive path, so a typo fix in a README bought the same fan-out as a
82
+ rewrite of the storage layer. The dispatcher decides which lane the change
83
+ earns, in ascending order of cost:
84
+
85
+ ```
86
+ `deterministic` → `fast-path` → `model`
87
+ ```
88
+
89
+ ```sh
90
+ node .claude/scripts/decision-router.mjs --base origin/<default> --json
91
+ ```
92
+
93
+ **Pass the same base step 1 resolved.** The default is `origin/HEAD`, a ref
94
+ `git clone` sets and `git init` + `git remote add` does not — and a base that
95
+ is merely *different* rather than missing does not fail at all: the router
96
+ routes a narrower file set than the one this gate reviews, and the narrower
97
+ set is the one that can lose a risk flag.
98
+
99
+ ⚠ **It routes the committed diff, `<base>...<head>` — never the working
100
+ tree.** An uncommitted edit is invisible to it, including reviewer fixes you
101
+ have applied but not committed, so commit before routing. If this gate was
102
+ invoked on a PR that is not checked out, check it out first.
103
+
104
+ `decision-router` reads the changed paths and returns the lane plus the
105
+ reviewers that lane requires. **Risk flags escalate ahead of all three** — a
106
+ file under a declared elevated path, a dependency manifest or a path naming
107
+ auth, secrets, tokens, sessions or permissions, a deleted test (including the
108
+ deletion half of a rename) — and any one of them lands the change in `model`
109
+ however cheap it otherwise looked. The elevated-path flag has one carve-out,
110
+ inherited from the gate sweep rather than invented here: `.md`/`.mdx` files
111
+ and test paths that provision nothing are **inert**, so `infra/README.md`
112
+ does not escalate while `infra/stack.ts` does. Note the mechanism is those
113
+ two extensions and test paths — **not** this router's own notion of prose,
114
+ which is `.md`/`.txt`. Neither set contains the other, and both differences
115
+ are deliberate: aligning the sweep to the router takes `requirements.txt` in
116
+ an elevated directory out of escalation, and aligning the router to the
117
+ sweep puts executable `.mdx` back on the prose lane. A rulebook file is
118
+ never inert.
119
+
120
+ 🔴 **The lane is on stdout; the exit code says only that the router ran.**
121
+ Never chain it on `&&`, and never read `0` as "cheap" — that misreading turns
122
+ this gate into a rubber stamp. **Exit 1 is not a lane**: it means nothing was
123
+ routed — an unreadable diff, an empty file list, a project declaring no
124
+ elevated path, an unrecognised flag, a base or head that is not a revision,
125
+ or a run directory that is not there. Treat it as `model` and fix the cause;
126
+ it is never a reason to skip the gate.
127
+
128
+ 🔴 **One rule covers every outcome: read STDOUT.** If a lane printed, that is
129
+ the answer; if stdout is empty, treat the change as `model`. Do **not** key
130
+ on the `run journal:` prefix — both journal failures wear it and they end
131
+ differently. A trace that can no longer accept records ends the *trace*, not
132
+ the routing, so the lane still prints and the exit stays 0 (start the next
133
+ run in a new run directory). A run directory that was never there exits 1
134
+ with nothing routed.
135
+
136
+ What each lane buys:
137
+
138
+ - `deterministic` — every changed file is a derived artifact, git says it was
139
+ `modified` or `removed`, and **none of them sits under a declared elevated
140
+ path**. The lane's floor is empty; step 4's triggers still apply on top.
141
+ - `fast-path` — documentation outside the rulebook, plus any derived file
142
+ travelling with it under those same two rules. `prose-reviewer` is the
143
+ floor.
144
+ - `model` — everything else, and `code-reviewer` runs on it **always**,
145
+ with the triggers in step 4 beside it. Anything the router cannot classify
146
+ lands here.
147
+
148
+ 🔴 **State what the cheap lanes give up, because they do give something up.**
149
+ Dropping `code-reviewer` drops two of its checklist items that are *not*
150
+ about code — contract drift, and "contradicts the item it claims to
151
+ implement". Neither is decidable from paths. So the cheap lanes carry the
152
+ item text to whatever cold reader they do launch (step 4).
153
+
154
+ The `deterministic` lane launches none, and that rests on one claim: a file
155
+ is generator output, so a check already catches its drift. The claim needs a
156
+ prior output to have drifted **from** — which is why an added, copied,
157
+ renamed or status-less entry is refused the lane, and why a **test snapshot
158
+ is not a derived artifact here at all**. A snapshot is the behaviour claim,
159
+ rewritten by the run that then passes by construction; routing one to a lane
160
+ with no reviewer would be weakening a test with a dispatcher.
161
+
162
+ If you disagree with a lane, run the expensive one — never argue a diff
163
+ downward.
164
+ 3. **The project's own checks.** Run the full check suite the project defines
165
+ (see its README / package scripts). Any failure is an instant HOLD — never
166
+ argue with a red check, never rerun flakiness to green
167
+ (`.claude/rules/workflow.md`).
168
+ 4. **Reviewer fan-out.** The lane from step 2 sets the **floor**:
169
+
170
+ - `model` → launch the `code-reviewer` agent on the diff, always;
171
+ - `fast-path` → launch `prose-reviewer`;
172
+ - `deterministic` → the lane's floor is empty. The triggers below still
173
+ apply: a floor of zero is not permission to skip one.
174
+
175
+ **Whatever you launch, pass it the text of the queue item this branch
176
+ implements.** A reviewer given only a diff cannot check the change against
177
+ what was asked: a cold context has no way to know, and reconstructing it from
178
+ the PR description would mean trusting the run under review. If there is no
179
+ item — owner-directed work, a hotfix — say so when launching, and the
180
+ reviewer skips that check openly instead of guessing at it.
181
+
182
+ 🔴 **The triggers below are lane-independent and may only ADD.** They read
183
+ *what the code does*; the router reads *paths*, and a path cannot say that a
184
+ module parses untrusted input — measured on this router's own first run,
185
+ which named `code-reviewer` and `prose-reviewer` for a diff doing exactly
186
+ that. So a `fast-path` diff still reaches `security-scanner` when it trips a
187
+ trigger, and no lane removes one:
188
+
189
+ - `security-scanner` when the diff touches auth, secrets or configuration,
190
+ input parsing, file handling, new outbound calls, dependency changes;
191
+ - `prose-reviewer` when the diff touches a rule file, a skill, an agent spec,
192
+ a decision record under `docs/decisions/`, `CLAUDE.md` or the README — a
193
+ rulebook that overstates its own enforcement fails silently and in the
194
+ direction of false confidence;
195
+ - an infrastructure review when it touches infrastructure (the stack layer
196
+ names the agent).
197
+
198
+ Run them as subagents, in parallel — a fresh context reviews better than the
199
+ session that wrote the code (see `.claude/rules/workflow.md`,
200
+ "Review-context isolation").
201
+
202
+ 🔴 **Record the set you launched, as you launch it.** The router journals the
203
+ set it *routed*; the triggers above may only add, so what you actually
204
+ launched is a different list and this is the only place that knows it:
205
+ **Record the fan-out even when the launched set is empty.**
206
+
207
+ ```sh
208
+ node --input-type=module -e '
209
+ const runDir = process.env.RIG_RUN_DIR;
210
+ if (!runDir) process.exit(0); // an undeclared run has no trace to write
211
+ const journal = await import("./.claude/scripts/run-journal.mjs");
212
+ try {
213
+ console.log(journal.recordDecision({
214
+ runDir,
215
+ gate: "reviewer-fan-out",
216
+ verdict: "launched",
217
+ // `argv[1]` is the first argument after the script — `argv[0]` is the
218
+ // node binary itself, and reading it here would record that path as
219
+ // the commit and shift every reviewer along by one.
220
+ headSha: process.argv[1],
221
+ reviewers: process.argv.slice(2), // every reviewer you just started
222
+ now: new Date().toISOString(),
223
+ }));
224
+ } catch (error) {
225
+ if (!journal.isTraceExhausted?.(error)) throw error;
226
+ process.stderr.write(`run journal: ${error.message}\n the fan-out above was NOT recorded.\n`);
227
+ }
228
+ ' "$(git rev-parse HEAD)" <reviewer> <reviewer> …
229
+ ```
230
+
231
+ Substitute the reviewers you actually started — the point of the record is
232
+ that it is not derivable from the lane, so a list copied from this example
233
+ records somebody else's fan-out. **One argument each**, unquoted — a single
234
+ quoted string arrives as one reviewer whose name is both of theirs joined by
235
+ a space, and `recordDecision` accepts it: it checks for a list of strings and
236
+ nothing about what a name is.
237
+
238
+ **Launched is not answered, and the difference is the point.** The records
239
+ below are written per verdict that *parsed* — so a reviewer whose report came
240
+ back `incomplete` produced no record at all, and without this one nothing
241
+ afterwards can tell "that reviewer was never launched" from "it was launched
242
+ and did not answer". Those need opposite responses, and the round that has to
243
+ tell them apart is the one reading this trace after a compaction.
244
+
245
+ 🔴 **Check each reviewer's answer before you believe it.** Every gate spec
246
+ ends in one fenced `json` block; save what each subagent returned and run
247
+
248
+ ```sh
249
+ node .claude/scripts/verdict.mjs check <report> <the reviewer you launched>
250
+ ```
251
+
252
+ on it **before** you decide anything from it. 🔴 **Name the reviewer.** You
253
+ launched two or three of them and the check reads the report's LAST block, so
254
+ without the name a report carrying `code-reviewer`'s `HOLD` followed by
255
+ anything else answers about the anything else — a stop you never see. With
256
+ the name, a block claiming another gate is refused.
257
+
258
+ Exit 0 prints the parsed
259
+ verdict — including for a `HOLD`, because a reviewer that *found* something
260
+ is not a reviewer that broke. Exit 1 means the report does not end in a
261
+ verdict this gate can act on: no block or one that is not JSON, a word no
262
+ gate returns, a blocker naming no rule, or the case this check exists for —
263
+ a `HOLD` with an empty `blockers` list, and its mirror, a `SHIP` carrying
264
+ one. Where the shared vocabulary names the gate it also refuses a word that
265
+ belongs to a different one; for a gate it does not name — a reviewer this
266
+ project or its stack added — that one check is not made
267
+ (`.claude/scripts/lib/verdict.mjs`, limit 1), so read such a report's word
268
+ against the reviewer's own spec yourself.
269
+
270
+ Such a report is **`incomplete`**: the reviewer did not answer. Read it as
271
+ neither a pass nor a stop — relaunch that one reviewer with the shape, or
272
+ record `incomplete` as a blocker of your own. Never read "no blockers
273
+ parsed" as "no blockers found".
274
+
275
+ **Then journal each verdict that parsed**, so the round's blockers outlive
276
+ the session that read them — a gate round is counted and finite, and an
277
+ escalation written after a compaction otherwise carries the round count and
278
+ nothing about what was found:
279
+
280
+ ```sh
281
+ node --input-type=module -e '
282
+ const runDir = process.env.RIG_RUN_DIR;
283
+ if (!runDir) process.exit(0); // an undeclared run has no trace to write
284
+ // `check` prints nothing when it refuses, and a refused report is not a
285
+ // verdict to record — parse only what it actually printed.
286
+ if (!process.argv[1]) process.exit(0);
287
+ const journal = await import("./.claude/scripts/run-journal.mjs");
288
+ const v = JSON.parse(process.argv[1]); // the block `check` printed
289
+ try {
290
+ console.log(journal.recordDecision({
291
+ runDir,
292
+ gate: v.gate,
293
+ verdict: v.verdict,
294
+ blockers: v.blockers,
295
+ headSha: v.headSha, // absent when the gate named no commit
296
+ now: new Date().toISOString(),
297
+ }));
298
+ } catch (error) {
299
+ // The split `queue/index.mjs` makes, for the reason `run-journal.mjs`
300
+ // gives: a trace that can accept no more records is over, and the GATE
301
+ // is not. Anything else is this call mis-declared, and stops it.
302
+ if (!journal.isTraceExhausted?.(error)) throw error;
303
+ process.stderr.write(`run journal: ${error.message}\n the verdict above was NOT recorded.\n`);
304
+ }
305
+ ' "$(node .claude/scripts/verdict.mjs check <report> <reviewer>)"
306
+ ```
307
+
308
+ 🔴 **The guard and the `catch` are the contract, not decoration** — an
309
+ exhausted trace must cost this round its record and nothing else. A round is
310
+ counted and capped, so a crash here spends one on a journal that was never
311
+ the thing under review.
312
+ 5. 🔴 **Coverage — check your own fan-out before you believe it.** You recorded
313
+ what the route asked for, what you launched and what came back; this is the
314
+ step that compares them, and it is the only one that does — nothing else in
315
+ this gate would notice a reviewer that never answered:
316
+
317
+ ```sh
318
+ node .claude/scripts/verdict.mjs coverage "$(git rev-parse HEAD)"
319
+ ```
320
+
321
+ For a **declared run**, exit 0 is coverage. **Exit 1 is a `HOLD`.** A
322
+ reason-only unreadable-round failure always prints the evidence boundary;
323
+ remedies appear only when recovery is unambiguous. Reviewer lists cover the
324
+ four comparable cases — never launched (launch it),
325
+ launched and silent (go and read why), answered without naming a commit, or
326
+ answered for another commit (the head moved under the round). Record either
327
+ kind as a blocker of yours, in the same list as a failing check.
328
+
329
+ Two limits, stated because a step that looks mechanical is trusted like one.
330
+ It reads **this run's journal**, so with unset `RIG_RUN_DIR` the check is
331
+ skipped; exit 0 is then an honest nothing, not coverage. And it cannot see a
332
+ round that never reached this skill at all: a session that skips `pr-ship`
333
+ skips its coverage check with it (`docs/decisions/gate-coverage.md`).
334
+ 6. **DoD walk.** Check the Definition of Done list in
335
+ `.claude/rules/workflow.md` item by item — test-first evidence, nothing
336
+ skipped or weakened, boundaries respected, docs updated, autonomy tier
337
+ honored.
338
+ 7. **Named checks only.** The merge criterion is the project's *named* required
339
+ checks, all green. "Some checks passed" is not a criterion; an unnamed
340
+ green wall hides a red brick. Two traps here, both observed in the wild:
341
+ status watchers can exit while checks are **still unregistered** — poll the
342
+ head SHA's check runs and require each expected check *by name*; and a
343
+ result list containing only a scanner (no build, no tests) is **not** done,
344
+ it is a check set that has not arrived yet.
345
+
346
+ ## Verdict
347
+
348
+ - `SHIP` — checks green, no blocking findings, DoD holds. Say so explicitly; a
349
+ clean gate is a real result.
350
+ - `HOLD` — name every blocker: the failing check by name, the reviewer finding
351
+ with its file:line, or the DoD item that does not hold. Blocking findings are
352
+ resolved, not argued with; after fixes, the gate runs again from **step 0** —
353
+ which counts the new round and is what makes "again" finite. Re-entering at
354
+ step 1 skips the counter, and the unbounded rounds this gate measured are
355
+ exactly what that produces.
356
+
357
+ Your own answer is a verdict like any other, so it ends the same way: prose for
358
+ the author, then **exactly one** fenced `json` block, and nothing after it.
359
+
360
+ ```json
361
+ {
362
+ "gate": "pr-ship",
363
+ "verdict": "HOLD",
364
+ "blockers": [
365
+ { "rule": "required check `ci`", "note": "red on the head commit: 3 tests failed" },
366
+ {
367
+ "file": "packages/core/src/note.ts",
368
+ "line": 42,
369
+ "rule": "code-reviewer — checklist item 2",
370
+ "note": "the failing case was deleted rather than fixed"
371
+ }
372
+ ],
373
+ "advisories": [],
374
+ "evidence": ["lane: model", "reviewers: code-reviewer, prose-reviewer"],
375
+ "headSha": "9c1f0a7d4b3e2c5a8f6d0b9e7c4a1f2d3e5b6c70"
376
+ }
377
+ ```
378
+
379
+ - `verdict` is `SHIP` or `HOLD` — this gate has no third answer.
380
+ - A failing check and a DoD line have no location, so `file` and `line` are
381
+ omitted there; every blocker names the `rule` it came from either way.
382
+ - A `HOLD` with an empty `blockers` list, and a `SHIP` carrying one, are both
383
+ answers this gate may not give. **Run the same command on your own block
384
+ before you return it** — `node .claude/scripts/verdict.mjs check <your-block>
385
+ pr-ship` — and fix what it refuses. Nothing downstream re-checks the gate's
386
+ own answer, so this call is the only thing between a malformed verdict and
387
+ whoever acts on it.
388
+ - **`headSha` is the commit you gated** — `git rev-parse HEAD`, the same one
389
+ step 5 asked coverage about. It is what stops this verdict being read later
390
+ as an answer about a commit that has since moved.
391
+
392
+ ## Boundaries
393
+
394
+ - You never merge, push, or edit files — you gate. The merge itself stays with
395
+ whoever holds that authority under `.claude/rules/autonomy.md`.
396
+ - One verdict per run. No "SHIP if you feel the tests are probably fine".