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,441 @@
1
+ /**
2
+ * The run's own state — three of the four values `stopConditionOf` asks for and
3
+ * nothing used to answer. The fourth, `killSwitch`, is deliberately not here:
4
+ * it is already mechanical in `guard-bash` and scripted in preflight, and a
5
+ * second answer to "is the brake on" is the disagreement `invariants.md`
6
+ * forbids.
7
+ *
8
+ * 🔴 **Why this file exists.** `core.mjs` takes `consecutiveEscalations`,
9
+ * `lastDeployVerdict`, `budgetExhausted` and `killSwitch`, and every branch that
10
+ * reads them was live — but the CLI called it with `candidates` and `skipped`
11
+ * only, so all four held their defaults on every real selection. The values
12
+ * were "remembered" by the session instead, which is precisely the guarantee a
13
+ * mechanical check exists to replace, and they are lost at compaction — the
14
+ * moment a long run needs them most. A stop rule nobody can supply the input
15
+ * for is a stop rule that never fires.
16
+ *
17
+ * 🔴 **Per run, not per checkout, and the difference is the whole design.**
18
+ * `escalations` means *two in a row in this run*; a declared budget belongs to
19
+ * this run; a `REGRESSION` verdict is about the deploy this run made. Written
20
+ * into a checkout-wide file, every one of them would outlive the run that
21
+ * learned it: two runs sharing a checkout would stop each other on a counter
22
+ * neither of them raised, and a regression would carry into tomorrow's run,
23
+ * which knows nothing about the deploy that caused it and is the run best
24
+ * placed to fix it. (`HEALTHY` below can clear a verdict — so the argument is
25
+ * *whose fact is it*, not *can it be undone*. A stop that has to be
26
+ * hand-cleared by a run that did not set it is a stop that will be
27
+ * hand-cleared without being read.)
28
+ *
29
+ * The one value that is genuinely per-checkout stays where it is:
30
+ * `lastCompletedTier` in `queue/state.mjs` rations elevated work *across* runs,
31
+ * so moving it here would hand each new run a clean slate and restore the exact
32
+ * defect that file was written to close. The close writes both, deliberately.
33
+ *
34
+ * **What this module does NOT own:** the run-id convention and creating the
35
+ * directory. Those belong to whatever drives the run — here the `loop` skill,
36
+ * which declares `RIG_RUN_DIR` in its preflight. Handed a `runDir`, this module
37
+ * uses it verbatim, exactly as `run-journal.mjs` does. Two owners of one
38
+ * convention disagree the first time either changes.
39
+ *
40
+ * ⚠ **It assumes one writer**, like the journal beside it — but degrades
41
+ * differently, and the difference is worth knowing before relying on either.
42
+ * The journal *detects* a collision through its sequence and refuses; this
43
+ * merge is read-then-write with no lock, so two processes sharing a run
44
+ * directory silently lose one of their patches. Measured: four processes ×
45
+ * 200 increments recorded 215. The loss is always downward, so the stop this
46
+ * file exists to fire fires **late or never** — never early. One run directory
47
+ * per run is the caller's part of the contract, and the `loop` skill states it.
48
+ */
49
+
50
+ import { readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
51
+ import { join } from 'node:path';
52
+ import { fileURLToPath } from 'node:url';
53
+
54
+ const STATE = 'state.json';
55
+
56
+ /** The state file's path inside a run directory — one definition, not two. */
57
+ export const statePathIn = (runDir) => join(runDir, STATE);
58
+
59
+ /**
60
+ * What the run has recorded so far; `{}` when it has recorded nothing.
61
+ *
62
+ * 🔴 **Unreadable is empty, and that is a decision rather than an oversight.**
63
+ * A corrupt or half-written state file must not stop the run from selecting
64
+ * work: the failure mode of reading it as "no state" is today's behaviour —
65
+ * which is exactly what the caller had before this module — while the failure
66
+ * mode of throwing is a run that cannot take an item because of a file that
67
+ * only ever *adds* stop conditions. Fail towards the behaviour that was already
68
+ * trusted.
69
+ *
70
+ * Note the asymmetry with `run-journal.mjs`, which refuses a broken sequence
71
+ * loudly: the journal's whole job is to be trustworthy evidence, so a journal
72
+ * that cannot vouch for itself must say so. This file's job is to supply stop
73
+ * inputs, and an absent input is a defined state there.
74
+ */
75
+ export const readState = (runDir) => {
76
+ if (!runDir) return {};
77
+ try {
78
+ const parsed = JSON.parse(readFileSync(statePathIn(runDir), 'utf8'));
79
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : {};
80
+ } catch {
81
+ return {};
82
+ }
83
+ };
84
+
85
+ /**
86
+ * Merge `patch` over what is already recorded, and return the result.
87
+ *
88
+ * Shallow by design: the fields are flat values plus one object
89
+ * (`triggersFired`), and a deep merge would make "clear this field"
90
+ * unexpressible — a caller passing an object means *this value now*, not
91
+ * *these keys added to whatever was there*. The `trigger` command therefore
92
+ * merges its own map before calling, where the intent is explicit.
93
+ *
94
+ * 🔴 **Write-then-rename, because the reader is another process.** A plain
95
+ * write leaves a window where the file on disk is half a JSON document, and the
96
+ * reader above turns that into `{}` — a silently forgotten escalation streak.
97
+ * A rename within one directory is atomic on **POSIX**, so a reader sees either
98
+ * the old state or the new one and never a torn one. On Windows the same call
99
+ * goes through `MoveFileEx`, which does not guarantee atomicity when it
100
+ * replaces an existing file and fails outright if the destination is open — so
101
+ * there the failure is loud rather than torn, which is the direction to prefer
102
+ * if it ever has to be handled.
103
+ */
104
+ export const updateState = (runDir, patch) => {
105
+ const next = { ...readState(runDir), ...patch };
106
+ const file = statePathIn(runDir);
107
+ // The temp name carries the pid so a second writer cannot clobber the first
108
+ // one's half-written file — the merge above is still unsafe under two
109
+ // writers, but a torn read is not how it fails.
110
+ const tmp = `${file}.${process.pid}.tmp`;
111
+ // `wx` refuses an existing path rather than writing through it. Without it a
112
+ // symlink planted at the temp name is followed, and the rename then makes
113
+ // `state.json` itself that symlink — so the run's stop conditions would be
114
+ // read from, and written to, a file somebody else chose. The pid narrows who
115
+ // can guess the name; it does not stop anyone who watches the directory.
116
+ //
117
+ // `0o600` narrows this file specifically. It is not a claim about the run
118
+ // directory: `run-journal.mjs` appends beside it at the default mode, and its
119
+ // records carry the same run's item ids.
120
+ writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
121
+ try {
122
+ renameSync(tmp, file);
123
+ } catch (error) {
124
+ // A failed rename used to leave the temp file behind, where `wx` then
125
+ // refuses any later write from a process that redraws the same pid — the
126
+ // name carries it, so this is a narrow collision rather than a bricked run,
127
+ // and it is loud either way. The one case this cannot clean is a process
128
+ // killed between the write and the rename: no handler runs, and the temp
129
+ // leaks until the run directory is discarded. The unlink's own failure is
130
+ // swallowed on purpose: the caller needs the rename's error, not this one.
131
+ try {
132
+ unlinkSync(tmp);
133
+ } catch {
134
+ /* the temp file is already gone, or unreachable for the same reason the rename was */
135
+ }
136
+ throw error;
137
+ }
138
+ return next;
139
+ };
140
+
141
+ /**
142
+ * Count one task that hit a wall, and hand back the new total.
143
+ *
144
+ * 🔴 **One recorder, called by three adapters — not three counters.** Every
145
+ * adapter's `escalate()` needs this, and three copies of read-then-increment is
146
+ * how the number means something different depending on which tracker the rig
147
+ * happens to use (`invariants.md`, "one mechanism, one implementation"). The
148
+ * adapters own *how a tracker records an escalation*; the count of them in this
149
+ * run is not tracker business at all.
150
+ *
151
+ * 🔴 **Where to call it from, stated because the next adapter's author cannot
152
+ * infer it:** the count rises **after** the tracker has been mutated, and
153
+ * unconditionally only where the adapter mutates no tracker at all. So
154
+ * `github-issues` and `jira` call this last, once the comment and the label
155
+ * have landed; `plan-md` calls it immediately, because a flat list has nothing
156
+ * to write. Called first, the count would claim an escalation the tracker never
157
+ * received — and it is the count, not the tracker, that ends the run.
158
+ *
159
+ * A run that declared no directory records nothing and **does not throw**: an
160
+ * attended session escalating an item by hand is an ordinary thing to do, and a
161
+ * recorder that refused there would push callers into not calling it.
162
+ *
163
+ * A stored value that is not a number reads as none. It is the same permissive
164
+ * read as {@link readState} and for the same reason — a hand-edited state file
165
+ * must not be able to make the count `NaN`, which compares false against every
166
+ * threshold and silently disables the stop it feeds.
167
+ */
168
+ export const recordEscalation = (runDir) => {
169
+ if (!runDir) return 0;
170
+ const onDisk = readState(runDir).escalations;
171
+ const current = Number.isInteger(onDisk) && onDisk >= 0 ? onDisk : 0;
172
+ try {
173
+ updateState(runDir, { escalations: current + 1 });
174
+ return current + 1;
175
+ } catch (error) {
176
+ // 🔴 **This one call tolerates a failed write, and the reason is the caller,
177
+ // not the value.** Two of the three adapters run this AFTER the tracker is
178
+ // already mutated — the comment posted, the `escalated` label applied — so a
179
+ // stale `RIG_RUN_DIR` (an export surviving the last run, a cleaned run
180
+ // directory, a worktree the loop's `mkdir -p` never reached) would turn a
181
+ // fully successful escalation into a thrown error. The caller then retries,
182
+ // and the retry double-posts the diagnosis onto the issue.
183
+ //
184
+ // Loud, not silent: swallowing this quietly would trade a crash for a lie,
185
+ // and the count feeding the two-in-a-row stop would drift below the truth
186
+ // with nothing to show for it. Stderr, never stdout — a stray line on stdout
187
+ // lands inside `queue next --json`'s document.
188
+ //
189
+ // `updateState` itself keeps throwing. The CLI writers below refuse loudly
190
+ // on purpose, and only this call site has a mutated tracker behind it.
191
+ process.stderr.write(
192
+ `run state: the escalation was NOT counted in ${runDir} — ${error.message}\n` +
193
+ ' the tracker was updated; only the local count was lost. Fix the run ' +
194
+ 'directory before the next task, or the two-in-a-row stop reads low.\n',
195
+ );
196
+ // The count actually on disk, not the one intended: a caller journalling
197
+ // this number must not record a streak the state file does not carry.
198
+ return current;
199
+ }
200
+ };
201
+
202
+ /**
203
+ * The stop inputs, read out of a state object that anything may have written.
204
+ *
205
+ * 🔴 **An uninterpretable value is never "no stop".** The writers here are
206
+ * careful — `recordEscalation` refuses a non-integer, the CLI checks its
207
+ * vocabulary before writing — but the file is documented as hand-editable, and
208
+ * the reader used to pass whatever JSON held straight into JS comparisons.
209
+ * Measured, before this existed: `{"escalations":{}}` selected work, because
210
+ * `{} >= 2` is `NaN >= 2`; so did a `lastDeployVerdict` that was an object,
211
+ * because `=== 'REGRESSION'` is false for one. Both are the dangerous
212
+ * direction — a value that *looks* like a recorded stop and silently disables
213
+ * it.
214
+ *
215
+ * So this throws, naming the field, and the caller refuses to select. The
216
+ * permissive readings that remain are the ones where absence is a defined
217
+ * state: no file, no key, or an explicit `null`.
218
+ */
219
+ export const stopInputsOf = (state = {}) => {
220
+ const refuse = (field, value) => {
221
+ // 🔴 The rendering has a CONSTANT failure path, and that is not fussiness.
222
+ // `JSON.stringify` recurses, so a deeply nested value overflows the stack
223
+ // *inside the refusal* — the operator then gets `Maximum call stack size
224
+ // exceeded` from a function whose entire job is to name the field that is
225
+ // wrong. `String(value)` is no fallback: on an array it recurses too, via
226
+ // `join`. Measured: `JSON.stringify` gives out around 6 800 levels deep.
227
+ let rendered;
228
+ try {
229
+ rendered = JSON.stringify(value);
230
+ } catch {
231
+ rendered = '(a value too deeply nested to print)';
232
+ }
233
+ throw new Error(
234
+ `run state: ${field} is ${rendered ?? typeof value}, which is not a value this ` +
235
+ `can act on. An input a stop condition cannot read must not be read as ` +
236
+ `"no stop" — fix or delete the field rather than leaving it.`,
237
+ );
238
+ };
239
+
240
+ // 🔴 **A type gate ahead of the coercion — because `Number()` decides this
241
+ // question on the wrong axis.** The rule the three fields share is: coerce
242
+ // where any coercion fails safe, refuse where it fails dangerous. `Number()`
243
+ // gets that right for `"5"`, and wrong for everything that quietly becomes 0
244
+ // or 1: measured, `true` read as one escalation, and `false`, `''`, `' '` and
245
+ // `[]` all read as none — a present value silently disabling the stop it was
246
+ // written to enforce, which is the exact case this function exists for. What
247
+ // survives the gate is still coerced, and every one of those coercions rounds
248
+ // toward stopping earlier (`"1e3"` → 1000, `"0x10"` → 16).
249
+ //
250
+ // A hand-edit reaching for `"2"` means two; one reaching for `false` does not
251
+ // mean zero escalations, it means the file is not saying anything this can
252
+ // act on.
253
+ //
254
+ // **An array is refused rather than unwrapped**, though a one-element array
255
+ // used to read as its contents. Nothing writes one — `recordEscalation`
256
+ // writes an integer, `recordCompletedTier` writes `0`, and the CLI writes
257
+ // only the other three fields — and the branch that
258
+ // accepted it carried three defects of the very kind above: `[null]` read as
259
+ // zero, a multi-element array was refused only by an untested length check,
260
+ // and the unwrapping recursed as deep as its input. A shape with no writer is
261
+ // not a shape worth interpreting.
262
+ const countOf = (value) => {
263
+ if (typeof value === 'number') return Number.isInteger(value) && value >= 0 ? value : null;
264
+ if (typeof value !== 'string' || value.trim() === '') return null;
265
+ const parsed = Number(value);
266
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
267
+ };
268
+
269
+ const escalations = state.escalations ?? 0;
270
+ const count = countOf(escalations);
271
+ if (count === null) refuse('escalations', escalations);
272
+
273
+ const verdict = state.lastDeployVerdict ?? null;
274
+ if (verdict !== null && typeof verdict !== 'string') refuse('lastDeployVerdict', verdict);
275
+ // A verdict outside the vocabulary is the same defect wearing a plausible
276
+ // spelling: `REGRESSED` would sit in the file looking recorded and matching
277
+ // nothing. Case is normalised, exactly as the CLI normalises it on the way in.
278
+ const normalised = verdict === null ? null : verdict.toUpperCase();
279
+ if (normalised !== null && !DEPLOY_VERDICTS.includes(normalised)) {
280
+ refuse('lastDeployVerdict', verdict);
281
+ }
282
+
283
+ // `triggersFired` is deliberately absent from this, and its absence is a
284
+ // decision rather than an oversight: `core.mjs` compares `fired !== true`
285
+ // strictly, so a string, a number or a nonsense shape can only ever leave an
286
+ // item held back. Every misreading fails in the safe direction, which is the
287
+ // one case this function has nothing to add to.
288
+ return {
289
+ consecutiveEscalations: count,
290
+ lastDeployVerdict: normalised,
291
+ // Any truthy value means exhausted: the only writer stores `true`, and a
292
+ // hand-edit reaching for `"yes"` means yes. A flag has an honest `false`,
293
+ // unlike a count — so unlike `escalations` above, nothing here needs
294
+ // refusing: no present value silently disables the stop.
295
+ budgetExhausted: Boolean(state.budgetExhausted),
296
+ };
297
+ };
298
+
299
+ /**
300
+ * The post-deploy verdict, as `autonomy.md` defines it: binary, and the only
301
+ * two words `stopConditionOf` can act on.
302
+ *
303
+ * `HEALTHY` is not decoration. The field is `lastDeployVerdict` and "last" is
304
+ * the whole of it — without a word that clears a regression, one bad deploy
305
+ * ends every later selection in the run and the operator's only way out is to
306
+ * hand-edit a state file.
307
+ */
308
+ export const DEPLOY_VERDICTS = Object.freeze(['REGRESSION', 'HEALTHY']);
309
+
310
+ /**
311
+ * The budget vocabulary — one word, and **no word that lifts it**.
312
+ *
313
+ * The asymmetry with {@link DEPLOY_VERDICTS} is deliberate. `lastDeployVerdict`
314
+ * is about the *last* deploy, so a revert-and-redeploy inside the same run
315
+ * genuinely changes the answer and `HEALTHY` names that event. Spend only
316
+ * accumulates: nothing a run does later refunds it, so "un-exhaust" would name
317
+ * no event at all — it would name a decision to keep going, taken by the run
318
+ * that declared the stop. This is the one stop a run could lift on its own
319
+ * authority, which is exactly why it cannot.
320
+ *
321
+ * The escape hatch already exists and is cheaper than a word: a new run gets a
322
+ * new run directory, and therefore a clean state.
323
+ */
324
+ export const BUDGET_WORDS = Object.freeze(['EXHAUSTED']);
325
+
326
+ const invokedDirectly = () => {
327
+ if (!process.argv[1]) return false;
328
+ const real = (p) => {
329
+ try {
330
+ return realpathSync(p);
331
+ } catch {
332
+ return p;
333
+ }
334
+ };
335
+ return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
336
+ };
337
+
338
+ /**
339
+ * What each command writes, and what it refuses first.
340
+ *
341
+ * Two shapes share this table: `deploy` and `budget` take a word from a closed
342
+ * vocabulary, `trigger` takes an item id, which is whatever the tracker calls
343
+ * it — so it validates presence instead of membership (`words: null`). That
344
+ * sentinel leaks into three places below, which is the price of one table over
345
+ * two handlers; a fourth command is where that stops being worth it.
346
+ */
347
+ const COMMANDS = Object.freeze({
348
+ deploy: {
349
+ words: DEPLOY_VERDICTS,
350
+ field: 'lastDeployVerdict',
351
+ // The verdict is stored as the word itself: `stopConditionOf` compares it
352
+ // to `'REGRESSION'`, so the value on disk has to be the canonical spelling.
353
+ valueOf: (word) => word,
354
+ missingDir:
355
+ 'there is no run to record this verdict against. The run declares it in ' +
356
+ 'preflight; a verdict written nowhere would read as a healthy deploy to the ' +
357
+ 'next selection.',
358
+ },
359
+ budget: {
360
+ words: BUDGET_WORDS,
361
+ field: 'budgetExhausted',
362
+ valueOf: () => true,
363
+ missingDir:
364
+ 'there is no run whose budget this could exhaust. A budget belongs to one ' +
365
+ 'run — that is why a new run starts with a clean one.',
366
+ },
367
+ // The one command whose argument is not a vocabulary: an item id is whatever
368
+ // the tracker calls it. So it validates presence rather than membership —
369
+ // `trigger` with nothing after it must refuse rather than fire everything.
370
+ trigger: {
371
+ words: null,
372
+ field: 'triggersFired',
373
+ // Merged, not replaced: the shallow merge in `updateState` would drop the
374
+ // triggers already fired this run, and a second declaration must not
375
+ // silently retract the first.
376
+ valueOf: (id, state) => ({ ...(state.triggersFired ?? {}), [id]: true }),
377
+ missingDir:
378
+ 'there is no run for this trigger to be fired in. A declaration belongs to ' +
379
+ 'one run, which is what stops it carrying into tomorrow.',
380
+ missingWord:
381
+ 'trigger needs the id of the item whose trigger fired. Firing nothing in ' +
382
+ 'particular would either do nothing or arm every gated item at once, and ' +
383
+ 'both are worse than refusing.',
384
+ },
385
+ });
386
+
387
+ // The CLI the post-deploy step and the budget check call. It exists because both
388
+ // values are produced by a judgement taken outside any module — and a stop
389
+ // condition nothing can write is the state this whole file was added to end.
390
+ //
391
+ // 🔴 **Reading is permissive; writing refuses.** `readState` turns an absent or
392
+ // corrupt file into `{}` because an absent stop input is a defined state. A
393
+ // write has nowhere to go instead, and exiting 0 would tell the operator the
394
+ // regression was filed while the next selection hands out work on top of it —
395
+ // the one move `autonomy.md` names as never fix-forward.
396
+ if (invokedDirectly()) {
397
+ const [command, word] = process.argv.slice(2);
398
+ const runDir = process.env.RIG_RUN_DIR;
399
+ const spec = Object.hasOwn(COMMANDS, String(command)) ? COMMANDS[command] : null;
400
+
401
+ if (!spec) {
402
+ const names = Object.entries(COMMANDS).map(
403
+ ([name, { words }]) => `\`${name} <${words ? words.join('|').toLowerCase() : 'item-id'}>\``,
404
+ );
405
+ process.stderr.write(
406
+ `unknown command: ${command ?? '(none)'}. This CLI has ${names.length}: ` +
407
+ `${names.slice(0, -1).join(', ')} and ${names.at(-1)}. Everything else in this ` +
408
+ 'module is an import, not a command.\n',
409
+ );
410
+ process.exit(1);
411
+ }
412
+ if (!runDir) {
413
+ process.stderr.write(`RIG_RUN_DIR is not set, so ${spec.missingDir}\n`);
414
+ process.exit(1);
415
+ }
416
+
417
+ // Case is normalised rather than refused: a lowercase word is not a typo of
418
+ // meaning, while `REGRESSED` is — it matches nothing in `stopConditionOf` and
419
+ // would sit in the file looking recorded and stopping nothing. An item id is
420
+ // not a vocabulary, so it is taken as given and only checked for presence.
421
+ const given = String(word ?? '');
422
+ if (spec.words) {
423
+ const normalised = given.toUpperCase();
424
+ if (!spec.words.includes(normalised)) {
425
+ process.stderr.write(
426
+ `unknown ${command} word: ${word ?? '(none)'}. It must be one of ` +
427
+ `${spec.words.join(', ')} — the vocabulary the stop conditions compare ` +
428
+ 'against. A word outside it would be stored, read back, and match nothing.\n',
429
+ );
430
+ process.exit(1);
431
+ }
432
+ } else if (given === '') {
433
+ process.stderr.write(`${spec.missingWord}\n`);
434
+ process.exit(1);
435
+ }
436
+
437
+ const argument = spec.words ? given.toUpperCase() : given;
438
+ const value = spec.valueOf(argument, readState(runDir));
439
+ updateState(runDir, { [spec.field]: value });
440
+ process.stdout.write(`run state: ${spec.field} = ${JSON.stringify(value)}\n`);
441
+ }
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The verdict CLI — what a gate runs before it believes a reviewer.
4
+ *
5
+ * node .claude/scripts/verdict.mjs check <file> [gate] # `-` reads stdin
6
+ *
7
+ * It reads a gate's report, hands it to `lib/verdict.mjs`, and either prints the
8
+ * parsed verdict on stdout (exit 0) or refuses with a diagnosis on stderr
9
+ * (exit 1). All the deciding lives in the module; this file is the call site.
10
+ *
11
+ * 🔴 **Name the gate you launched.** The module reads the report's LAST block
12
+ * (its limit 3), so a capture holding two reviewers' answers end to end says
13
+ * only what the second one said — a stop that vanishes behind a later pass.
14
+ * With `[gate]`, a block claiming another gate is refused. The argument is
15
+ * optional because a caller checking one report of a known gate does not need
16
+ * it; a caller that fanned reviewers out does.
17
+ *
18
+ * 🔴 **The exit code says whether the REPORT was usable, never what the verdict
19
+ * was.** A well-formed `HOLD` exits 0 and prints `"verdict": "HOLD"` — a gate
20
+ * that found something is not a gate that broke, and a caller keying on the exit
21
+ * code would read the two as one. Read the word on stdout.
22
+ *
23
+ * 🔴 **stdout stays empty on a refusal.** A caller redirecting it into a file
24
+ * would otherwise capture a verdict this command has just refused, which is the
25
+ * failure the whole schema exists to prevent, reintroduced by the reader.
26
+ */
27
+
28
+ import { readFileSync } from 'node:fs';
29
+
30
+ import { parseVerdict, safeForDiagnosis } from './lib/verdict.mjs';
31
+
32
+ const USAGE =
33
+ 'usage: node .claude/scripts/verdict.mjs check <file> [gate] ' +
34
+ '(`-` reads the report from stdin)\n';
35
+
36
+ const refuse = (message) => {
37
+ process.stderr.write(message);
38
+ process.exit(1);
39
+ };
40
+
41
+ const readReport = (source) => {
42
+ try {
43
+ return readFileSync(source === '-' ? 0 : source, 'utf8');
44
+ } catch (error) {
45
+ // The path, not a stack: the operator needs to know WHICH report could not
46
+ // be read, and a node trace answers a question nobody asked.
47
+ refuse(
48
+ `verdict: could not read the report at ${source} (${error?.code ?? 'unknown error'}). ` +
49
+ 'Nothing was checked.\n',
50
+ );
51
+ return '';
52
+ }
53
+ };
54
+
55
+ const [subcommand, source, expectedGate] = process.argv.slice(2);
56
+
57
+ // Two arms, one branch apart, and telling them apart is the whole value of
58
+ // either: an operator told the subcommand is unknown goes looking for a typo
59
+ // that is not there.
60
+ if (subcommand === undefined) refuse(USAGE);
61
+ if (subcommand !== 'check') {
62
+ refuse(`verdict: \`${subcommand}\` is not a subcommand of this tool.\n${USAGE}`);
63
+ }
64
+ if (source === undefined) {
65
+ refuse(`verdict: \`check\` needs the report to read; no file was given.\n${USAGE}`);
66
+ }
67
+
68
+ const result = parseVerdict(readReport(source));
69
+
70
+ if (!result.ok) {
71
+ refuse(
72
+ `verdict: the report at ${source} does not end in a verdict this gate can act on. ` +
73
+ 'Treat it as `incomplete` — the gate did not answer, which is not the same as ' +
74
+ 'answering that nothing is wrong.\n' +
75
+ result.problems.map((problem) => ` - ${problem}\n`).join(''),
76
+ );
77
+ }
78
+
79
+ if (expectedGate !== undefined && result.verdict.gate !== expectedGate) {
80
+ refuse(
81
+ `verdict: the report at ${source} was checked as ` +
82
+ `\`${safeForDiagnosis(expectedGate)}\`, and the block it ends with answers for ` +
83
+ `\`${safeForDiagnosis(result.verdict.gate)}\`. One capture holding two gates' ` +
84
+ "answers says only what the second one said, so the first one's verdict — a stop, " +
85
+ 'as often as not — would go unread. Check each report on its own.\n',
86
+ );
87
+ }
88
+
89
+ try {
90
+ process.stdout.write(`${JSON.stringify(result.verdict, null, 2)}\n`);
91
+ } catch {
92
+ // A verdict can parse and still be unprintable: nothing refuses an unknown
93
+ // key inside a blocker, and one nested deeply enough defeats the printer.
94
+ // Uncaught it is a raw stack, which this file promises never to emit — and a
95
+ // legitimate stop then reads as `incomplete` with no way to see why.
96
+ refuse(
97
+ `verdict: the verdict in ${source} parsed, and could not be printed — a blocker ` +
98
+ 'nested far past anything a reader needs. Nothing was printed; flatten the ' +
99
+ 'blocker and answer again.\n',
100
+ );
101
+ }
@@ -2,7 +2,7 @@
2
2
  "hooks": {
3
3
  "PreToolUse": [
4
4
  {
5
- "matcher": "Write|Edit",
5
+ "matcher": "Write|Edit|apply_patch",
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
@@ -11,6 +11,10 @@
11
11
  {
12
12
  "type": "command",
13
13
  "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-web-boundary.mjs\""
14
+ },
15
+ {
16
+ "type": "command",
17
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-secret-file.mjs\""
14
18
  }
15
19
  ]
16
20
  },
@@ -33,7 +37,8 @@
33
37
  "hooks": [
34
38
  {
35
39
  "type": "command",
36
- "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/gate-stop-dod.mjs\""
40
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/gate-stop-dod.mjs\"",
41
+ "timeout": 900
37
42
  }
38
43
  ]
39
44
  }