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,73 @@
1
+ ---
2
+ name: worktree-task
3
+ description: Start and finish a task in its own git worktree — creation off the remote default branch, and the full cleanup after the merge. Use at the start of every implementation task and after every merge, and whenever two sessions might touch this repo at once.
4
+ allowed-tools: Bash, Read
5
+ ---
6
+
7
+ # Worktree task lifecycle
8
+
9
+ `.claude/rules/workflow.md` says **one task, one branch**. A worktree is how
10
+ that rule survives a second session: two branches checked out at once, in two
11
+ directories, with one `.git`. Without it, an unattended run and a hand-driven
12
+ session share a working tree and overwrite each other's edits.
13
+
14
+ **Use one when** anything else may touch this repo while you work — an
15
+ unattended `loop` run, a colleague, a second Claude session. A single attended
16
+ session on a quiet repo can just use a branch; the discipline that is never
17
+ optional is the branch, not the worktree.
18
+
19
+ ## Start
20
+
21
+ ```bash
22
+ cd "$(git rev-parse --show-toplevel)" # ALWAYS anchor cwd first — see gotcha 1
23
+ git fetch origin
24
+ git worktree add -b <type>/<slug> .claude/worktrees/<slug> origin/HEAD
25
+ git worktree list # verify the path is DIRECTLY under .claude/worktrees/
26
+ ```
27
+
28
+ - **Branch off the remote default branch**, never a local copy — a stale local
29
+ default is how a task gets built on code that was replaced last week
30
+ (`.claude/rules/autonomy.md`, "Session staleness"). If `origin/HEAD` is not
31
+ set locally, name the branch explicitly: `origin/<default-branch>`.
32
+ - **Branch name: `<type>/<slug>`**, type one of `feat|fix|docs|chore|refactor`.
33
+ If the project's queue gives work an id, the branch carries it —
34
+ `feat/<id>-<slug>` — because the branch name is often the only thread between
35
+ a queue item and its code. Work with no queue item keeps the plain form: **do
36
+ not invent an id.**
37
+ - If tests will run there, install inside the worktree first. A worktree gets a
38
+ fresh, empty `node_modules`; a missing install fails as a confusing
39
+ module-resolution error rather than as "you forgot to install".
40
+
41
+ ## Finish (after the PR is merged)
42
+
43
+ ```bash
44
+ cd "$(git -C <repo root> rev-parse --show-toplevel)" # cd OUT of the worktree first — gotcha 2
45
+ git worktree remove .claude/worktrees/<slug> --force
46
+ git worktree prune
47
+ git branch -D <type>/<slug>
48
+ git push origin --delete <type>/<slug> # if the merge did not already delete it
49
+ rm -rf .claude/worktrees/<slug> # only if `remove` left the directory behind
50
+ ```
51
+
52
+ Add `.claude/worktrees/` to `.gitignore` once, so a live worktree never shows
53
+ up as untracked noise in every `git status`.
54
+
55
+ ## Gotchas
56
+
57
+ 1. **A stale cwd creates worktrees inside dead paths.** If your shell sits in a
58
+ directory that has been removed (typically the *previous* task's worktree), a
59
+ relative `git worktree add .claude/worktrees/x` recreates the dead path and
60
+ nests the new worktree inside it. Always `cd` to the repo root by an
61
+ **absolute** path first, and verify with `git worktree list` afterwards.
62
+ 2. **`git worktree remove` fails while your shell is inside it** — or while a
63
+ watcher or install still holds a handle on `node_modules`. `cd` out first. If
64
+ the directory survives `remove --force`, run `git worktree prune` (the
65
+ metadata is then clean) and delete the leftover directory; retry later if it
66
+ is still busy.
67
+ 3. **Never touch a worktree you did not create.** Concurrent sessions make
68
+ worktrees appear and vanish mid-task. Removing another session's worktree
69
+ destroys unmerged work — and it is indistinguishable, afterwards, from that
70
+ session never having done the work.
71
+ 4. **One worktree per task.** Do not reuse a finished task's worktree: its
72
+ branch state and its install are both stale, and the reuse is invisible in
73
+ the diff.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: code-reviewer
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. Blocking findings must be resolved, not argued with.
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
5
  ---
6
6
 
@@ -49,3 +49,42 @@ do not block on them.
49
49
  - Quote the checklist item a blocking finding violates. If nothing blocks, say
50
50
  so explicitly — "no blocking findings" is a valid, useful verdict.
51
51
  - Do not request rewrites of working, tested code for style alone.
52
+
53
+ ## The verdict block
54
+
55
+ Write your report for the human, then end it with **exactly one** fenced `json`
56
+ block of this shape, and nothing after it. That block is what the calling gate
57
+ reads; a report that never writes one is read as whatever the caller expected.
58
+
59
+ ```json
60
+ {
61
+ "gate": "code-reviewer",
62
+ "verdict": "HOLD",
63
+ "blockers": [
64
+ {
65
+ "file": "packages/core/src/note.ts",
66
+ "line": 42,
67
+ "rule": "checklist item 2 — test integrity",
68
+ "note": "the failing case was deleted rather than fixed"
69
+ }
70
+ ],
71
+ "advisories": [],
72
+ "evidence": ["diffed against origin/master", "queue item supplied"],
73
+ "headSha": "9c1f0a7d4b3e2c5a8f6d0b9e7c4a1f2d3e5b6c70"
74
+ }
75
+ ```
76
+
77
+ - `verdict` is `SHIP`, `HOLD` or `NOT_APPLICABLE` — no other word.
78
+ - Every blocker names the `rule` it violates. `file` and `line` travel together
79
+ and are both omitted when the finding has no single location.
80
+ - A `HOLD` with an empty `blockers` list is **refused**, and so is a `SHIP`
81
+ carrying one: `node .claude/scripts/verdict.mjs check <report> <this gate>` is
82
+ what refuses them, and the shape it enforces is in
83
+ `.claude/scripts/lib/verdict.mjs`. The gate name is what stops your answer
84
+ being read as somebody else's.
85
+ - **`headSha` is the commit you reviewed** — `git rev-parse HEAD` in the
86
+ checkout you read. It is what lets `node .claude/scripts/verdict.mjs coverage
87
+ <commit>` tell "this gate answered for the commit being merged" from "it
88
+ answered two pushes ago". A verdict naming no commit is counted as neither
89
+ covered nor missing, so `pr-ship` holds on it — and only `pr-ship`: no hook
90
+ runs that check, so a session that skips the gate skips this with it.
@@ -50,7 +50,39 @@ about it here, so the two gates never file competing opinions on one paragraph.
50
50
  two bypasses, sells cover it does not have. Both are blocking, and both are
51
51
  found the same way: read the mechanism, then read what the text claims about
52
52
  it.
53
- 5. **Domain that must not travel.** In a layer meant to be neutral: a provider or
53
+ 5. **An unbacked behaviour claim.** A sentence asserts what a mechanism does, how
54
+ much something costs, or how often it happens, and **nothing backs it**: no
55
+ test you can name, no command output, no citation to the code. Per
56
+ `.claude/rules/invariants.md` ("State the limits") such a sentence must be
57
+ **generated** from what it describes or be a **pointer to a test** — the form is
58
+ `see <test file> › "<test name>"`, and the name has to be greppable in a file the
59
+ reader has. This is a blocker **by rule**, so you do not have to prove the claim
60
+ wrong; an unbacked claim about behaviour is the finding.
61
+
62
+ ⚠ A pointer into a test suite the reader's project does not carry is normally
63
+ item 2, not backing. There is one narrow inherited-snapshot exception from
64
+ `invariants.md`: a generator-authored hook may point to upstream generator
65
+ tests that are absent locally **only while the hook is unchanged downstream**
66
+ and its hook header identifies those tests as absent locally. If that hook is
67
+ edited downstream or appears as changed in the current diff, the exception
68
+ expires and the local test is yours; then an absent pointer is item 2 again.
69
+
70
+ 🔴 Three things this is not. It is not item 1: that one is about enforcement the
71
+ mechanism does not provide, this one is about any claim with nothing behind it,
72
+ including a true one. It is not item 4 either, and the split is worth getting
73
+ right because both can reach one sentence: **item 4 is for a limit you checked
74
+ against the mechanism and found wrong or missing; item 5 is for a claim you did
75
+ not have to check, because nothing is offered as backing.** If you opened the
76
+ hook and it disagrees with the text, file item 4 and quote the line. If there was
77
+ nothing offered to open, file item 5. If you opened it and the claim was right,
78
+ there is no finding. One sentence, one item. And it is not an attack on rationale — "we chose X
79
+ because Y" needs no test. The target is a **factual assertion about behaviour**:
80
+ a number, a rate, a limit, a "measured" anything.
81
+
82
+ The remedy has two forms and rewording is neither: the sentence goes, or it
83
+ becomes a pointer. Say which you would expect, and where the test lives if one
84
+ exists.
85
+ 6. **Domain that must not travel.** In a layer meant to be neutral: a provider or
54
86
  vendor name, a host-specific absolute path, a tracker key, a company or
55
87
  product name, credentials or personal data in an example. State which layer
56
88
  the file belongs to and why the mention breaks it.
@@ -102,3 +134,41 @@ You read text and the mechanisms it names. You cannot tell whether a rule is
102
134
  claim about the world outside this repository is true. Those are the owner's
103
135
  questions, and answering them from this seat would be exactly the overreach
104
136
  item 1 exists to catch.
137
+
138
+ ## The verdict block
139
+
140
+ End your report with **exactly one** fenced `json` block of this shape, and
141
+ nothing after it. The prose above it is for the human; this block is what the
142
+ calling gate reads.
143
+
144
+ ```json
145
+ {
146
+ "gate": "prose-reviewer",
147
+ "verdict": "HOLD",
148
+ "blockers": [
149
+ {
150
+ "file": ".claude/rules/invariants.md",
151
+ "line": 118,
152
+ "rule": "item 5 — an unbacked behaviour claim",
153
+ "note": "no test named, and the hook it describes does not do this"
154
+ }
155
+ ],
156
+ "advisories": [],
157
+ "evidence": ["opened .claude/hooks/guard-bash.mjs and quoted the line"],
158
+ "headSha": "9c1f0a7d4b3e2c5a8f6d0b9e7c4a1f2d3e5b6c70"
159
+ }
160
+ ```
161
+
162
+ - `verdict` is `SHIP`, `HOLD` or `NOT_APPLICABLE` — no other word.
163
+ - Every blocker names the `rule` it violates; give the `file` and `line` of the
164
+ text, and cite the contradicting mechanism in the `note`.
165
+ - A `HOLD` naming no blocker is **refused**, and so is a `SHIP` carrying one:
166
+ `node .claude/scripts/verdict.mjs check <report> <this gate>` is what refuses
167
+ them, and the gate name is what stops your answer being read as somebody
168
+ else's.
169
+ - **`headSha` is the commit you reviewed** — `git rev-parse HEAD` in the
170
+ checkout you read. It is what lets `node .claude/scripts/verdict.mjs coverage
171
+ <commit>` tell "this gate answered for the commit being merged" from "it
172
+ answered two pushes ago". A verdict naming no commit is counted as neither
173
+ covered nor missing, so `pr-ship` holds on it — and only `pr-ship`: no hook
174
+ runs that check, so a session that skips the gate skips this with it.
@@ -38,3 +38,43 @@ blocking findings stop the PR until resolved.
38
38
  the smallest fix. No theoretical lectures without a code path.
39
39
  - If the change is outside your triggers, say so and return quickly — a clean
40
40
  "not security-relevant" is a valid verdict.
41
+
42
+ ## The verdict block
43
+
44
+ End your report with **exactly one** fenced `json` block of this shape, and
45
+ nothing after it. It is what the calling gate reads; the prose above it is for
46
+ the human who has to fix the finding.
47
+
48
+ ```json
49
+ {
50
+ "gate": "security-scanner",
51
+ "verdict": "HOLD",
52
+ "blockers": [
53
+ {
54
+ "file": "services/api/src/handlers/upload.ts",
55
+ "line": 31,
56
+ "rule": "unvalidated input",
57
+ "note": "the filename reaches the shell unescaped — attacker-controlled"
58
+ }
59
+ ],
60
+ "advisories": [],
61
+ "evidence": ["grepped for the pattern across services/"],
62
+ "headSha": "9c1f0a7d4b3e2c5a8f6d0b9e7c4a1f2d3e5b6c70"
63
+ }
64
+ ```
65
+
66
+ - `verdict` is `SHIP` (nothing blocking), `HOLD`, or `NOT_APPLICABLE` when the
67
+ change is outside your triggers — that last one is the structured form of the
68
+ clean "not security-relevant" answer above.
69
+ - Every blocker names the `rule` it violates, with `file` and `line` when the
70
+ finding has a location and neither when it does not.
71
+ - A `HOLD` naming no blocker is **refused**, and so is a `SHIP` carrying one:
72
+ `node .claude/scripts/verdict.mjs check <report> <this gate>` is what refuses
73
+ them, and the gate name is what stops your answer being read as somebody
74
+ else's.
75
+ - **`headSha` is the commit you reviewed** — `git rev-parse HEAD` in the
76
+ checkout you read. It is what lets `node .claude/scripts/verdict.mjs coverage
77
+ <commit>` tell "this gate answered for the commit being merged" from "it
78
+ answered two pushes ago". A verdict naming no commit is counted as neither
79
+ covered nor missing, so `pr-ship` holds on it — and only `pr-ship`: no hook
80
+ runs that check, so a session that skips the gate skips this with it.
@@ -9,21 +9,174 @@
9
9
  // - stop_hook_active in the payload means we already blocked this stop once
10
10
  // — never block again, or an agent that cannot go green spins forever;
11
11
  // - a clean git tree stops instantly: nothing changed, nothing to gate;
12
- // - fail open on any hook error — a crashed gate must not make the session
13
- // unquittable.
14
- import { execSync } from 'node:child_process';
15
- import { readFileSync } from 'node:fs';
12
+ // - fail open on any fault of the hook's OWN — a crashed gate must not make
13
+ // the session unquittable. A check that ran and was killed is NOT one of
14
+ // those; the 🔴 block below is the rule that separates the two.
15
+ //
16
+ // 🔴 The budget, and the one place this gate fails CLOSED. The checks are a
17
+ // real test suite, and it can outrun the wiring `timeout` in
18
+ // `.claude/settings.json`. So the hook carries a budget of its own and spends
19
+ // it BEFORE the wiring's runs out.
20
+ //
21
+ // The rule the whole file follows, stated once because two earlier readings of
22
+ // it disagreed:
23
+ //
24
+ // a check that did not produce a VERDICT blocks;
25
+ // a gate that could not START stays open.
26
+ //
27
+ // `spawnSync` reports a timeout and a buffer overrun the same way — an `error`
28
+ // with `status: null` — and in both the check's result is unknown. Unmeasured
29
+ // is not a pass. The hook's OWN faults (an unreadable payload, a config it
30
+ // cannot use, a shell it cannot spawn) keep failing open and announcing
31
+ // themselves, so a crashed gate still cannot make the session unquittable.
32
+ //
33
+ // The budget is ONE allowance for the whole suite, not a fresh one per check:
34
+ // a per-step budget is how three checks quietly cost three times the wall
35
+ // clock the wiring allows (`.claude/rules/invariants.md` — "an explicit total
36
+ // budget rather than a per-step one").
37
+ // see hooks.test.ts › "gates the stop when a check outruns the budget — unmeasured is not a pass"
38
+ // see hooks.test.ts › "spends one budget across the whole suite, not a fresh one per check"
39
+ // see hooks.test.ts › "gives the stop gate a harness timeout its own budget finishes inside"
40
+ //
41
+ // ⚠ This rests on ONE assumption about the harness that nothing in this
42
+ // repository can prove or falsify: that a hook outrunning its timeout is
43
+ // killed, and that a killed Stop hook does not block the stop. If it is false
44
+ // the budget is not merely inert — it becomes the only thing that would stop a
45
+ // suite still running at the wiring timeout. It is stated rather than asserted
46
+ // because the whole design follows from it.
47
+ //
48
+ // Limits, each with the test that pins it:
49
+ // - a check whose output outgrows the buffer is UNMEASURED, and blocks like
50
+ // any other unmeasured check —
51
+ // see hooks.test.ts › "gates the stop when a check drowns its own buffer — a pass nobody watched is not a pass"
52
+ // - below that buffer, output volume is not a verdict: a chatty check that
53
+ // passes, passes —
54
+ // see hooks.test.ts › "does not read a chatty passing check as a failure (the ENOBUFS false gate)"
55
+ // - the RIG_DOD_BUDGET_MS override may only LOWER the budget, and an
56
+ // override this hook did not honour is announced rather than ignored —
57
+ // see hooks.test.ts › "clamps a budget override that would outlive the harness, and names the budget it used"
58
+ // - a fail-open is announced on stderr, because a silent exit 0 and a clean
59
+ // pass are the same observation from outside —
60
+ // see hooks.test.ts › "announces a fail-open instead of returning a silent clean pass"
61
+ // - an ABSENT config is not a failure and says nothing; only a config that
62
+ // exists and cannot be used announces —
63
+ // see hooks.test.ts › "stays silent when there is no config at all — nothing to gate is the design, not a swallowed error"
64
+ import { execSync, spawnSync } from 'node:child_process';
65
+ import { readFileSync, realpathSync } from 'node:fs';
66
+ import { fileURLToPath } from 'node:url';
67
+
68
+ import { withoutGitLocation } from '../scripts/git-env.mjs';
69
+
70
+ // 🔴 The tree this gate measures is the project the hook BELONGS to — the
71
+ // directory above `.claude/hooks/` — never the directory the session happens
72
+ // to be in. Observed twice in one session (AR-119): reviewing another branch
73
+ // in a worktree under `.claude/worktrees/`, the gate ran the suite in the
74
+ // worktree the shell had last cd-ed into and reported THAT branch's failing
75
+ // test as this session's own Definition-of-Done failure. Reviewing a foreign
76
+ // branch in a worktree is a first-class motion here, so an inherited cwd is
77
+ // the wrong tree often enough to matter. `git status` and every check run
78
+ // with this as their `cwd`, and both refusals name it, so a failure that is
79
+ // still foreign is visible at a glance. It is the session's ROOT, not its
80
+ // branch: a session started at the main checkout whose own task lives in a
81
+ // `worktree-task` worktree is measured at the main checkout, and the refusal
82
+ // says so. Resolved inside `main()`, so a throw here reaches the backstop
83
+ // and announces itself like every other fault of the gate's own.
84
+ // see hooks.test.ts › "runs the checks in the project root, so a check reading the tree sees the session project"
85
+ // see hooks.test.ts › "asks "is the tree clean?" about the project, not about the cwd"
86
+ // see hooks.test.ts › "names the tree it measured in the refusal, so a foreign failure is visible at a glance"
87
+ const projectRootOf = () => realpathSync(fileURLToPath(new URL('../..', import.meta.url)));
88
+
89
+ // The default total budget, and the allowance for everything that happens
90
+ // OUTSIDE it.
91
+ //
92
+ // The relation the gate actually needs is not "the budget is below the wiring
93
+ // `timeout`" — it is `budget + preamble <= timeout`. The budget's clock starts
94
+ // after node has booted, the payload has been read, `git status` has answered
95
+ // and the config has been parsed, so a budget merely below the wiring timeout
96
+ // still lets the whole hook outrun it. That was a real reader: 890_000 ms
97
+ // against a 900 s wiring timeout obeys "strictly below" and restores the silent
98
+ // kill this hook exists to remove.
99
+ //
100
+ // So the preamble gets a number and a leash: `git status` below is given this
101
+ // as its own timeout, which turns the allowance from a guess into a bound.
102
+ // Both numbers are read from this file and compared against the wiring —
103
+ // see hooks.test.ts › "gives the stop gate a harness timeout its own budget finishes inside"
104
+ // see hooks.test.ts › "bounds its own preamble: the git status call carries a timeout derived from the declared margin"
105
+ const DEFAULT_BUDGET_MS = 600_000;
106
+ const PREAMBLE_MARGIN_MS = 60_000;
107
+
108
+ /** The gate stays open, and says so — a silent 0 is indistinguishable from clean. */
109
+ function failOpen(reason) {
110
+ process.stderr.write(`[hook fail-open] ${reason}\n`);
111
+ return 0;
112
+ }
113
+
114
+ /**
115
+ * Error codes that mean the child never ran, so there is no verdict to lose.
116
+ * Everything else that surfaces as `result.error` — a timeout, a buffer
117
+ * overrun — killed a check that WAS running, and that is the unmeasured case.
118
+ */
119
+ // 🔴 `E2BIG` looks like it belongs here by the letter of the rule — the exec
120
+ // never started — and adding it would reopen the round-1 bypass: one
121
+ // over-long entry would then fail the WHOLE suite open. It blocks instead, and
122
+ // that is deliberate.
123
+ const SPAWN_NEVER_STARTED = new Set(['ENOENT', 'EACCES', 'EPERM', 'EMFILE', 'ENFILE', 'ENOMEM']);
124
+
125
+ /**
126
+ * The total budget for the whole suite, and a note when the override was not
127
+ * taken at face value.
128
+ *
129
+ * 🔴 The override may only LOWER the budget. Raising it past the wiring's
130
+ * `timeout` puts back the exact defect this hook exists to remove — the
131
+ * harness kills the gate before it can report, and that kill is silent.
132
+ * Raising the ceiling is a two-file decision (this constant and the wiring),
133
+ * deliberately not something one environment variable can do.
134
+ *
135
+ * An override this hook did not honour is announced, never silently dropped:
136
+ * an operator who set a budget and got a different one has to be able to see
137
+ * it. `Number.isSafeInteger` is the test rather than `isFinite`, because
138
+ * `spawnSync` throws on a fractional `timeout` — and a throw here would land in
139
+ * the backstop and open the gate completely.
140
+ * see hooks.test.ts › "runs the gate on the default budget when the override is unusable, instead of not running it"
141
+ */
142
+ function budgetMs(env) {
143
+ const raw = env.RIG_DOD_BUDGET_MS;
144
+ if (raw === undefined || raw === '') return { ms: DEFAULT_BUDGET_MS, notice: null };
145
+
146
+ const declared = Number(raw);
147
+ if (!Number.isSafeInteger(declared) || declared <= 0) {
148
+ return {
149
+ ms: DEFAULT_BUDGET_MS,
150
+ notice:
151
+ `RIG_DOD_BUDGET_MS=${raw} is not a whole number of milliseconds above zero — ` +
152
+ `using the default ${DEFAULT_BUDGET_MS} ms instead.`,
153
+ };
154
+ }
155
+ if (declared > DEFAULT_BUDGET_MS) {
156
+ return {
157
+ ms: DEFAULT_BUDGET_MS,
158
+ notice:
159
+ `RIG_DOD_BUDGET_MS=${raw} is above this gate's ceiling and was clamped to ` +
160
+ `${DEFAULT_BUDGET_MS} ms — the budget must expire before the Stop hook's ` +
161
+ `\`timeout\` in .claude/settings.json, or the gate is killed before it can report. ` +
162
+ `Raise both, in their own files, to raise it at all.`,
163
+ };
164
+ }
165
+ return { ms: declared, notice: null };
166
+ }
16
167
 
17
168
  function main() {
18
169
  let input;
19
170
  try {
20
171
  input = JSON.parse(readFileSync(0, 'utf8'));
21
- } catch {
22
- return 0;
172
+ } catch (error) {
173
+ return failOpen(`the Stop payload could not be read: ${error.message}`);
23
174
  }
24
175
  if (input.hook_event_name !== 'Stop' && input.hook_event_name !== 'SubagentStop') return 0;
25
176
  if (input.stop_hook_active) return 0;
26
177
 
178
+ const PROJECT_ROOT = projectRootOf();
179
+
27
180
  try {
28
181
  // The environment loses the variables that locate a repository first. A
29
182
  // process started under a git hook inherits an absolute GIT_DIR, and this
@@ -31,23 +184,32 @@ function main() {
31
184
  // different repository entirely: gated on somebody else's uncommitted
32
185
  // work, or waved through despite its own.
33
186
  //
34
- // Four of the eight variables that can relocate a repository, because
35
- // these are the four git itself hands its hooks — and this file ships into
36
- // generated projects, so it cannot import the canonical list from the
37
- // generator. A shorter list that says why it is shorter beats a copy that
38
- // silently drifts.
187
+ // The canonical list, imported. This file used to carry four of the eight
188
+ // inline, justified by "it ships into generated projects, so it cannot
189
+ // import the canonical list from the generator" — a reason that expired the
190
+ // day `.claude/scripts/git-env.mjs` started shipping into generated projects
191
+ // too. Both are in the same layer, one directory apart.
192
+ //
193
+ // The four it dropped were not a smaller opinion. `GIT_OBJECT_DIRECTORY`
194
+ // pointing anywhere but this repository's objects makes `git status` exit
195
+ // 128 (`fatal: bad object HEAD`) — which the catch below reads as "not a git
196
+ // repo" and waves through, running the whole Definition-of-Done suite
197
+ // against a tree it never managed to read.
39
198
  //
40
199
  // 🔴 Limit: only THIS command is sanitised. The Definition-of-Done checks
41
200
  // below run with the environment as given, because they are the project's
42
201
  // own commands and their environment is the project's business.
43
- const env = { ...process.env };
44
- for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR']) {
45
- delete env[key];
46
- }
202
+ // `timeout` below is the preamble's own leash: this is the one call that
203
+ // runs before the budget's clock starts. A repository slow enough to exceed
204
+ // it throws into the catch and the checks run anyway — the safe direction,
205
+ // and the reason the bound can be generous. The options object stays terse
206
+ // on purpose; a sibling test matches it by a bounded window.
47
207
  const status = execSync('git status --porcelain', {
208
+ cwd: PROJECT_ROOT,
48
209
  encoding: 'utf8',
49
210
  stdio: ['ignore', 'pipe', 'ignore'],
50
- env,
211
+ env: withoutGitLocation(),
212
+ timeout: PREAMBLE_MARGIN_MS,
51
213
  });
52
214
  if (status.trim() === '') return 0;
53
215
  } catch {
@@ -57,21 +219,110 @@ function main() {
57
219
  let checks;
58
220
  try {
59
221
  checks = JSON.parse(readFileSync(new URL('./dod-checks.json', import.meta.url), 'utf8'));
60
- } catch {
61
- return 0;
222
+ } catch (error) {
223
+ // An absent config is the documented "nothing to gate" case, not an error
224
+ // to announce; anything else is a config that exists and cannot be used.
225
+ if (error.code === 'ENOENT') return 0;
226
+ return failOpen(`dod-checks.json could not be read: ${error.message}`);
62
227
  }
63
- if (!Array.isArray(checks) || checks.length === 0) return 0;
228
+ if (!Array.isArray(checks)) return failOpen('dod-checks.json is not an array of commands');
229
+ // Judged per ENTRY, and the two ways to get this wrong are mirror images.
230
+ //
231
+ // Refusing the whole config on one bad entry hides a FAILING check standing
232
+ // next to the bad one: the refusal returns 0 and the neighbour never runs.
233
+ // Skipping the bad entry and returning 0 when nothing usable is left hides
234
+ // the bad entry itself: a declared check produced no verdict and the session
235
+ // still ended green. Both were shipped here, one after the other, and both
236
+ // are closed the same way — every usable entry runs, and a skipped entry is
237
+ // a check with no verdict, which blocks.
238
+ // see hooks.test.ts › "never lets an unusable config entry hide a failing check behind it (an empty string)"
239
+ // see hooks.test.ts › "refuses the stop for an empty config entry it skipped, even though every check it could run passed"
240
+ //
241
+ // The predicate is exactly the three shapes `spawnSync` throws on, and no
242
+ // wider: the shell answers every other unrunnable string with 127, which is
243
+ // a verdict. Filtering past these three converts a real block into a skip.
244
+ // see hooks.test.ts › "refuses the stop for a config it could read but cannot use, and names the file to fix"
245
+ const runnable = (command) =>
246
+ typeof command === 'string' && command !== '' && !command.includes('\0');
247
+ const usable = checks.filter(runnable);
248
+ const skipped = checks.map((command, index) => index).filter((index) => !runnable(checks[index]));
249
+
250
+ // Nothing DECLARED is the documented "nothing to gate" case. Everything
251
+ // declared being unusable is not the same state, and reading them as one is
252
+ // what let a skipped check pass for a passing suite.
253
+ if (checks.length === 0) return 0;
254
+
255
+ // Said UP FRONT, because the block for it comes last: a check that failed is
256
+ // the more useful first message, and without this line a run that stops on a
257
+ // failing check would never mention the entry it could not run at all.
258
+ //
259
+ // Deliberately NOT the `[hook fail-open]` marker: that marker means the gate
260
+ // stayed open, and this path ends in a block. One vocabulary, one meaning.
261
+ if (skipped.length > 0) {
262
+ process.stderr.write(
263
+ `gate-stop-dod: dod-checks.json entr${skipped.length > 1 ? 'ies' : 'y'} at index ` +
264
+ `${skipped.join(', ')} cannot be run as a command and will not be measured.\n`,
265
+ );
266
+ }
267
+
268
+ const budget = budgetMs(process.env);
269
+ if (budget.notice) process.stderr.write(`gate-stop-dod: ${budget.notice}\n`);
270
+ const deadline = Date.now() + budget.ms;
271
+
272
+ for (const command of usable) {
273
+ // A 1 ms floor rather than a branch for "the budget is already gone": the
274
+ // check then times out through the ordinary path, which names the command
275
+ // that did not fit instead of blaming it for spending what an earlier one
276
+ // spent. One path, and no branch that only a race can reach.
277
+ const result = spawnSync(command, {
278
+ cwd: PROJECT_ROOT,
279
+ shell: true,
280
+ encoding: 'utf8',
281
+ stdio: ['ignore', 'pipe', 'pipe'],
282
+ timeout: Math.max(1, deadline - Date.now()),
283
+ // Output volume is not a verdict — below this bound. Past it the child is
284
+ // killed and its result is unknown, which the error branch treats as
285
+ // unmeasured rather than as a failure or a pass. The old 1 MB default
286
+ // made a chatty but PASSING suite look like a failing check.
287
+ maxBuffer: 64 * 1024 * 1024,
288
+ });
289
+
290
+ if (result.error) {
291
+ // 🔴 The rule this file follows, at the point where it is decided.
292
+ //
293
+ // A spawn that never started is the gate's own problem — no shell, no
294
+ // file descriptors — and there is no verdict to lose, so it stays open
295
+ // and says so.
296
+ if (SPAWN_NEVER_STARTED.has(result.error.code)) {
297
+ return failOpen(`the check \`${command}\` could not be started: ${result.error.message}`);
298
+ }
299
+ // Everything else killed a check that WAS running — a timeout, a buffer
300
+ // overrun, anything unrecognised. `status` is null, so the verdict is
301
+ // unknown, and an unmeasured Definition of Done is not a passed one.
302
+ const timedOut = result.error.code === 'ETIMEDOUT';
303
+ process.stderr.write(
304
+ `STOP GATED — \`${command}\` produced no verdict (measured in ${PROJECT_ROOT}): ` +
305
+ `${result.error.message}\n` +
306
+ (timedOut
307
+ ? `It did not finish inside the ${budget.ms} ms budget for the whole suite ` +
308
+ `(RIG_DOD_BUDGET_MS lowers it; raising it means raising this hook's default ` +
309
+ `and the Stop hook's \`timeout\` in .claude/settings.json together, in that ` +
310
+ `order — the budget must stay the smaller of the two).\n`
311
+ : `It was killed before it could report — output past the buffer is the usual ` +
312
+ `cause. Run it yourself to see the result.\n`) +
313
+ `The check's outcome is UNMEASURED, which is not a pass. ` +
314
+ `This gate never fires twice in a row.\n`,
315
+ );
316
+ return 2;
317
+ }
64
318
 
65
- for (const command of checks) {
66
- try {
67
- execSync(command, { stdio: ['ignore', 'pipe', 'pipe'] });
68
- } catch (error) {
69
- const tail = String(error.stdout ?? '')
319
+ if (result.status !== 0) {
320
+ const tail = String(result.stdout ?? '')
70
321
  .split('\n')
71
322
  .slice(-15)
72
323
  .join('\n');
73
324
  process.stderr.write(
74
- `STOP GATED — a Definition of Done check fails: ${command}\n` +
325
+ `STOP GATED — a Definition of Done check fails (measured in ${PROJECT_ROOT}): ${command}\n` +
75
326
  (tail.trim() ? `${tail}\n` : '') +
76
327
  `Fix the failure before ending the session. If this failure has resisted ` +
77
328
  `repeated attempts, follow the stop rules instead: end with a written ` +
@@ -80,7 +331,31 @@ function main() {
80
331
  return 2;
81
332
  }
82
333
  }
334
+
335
+ // Reached only when every check that COULD run passed. A skipped entry is a
336
+ // declared check that produced no verdict, and unmeasured is not a pass —
337
+ // the same rule the killed-check branch above answers to. It is reported
338
+ // last because a check that actually failed is the more useful message.
339
+ if (skipped.length > 0) {
340
+ const plural = skipped.length > 1;
341
+ process.stderr.write(
342
+ `STOP GATED — dod-checks.json entr${plural ? 'ies' : 'y'} at index ` +
343
+ `${skipped.join(', ')} cannot be run as a command, so ${plural ? 'those checks' : 'that check'} ` +
344
+ `produced no verdict. Every other check passed; these were never measured, ` +
345
+ `and unmeasured is not a pass. Fix the entr${plural ? 'ies' : 'y'} — an empty string, ` +
346
+ `a NUL byte or a non-string is not a command. This gate never fires twice in a row.\n`,
347
+ );
348
+ return 2;
349
+ }
83
350
  return 0;
84
351
  }
85
352
 
86
- process.exit(main());
353
+ let code;
354
+ try {
355
+ code = main();
356
+ } catch (error) {
357
+ // The backstop. A gate that throws must not make the session unquittable —
358
+ // but it must not look clean either.
359
+ code = failOpen(`the gate itself threw: ${error.message}`);
360
+ }
361
+ process.exit(code);