create-agent-rig 0.4.0 → 0.5.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 (105) hide show
  1. package/CHANGELOG.md +207 -8
  2. package/README.md +50 -17
  3. package/package.json +2 -1
  4. package/packages/cli/dist/commands/create.js +8 -3
  5. package/packages/cli/dist/commands/init.js +82 -34
  6. package/packages/cli/dist/commands/upgrade.js +112 -30
  7. package/packages/cli/dist/index.js +38 -14
  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 +21 -9
  12. package/packages/cli/dist/lib/safe-path.js +30 -0
  13. package/templates/agent-os/init/AGENTS.md +191 -0
  14. package/templates/agent-os/init/CLAUDE.md +61 -9
  15. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +98 -0
  16. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
  17. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +34 -3
  18. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
  19. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +34 -0
  20. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
  21. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +233 -0
  22. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +676 -0
  23. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
  24. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
  25. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  26. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +338 -0
  27. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
  28. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +33 -1
  29. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +64 -1
  30. package/templates/agent-os/universal/.claude/agents/security-scanner.md +33 -0
  31. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +274 -24
  32. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
  33. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
  34. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
  35. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
  36. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
  37. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +480 -0
  38. package/templates/agent-os/universal/.claude/rules/autonomy.md +61 -1
  39. package/templates/agent-os/universal/.claude/rules/invariants.md +71 -16
  40. package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
  41. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1242 -0
  42. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
  43. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
  44. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
  45. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +433 -0
  46. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +6 -16
  47. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +89 -0
  48. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +334 -23
  49. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
  50. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +7 -2
  51. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +413 -8
  52. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +75 -13
  53. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +222 -21
  54. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
  55. package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
  56. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +441 -0
  57. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -0
  58. package/templates/agent-os/universal/.claude/settings.json +7 -2
  59. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +115 -7
  60. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +378 -31
  61. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +300 -25
  62. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
  63. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
  64. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
  65. package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
  66. package/templates/agent-os/universal/.codex/hooks.json +65 -0
  67. package/templates/agent-os/universal/AGENTS.md +164 -0
  68. package/templates/agent-os/universal/CLAUDE.md +47 -14
  69. package/templates/agent-os/universal/PLAN.md +7 -40
  70. package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
  71. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
  72. package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
  73. package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
  74. package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
  75. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
  76. package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
  77. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +62 -0
  78. package/templates/agent-os/universal/journal/README.md +101 -0
  79. package/templates/agent-os/universal/layers.json +36 -2
  80. package/templates/hash-history.json +2 -1
  81. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
  82. package/templates/skeleton/aws-serverless/README.md +91 -9
  83. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
  84. package/templates/skeleton/aws-serverless/gitignore +37 -0
  85. package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
  86. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
  87. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
  88. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
  89. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
  90. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
  91. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
  92. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
  93. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
  94. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
  95. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
  96. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
  97. package/templates/skeleton/node-service/README.md +11 -1
  98. package/templates/skeleton/node-service/gitignore +34 -0
  99. package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
  100. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
  101. package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
  102. package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
  103. package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
  104. package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
  105. 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,35 @@ 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
+ }
74
+ ```
75
+
76
+ - `verdict` is `SHIP`, `HOLD` or `NOT_APPLICABLE` — no other word.
77
+ - Every blocker names the `rule` it violates. `file` and `line` travel together
78
+ and are both omitted when the finding has no single location.
79
+ - A `HOLD` with an empty `blockers` list is **refused**, and so is a `SHIP`
80
+ carrying one: `node .claude/scripts/verdict.mjs check <report> <this gate>` is
81
+ what refuses them, and the shape it enforces is in
82
+ `.claude/scripts/lib/verdict.mjs`. The gate name is what stops your answer
83
+ being read as somebody else's.
@@ -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,34 @@ 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
+ }
159
+ ```
160
+
161
+ - `verdict` is `SHIP`, `HOLD` or `NOT_APPLICABLE` — no other word.
162
+ - Every blocker names the `rule` it violates; give the `file` and `line` of the
163
+ text, and cite the contradicting mechanism in the `note`.
164
+ - A `HOLD` naming no blocker is **refused**, and so is a `SHIP` carrying one:
165
+ `node .claude/scripts/verdict.mjs check <report> <this gate>` is what refuses
166
+ them, and the gate name is what stops your answer being read as somebody
167
+ else's.
@@ -38,3 +38,36 @@ 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
+ }
63
+ ```
64
+
65
+ - `verdict` is `SHIP` (nothing blocking), `HOLD`, or `NOT_APPLICABLE` when the
66
+ change is outside your triggers — that last one is the structured form of the
67
+ clean "not security-relevant" answer above.
68
+ - Every blocker names the `rule` it violates, with `file` and `line` when the
69
+ finding has a location and neither when it does not.
70
+ - A `HOLD` naming no blocker is **refused**, and so is a `SHIP` carrying one:
71
+ `node .claude/scripts/verdict.mjs check <report> <this gate>` is what refuses
72
+ them, and the gate name is what stops your answer being read as somebody
73
+ else's.
@@ -9,17 +9,148 @@
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';
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';
15
65
  import { readFileSync } from 'node:fs';
16
66
 
67
+ import { withoutGitLocation } from '../scripts/git-env.mjs';
68
+
69
+ // The default total budget, and the allowance for everything that happens
70
+ // OUTSIDE it.
71
+ //
72
+ // The relation the gate actually needs is not "the budget is below the wiring
73
+ // `timeout`" — it is `budget + preamble <= timeout`. The budget's clock starts
74
+ // after node has booted, the payload has been read, `git status` has answered
75
+ // and the config has been parsed, so a budget merely below the wiring timeout
76
+ // still lets the whole hook outrun it. That was a real reader: 890_000 ms
77
+ // against a 900 s wiring timeout obeys "strictly below" and restores the silent
78
+ // kill this hook exists to remove.
79
+ //
80
+ // So the preamble gets a number and a leash: `git status` below is given this
81
+ // as its own timeout, which turns the allowance from a guess into a bound.
82
+ // Both numbers are read from this file and compared against the wiring —
83
+ // see hooks.test.ts › "gives the stop gate a harness timeout its own budget finishes inside"
84
+ // see hooks.test.ts › "bounds its own preamble: the git status call carries a timeout derived from the declared margin"
85
+ const DEFAULT_BUDGET_MS = 600_000;
86
+ const PREAMBLE_MARGIN_MS = 60_000;
87
+
88
+ /** The gate stays open, and says so — a silent 0 is indistinguishable from clean. */
89
+ function failOpen(reason) {
90
+ process.stderr.write(`[hook fail-open] ${reason}\n`);
91
+ return 0;
92
+ }
93
+
94
+ /**
95
+ * Error codes that mean the child never ran, so there is no verdict to lose.
96
+ * Everything else that surfaces as `result.error` — a timeout, a buffer
97
+ * overrun — killed a check that WAS running, and that is the unmeasured case.
98
+ */
99
+ // 🔴 `E2BIG` looks like it belongs here by the letter of the rule — the exec
100
+ // never started — and adding it would reopen the round-1 bypass: one
101
+ // over-long entry would then fail the WHOLE suite open. It blocks instead, and
102
+ // that is deliberate.
103
+ const SPAWN_NEVER_STARTED = new Set(['ENOENT', 'EACCES', 'EPERM', 'EMFILE', 'ENFILE', 'ENOMEM']);
104
+
105
+ /**
106
+ * The total budget for the whole suite, and a note when the override was not
107
+ * taken at face value.
108
+ *
109
+ * 🔴 The override may only LOWER the budget. Raising it past the wiring's
110
+ * `timeout` puts back the exact defect this hook exists to remove — the
111
+ * harness kills the gate before it can report, and that kill is silent.
112
+ * Raising the ceiling is a two-file decision (this constant and the wiring),
113
+ * deliberately not something one environment variable can do.
114
+ *
115
+ * An override this hook did not honour is announced, never silently dropped:
116
+ * an operator who set a budget and got a different one has to be able to see
117
+ * it. `Number.isSafeInteger` is the test rather than `isFinite`, because
118
+ * `spawnSync` throws on a fractional `timeout` — and a throw here would land in
119
+ * the backstop and open the gate completely.
120
+ * see hooks.test.ts › "runs the gate on the default budget when the override is unusable, instead of not running it"
121
+ */
122
+ function budgetMs(env) {
123
+ const raw = env.RIG_DOD_BUDGET_MS;
124
+ if (raw === undefined || raw === '') return { ms: DEFAULT_BUDGET_MS, notice: null };
125
+
126
+ const declared = Number(raw);
127
+ if (!Number.isSafeInteger(declared) || declared <= 0) {
128
+ return {
129
+ ms: DEFAULT_BUDGET_MS,
130
+ notice:
131
+ `RIG_DOD_BUDGET_MS=${raw} is not a whole number of milliseconds above zero — ` +
132
+ `using the default ${DEFAULT_BUDGET_MS} ms instead.`,
133
+ };
134
+ }
135
+ if (declared > DEFAULT_BUDGET_MS) {
136
+ return {
137
+ ms: DEFAULT_BUDGET_MS,
138
+ notice:
139
+ `RIG_DOD_BUDGET_MS=${raw} is above this gate's ceiling and was clamped to ` +
140
+ `${DEFAULT_BUDGET_MS} ms — the budget must expire before the Stop hook's ` +
141
+ `\`timeout\` in .claude/settings.json, or the gate is killed before it can report. ` +
142
+ `Raise both, in their own files, to raise it at all.`,
143
+ };
144
+ }
145
+ return { ms: declared, notice: null };
146
+ }
147
+
17
148
  function main() {
18
149
  let input;
19
150
  try {
20
151
  input = JSON.parse(readFileSync(0, 'utf8'));
21
- } catch {
22
- return 0;
152
+ } catch (error) {
153
+ return failOpen(`the Stop payload could not be read: ${error.message}`);
23
154
  }
24
155
  if (input.hook_event_name !== 'Stop' && input.hook_event_name !== 'SubagentStop') return 0;
25
156
  if (input.stop_hook_active) return 0;
@@ -31,23 +162,31 @@ function main() {
31
162
  // different repository entirely: gated on somebody else's uncommitted
32
163
  // work, or waved through despite its own.
33
164
  //
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.
165
+ // The canonical list, imported. This file used to carry four of the eight
166
+ // inline, justified by "it ships into generated projects, so it cannot
167
+ // import the canonical list from the generator" — a reason that expired the
168
+ // day `.claude/scripts/git-env.mjs` started shipping into generated projects
169
+ // too. Both are in the same layer, one directory apart.
170
+ //
171
+ // The four it dropped were not a smaller opinion. `GIT_OBJECT_DIRECTORY`
172
+ // pointing anywhere but this repository's objects makes `git status` exit
173
+ // 128 (`fatal: bad object HEAD`) — which the catch below reads as "not a git
174
+ // repo" and waves through, running the whole Definition-of-Done suite
175
+ // against a tree it never managed to read.
39
176
  //
40
177
  // 🔴 Limit: only THIS command is sanitised. The Definition-of-Done checks
41
178
  // below run with the environment as given, because they are the project's
42
179
  // 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
- }
180
+ // `timeout` below is the preamble's own leash: this is the one call that
181
+ // runs before the budget's clock starts. A repository slow enough to exceed
182
+ // it throws into the catch and the checks run anyway — the safe direction,
183
+ // and the reason the bound can be generous. The options object stays terse
184
+ // on purpose; a sibling test matches it by a bounded window.
47
185
  const status = execSync('git status --porcelain', {
48
186
  encoding: 'utf8',
49
187
  stdio: ['ignore', 'pipe', 'ignore'],
50
- env,
188
+ env: withoutGitLocation(),
189
+ timeout: PREAMBLE_MARGIN_MS,
51
190
  });
52
191
  if (status.trim() === '') return 0;
53
192
  } catch {
@@ -57,16 +196,103 @@ function main() {
57
196
  let checks;
58
197
  try {
59
198
  checks = JSON.parse(readFileSync(new URL('./dod-checks.json', import.meta.url), 'utf8'));
60
- } catch {
61
- return 0;
199
+ } catch (error) {
200
+ // An absent config is the documented "nothing to gate" case, not an error
201
+ // to announce; anything else is a config that exists and cannot be used.
202
+ if (error.code === 'ENOENT') return 0;
203
+ return failOpen(`dod-checks.json could not be read: ${error.message}`);
204
+ }
205
+ if (!Array.isArray(checks)) return failOpen('dod-checks.json is not an array of commands');
206
+ // Judged per ENTRY, and the two ways to get this wrong are mirror images.
207
+ //
208
+ // Refusing the whole config on one bad entry hides a FAILING check standing
209
+ // next to the bad one: the refusal returns 0 and the neighbour never runs.
210
+ // Skipping the bad entry and returning 0 when nothing usable is left hides
211
+ // the bad entry itself: a declared check produced no verdict and the session
212
+ // still ended green. Both were shipped here, one after the other, and both
213
+ // are closed the same way — every usable entry runs, and a skipped entry is
214
+ // a check with no verdict, which blocks.
215
+ // see hooks.test.ts › "never lets an unusable config entry hide a failing check behind it (an empty string)"
216
+ // see hooks.test.ts › "refuses the stop for an empty config entry it skipped, even though every check it could run passed"
217
+ //
218
+ // The predicate is exactly the three shapes `spawnSync` throws on, and no
219
+ // wider: the shell answers every other unrunnable string with 127, which is
220
+ // a verdict. Filtering past these three converts a real block into a skip.
221
+ // see hooks.test.ts › "refuses the stop for a config it could read but cannot use, and names the file to fix"
222
+ const runnable = (command) =>
223
+ typeof command === 'string' && command !== '' && !command.includes('\0');
224
+ const usable = checks.filter(runnable);
225
+ const skipped = checks.map((command, index) => index).filter((index) => !runnable(checks[index]));
226
+
227
+ // Nothing DECLARED is the documented "nothing to gate" case. Everything
228
+ // declared being unusable is not the same state, and reading them as one is
229
+ // what let a skipped check pass for a passing suite.
230
+ if (checks.length === 0) return 0;
231
+
232
+ // Said UP FRONT, because the block for it comes last: a check that failed is
233
+ // the more useful first message, and without this line a run that stops on a
234
+ // failing check would never mention the entry it could not run at all.
235
+ //
236
+ // Deliberately NOT the `[hook fail-open]` marker: that marker means the gate
237
+ // stayed open, and this path ends in a block. One vocabulary, one meaning.
238
+ if (skipped.length > 0) {
239
+ process.stderr.write(
240
+ `gate-stop-dod: dod-checks.json entr${skipped.length > 1 ? 'ies' : 'y'} at index ` +
241
+ `${skipped.join(', ')} cannot be run as a command and will not be measured.\n`,
242
+ );
62
243
  }
63
- if (!Array.isArray(checks) || checks.length === 0) return 0;
64
244
 
65
- for (const command of checks) {
66
- try {
67
- execSync(command, { stdio: ['ignore', 'pipe', 'pipe'] });
68
- } catch (error) {
69
- const tail = String(error.stdout ?? '')
245
+ const budget = budgetMs(process.env);
246
+ if (budget.notice) process.stderr.write(`gate-stop-dod: ${budget.notice}\n`);
247
+ const deadline = Date.now() + budget.ms;
248
+
249
+ for (const command of usable) {
250
+ // A 1 ms floor rather than a branch for "the budget is already gone": the
251
+ // check then times out through the ordinary path, which names the command
252
+ // that did not fit instead of blaming it for spending what an earlier one
253
+ // spent. One path, and no branch that only a race can reach.
254
+ const result = spawnSync(command, {
255
+ shell: true,
256
+ encoding: 'utf8',
257
+ stdio: ['ignore', 'pipe', 'pipe'],
258
+ timeout: Math.max(1, deadline - Date.now()),
259
+ // Output volume is not a verdict — below this bound. Past it the child is
260
+ // killed and its result is unknown, which the error branch treats as
261
+ // unmeasured rather than as a failure or a pass. The old 1 MB default
262
+ // made a chatty but PASSING suite look like a failing check.
263
+ maxBuffer: 64 * 1024 * 1024,
264
+ });
265
+
266
+ if (result.error) {
267
+ // 🔴 The rule this file follows, at the point where it is decided.
268
+ //
269
+ // A spawn that never started is the gate's own problem — no shell, no
270
+ // file descriptors — and there is no verdict to lose, so it stays open
271
+ // and says so.
272
+ if (SPAWN_NEVER_STARTED.has(result.error.code)) {
273
+ return failOpen(`the check \`${command}\` could not be started: ${result.error.message}`);
274
+ }
275
+ // Everything else killed a check that WAS running — a timeout, a buffer
276
+ // overrun, anything unrecognised. `status` is null, so the verdict is
277
+ // unknown, and an unmeasured Definition of Done is not a passed one.
278
+ const timedOut = result.error.code === 'ETIMEDOUT';
279
+ process.stderr.write(
280
+ `STOP GATED — \`${command}\` produced no verdict: ${result.error.message}\n` +
281
+ (timedOut
282
+ ? `It did not finish inside the ${budget.ms} ms budget for the whole suite ` +
283
+ `(RIG_DOD_BUDGET_MS lowers it; raising it means raising this hook's default ` +
284
+ `and the Stop hook's \`timeout\` in .claude/settings.json together, in that ` +
285
+ `order — the budget must stay the smaller of the two).\n`
286
+ : `It was killed before it could report — output past the buffer is the usual ` +
287
+ `cause. Run it yourself to see the result.\n`) +
288
+ `The check's outcome is UNMEASURED, which is not a pass. ` +
289
+ `This gate never fires twice in a row.\n`,
290
+ );
291
+ return 2;
292
+ }
293
+
294
+ if (result.status !== 0) {
295
+ const tail = String(result.stdout ?? '')
70
296
  .split('\n')
71
297
  .slice(-15)
72
298
  .join('\n');
@@ -80,7 +306,31 @@ function main() {
80
306
  return 2;
81
307
  }
82
308
  }
309
+
310
+ // Reached only when every check that COULD run passed. A skipped entry is a
311
+ // declared check that produced no verdict, and unmeasured is not a pass —
312
+ // the same rule the killed-check branch above answers to. It is reported
313
+ // last because a check that actually failed is the more useful message.
314
+ if (skipped.length > 0) {
315
+ const plural = skipped.length > 1;
316
+ process.stderr.write(
317
+ `STOP GATED — dod-checks.json entr${plural ? 'ies' : 'y'} at index ` +
318
+ `${skipped.join(', ')} cannot be run as a command, so ${plural ? 'those checks' : 'that check'} ` +
319
+ `produced no verdict. Every other check passed; these were never measured, ` +
320
+ `and unmeasured is not a pass. Fix the entr${plural ? 'ies' : 'y'} — an empty string, ` +
321
+ `a NUL byte or a non-string is not a command. This gate never fires twice in a row.\n`,
322
+ );
323
+ return 2;
324
+ }
83
325
  return 0;
84
326
  }
85
327
 
86
- process.exit(main());
328
+ let code;
329
+ try {
330
+ code = main();
331
+ } catch (error) {
332
+ // The backstop. A gate that throws must not make the session unquittable —
333
+ // but it must not look clean either.
334
+ code = failOpen(`the gate itself threw: ${error.message}`);
335
+ }
336
+ process.exit(code);
@@ -135,6 +135,43 @@ const WRAPPER_VALUE_FLAGS = {
135
135
  const KEYWORDS = new Set(['do', 'then', 'else', 'elif', 'fi', 'done', 'in', '!', '{', '}']);
136
136
  /** Shells whose `-c` argument is itself a command line, so it must be parsed too. */
137
137
  const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh']);
138
+ /** Shell options that consume the following token before the `-c` script. */
139
+ const SHELL_VALUE_FLAGS = new Set(['-o', '-O', '+o', '+O', '--init-file', '--rcfile']);
140
+ const compactShellValueCount = (value) =>
141
+ /^[+-][^+-]+$/.test(value)
142
+ ? [...value].filter((flag) => flag === 'o' || flag === 'O').length
143
+ : 0;
144
+
145
+ const shellScript = (args) => {
146
+ let commandFlag = -1;
147
+ for (let index = 0; index < args.length; index += 1) {
148
+ const value = args[index].value;
149
+ if (value === '--' || value === '-' || value === '+' || !/^[+-]/.test(value)) break;
150
+ if (value === '-c' || (/^-[^-]+$/.test(value) && value.includes('c'))) {
151
+ commandFlag = index;
152
+ break;
153
+ }
154
+ const valueCount = compactShellValueCount(value);
155
+ if (valueCount > 0 || SHELL_VALUE_FLAGS.has(value)) index += Math.max(1, valueCount);
156
+ }
157
+ if (commandFlag === -1) return '';
158
+
159
+ const compactFlag = args[commandFlag].value;
160
+ const compactValues = compactShellValueCount(compactFlag);
161
+ const firstCandidate = commandFlag + 1 + compactValues;
162
+ for (let index = firstCandidate; index < args.length; index += 1) {
163
+ const value = args[index].value;
164
+ if (value === '--') return args[index + 1]?.value ?? '';
165
+ const valueCount = compactShellValueCount(value);
166
+ if (valueCount > 0 || SHELL_VALUE_FLAGS.has(value)) {
167
+ index += Math.max(1, valueCount);
168
+ continue;
169
+ }
170
+ if ((value.startsWith('-') || value.startsWith('+')) && value !== '-' && value !== '+') continue;
171
+ return value;
172
+ }
173
+ return '';
174
+ };
138
175
  /**
139
176
  * Flags whose VALUE is prose or a path, never a ref. Skipping them is what keeps
140
177
  * a commit message from being read as a live argument.
@@ -735,11 +772,10 @@ export const inspect = (raw, brake, depth = 0) => {
735
772
  // `bash -c "<command line>"` / `eval "<command line>"` — the payload is a
736
773
  // command line of its own. Taken whether or not it is quote-delimited: a
737
774
  // backslash-joined payload is still a payload.
738
- const flagIndex = command.args.findIndex(({ value }) => value === '-c');
739
775
  const script =
740
- flagIndex >= 0
741
- ? command.args[flagIndex + 1]?.value
742
- : command.args.map(({ value }) => value).join(' ');
776
+ command.name === 'eval'
777
+ ? command.args.map(({ value }) => value).join(' ')
778
+ : shellScript(command.args);
743
779
  if (script) {
744
780
  const reason = inspect(script, brake, depth + 1);
745
781
  if (reason) return reason;
@@ -774,7 +810,9 @@ function main() {
774
810
  return 0;
775
811
  }
776
812
  if (input.tool_name !== 'Bash') return 0;
777
- const raw = String(input.tool_input?.command ?? '');
813
+ const commandValue = input.tool_input?.command;
814
+ if (typeof commandValue !== 'string') return 0;
815
+ const raw = commandValue;
778
816
  if (!raw.trim()) return 0;
779
817
 
780
818
  try {