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,539 @@
1
+ /**
2
+ * The run's own state — three of the four values `stopConditionOf` asks for and
3
+ * nothing used to answer, plus one value it does not ask for: the take-up
4
+ * snapshot `takeUps` ({@link recordTakeUp}), which is the run's fact as much as
5
+ * the other three. The fourth stop input, `killSwitch`, is deliberately not here:
6
+ * it is already mechanical in `guard-bash` and scripted in preflight, and a
7
+ * second answer to "is the brake on" is the disagreement `invariants.md`
8
+ * forbids.
9
+ *
10
+ * 🔴 **Why this file exists.** `core.mjs` takes `consecutiveEscalations`,
11
+ * `lastDeployVerdict`, `budgetExhausted` and `killSwitch`, and every branch that
12
+ * reads them was live — but the CLI called it with `candidates` and `skipped`
13
+ * only, so all four held their defaults on every real selection. The values
14
+ * were "remembered" by the session instead, which is precisely the guarantee a
15
+ * mechanical check exists to replace, and they are lost at compaction — the
16
+ * moment a long run needs them most. A stop rule nobody can supply the input
17
+ * for is a stop rule that never fires.
18
+ *
19
+ * 🔴 **Per run, not per checkout, and the difference is the whole design.**
20
+ * `escalations` means *two in a row in this run*; a declared budget belongs to
21
+ * this run; a `REGRESSION` verdict is about the deploy this run made. Written
22
+ * into a checkout-wide file, every one of them would outlive the run that
23
+ * learned it: two runs sharing a checkout would stop each other on a counter
24
+ * neither of them raised, and a regression would carry into tomorrow's run,
25
+ * which knows nothing about the deploy that caused it and is the run best
26
+ * placed to fix it. (`HEALTHY` below can clear a verdict — so the argument is
27
+ * *whose fact is it*, not *can it be undone*. A stop that has to be
28
+ * hand-cleared by a run that did not set it is a stop that will be
29
+ * hand-cleared without being read.)
30
+ *
31
+ * The one value that is genuinely per-checkout stays where it is:
32
+ * `lastCompletedTier` in `queue/state.mjs` rations elevated work *across* runs,
33
+ * so moving it here would hand each new run a clean slate and restore the exact
34
+ * defect that file was written to close. The close writes both, deliberately.
35
+ *
36
+ * **What this module does NOT own:** the run-id convention and creating the
37
+ * directory. Those belong to whatever drives the run — here the `loop` skill,
38
+ * which declares `RIG_RUN_DIR` in its preflight. Handed a `runDir`, this module
39
+ * uses it verbatim, exactly as `run-journal.mjs` does. Two owners of one
40
+ * convention disagree the first time either changes.
41
+ *
42
+ * ⚠ **It assumes one writer**, like the journal beside it — but degrades
43
+ * differently, and the difference is worth knowing before relying on either.
44
+ * The journal *detects* a collision through its sequence and refuses; this
45
+ * merge is read-then-write with no lock, so two processes sharing a run
46
+ * directory silently lose one of their patches. Measured: four processes ×
47
+ * 200 increments recorded 215. The loss is always downward, so the stop this
48
+ * file exists to fire fires **late or never** — never early. One run directory
49
+ * per run is the caller's part of the contract, and the `loop` skill states it.
50
+ */
51
+
52
+ import { opendirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
53
+ import { basename, dirname, join } from 'node:path';
54
+ import { fileURLToPath } from 'node:url';
55
+
56
+ const STATE = 'state.json';
57
+
58
+ /** The state file's path inside a run directory — one definition, not two. */
59
+ export const statePathIn = (runDir) => join(runDir, STATE);
60
+
61
+ /**
62
+ * What the run has recorded so far; `{}` when it has recorded nothing.
63
+ *
64
+ * 🔴 **Unreadable is empty, and that is a decision rather than an oversight.**
65
+ * A corrupt or half-written state file must not stop the run from selecting
66
+ * work: the failure mode of reading it as "no state" is today's behaviour —
67
+ * which is exactly what the caller had before this module — while the failure
68
+ * mode of throwing is a run that cannot take an item because of a file that
69
+ * only ever *adds* stop conditions. Fail towards the behaviour that was already
70
+ * trusted.
71
+ *
72
+ * Note the asymmetry with `run-journal.mjs`, which refuses a broken sequence
73
+ * loudly: the journal's whole job is to be trustworthy evidence, so a journal
74
+ * that cannot vouch for itself must say so. This file's job is to supply stop
75
+ * inputs, and an absent input is a defined state there.
76
+ */
77
+ export const readState = (runDir) => {
78
+ if (!runDir) return {};
79
+ try {
80
+ const parsed = JSON.parse(readFileSync(statePathIn(runDir), 'utf8'));
81
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : {};
82
+ } catch {
83
+ return {};
84
+ }
85
+ };
86
+
87
+ /**
88
+ * Merge `patch` over what is already recorded, and return the result.
89
+ *
90
+ * Shallow by design: the fields are flat values plus one object
91
+ * (`triggersFired`), and a deep merge would make "clear this field"
92
+ * unexpressible — a caller passing an object means *this value now*, not
93
+ * *these keys added to whatever was there*. The `trigger` command therefore
94
+ * merges its own map before calling, where the intent is explicit.
95
+ *
96
+ * 🔴 **Write-then-rename, because the reader is another process.** A plain
97
+ * write leaves a window where the file on disk is half a JSON document, and the
98
+ * reader above turns that into `{}` — a silently forgotten escalation streak.
99
+ * A rename within one directory is atomic on **POSIX**, so a reader sees either
100
+ * the old state or the new one and never a torn one. On Windows the same call
101
+ * goes through `MoveFileEx`, which does not guarantee atomicity when it
102
+ * replaces an existing file and fails outright if the destination is open — so
103
+ * there the failure is loud rather than torn, which is the direction to prefer
104
+ * if it ever has to be handled.
105
+ */
106
+ export const updateState = (runDir, patch) => {
107
+ const next = { ...readState(runDir), ...patch };
108
+ const file = statePathIn(runDir);
109
+ // The temp name carries the pid so a second writer cannot clobber the first
110
+ // one's half-written file — the merge above is still unsafe under two
111
+ // writers, but a torn read is not how it fails.
112
+ const tmp = `${file}.${process.pid}.tmp`;
113
+ // `wx` refuses an existing path rather than writing through it. Without it a
114
+ // symlink planted at the temp name is followed, and the rename then makes
115
+ // `state.json` itself that symlink — so the run's stop conditions would be
116
+ // read from, and written to, a file somebody else chose. The pid narrows who
117
+ // can guess the name; it does not stop anyone who watches the directory.
118
+ //
119
+ // `0o600` narrows this file specifically. It is not a claim about the run
120
+ // directory: `run-journal.mjs` appends beside it at the default mode, and its
121
+ // records carry the same run's item ids.
122
+ writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
123
+ try {
124
+ renameSync(tmp, file);
125
+ } catch (error) {
126
+ // A failed rename used to leave the temp file behind, where `wx` then
127
+ // refuses any later write from a process that redraws the same pid — the
128
+ // name carries it, so this is a narrow collision rather than a bricked run,
129
+ // and it is loud either way. The one case this cannot clean is a process
130
+ // killed between the write and the rename: no handler runs, and the temp
131
+ // leaks until the run directory is discarded. The unlink's own failure is
132
+ // swallowed on purpose: the caller needs the rename's error, not this one.
133
+ try {
134
+ unlinkSync(tmp);
135
+ } catch {
136
+ /* the temp file is already gone, or unreachable for the same reason the rename was */
137
+ }
138
+ throw error;
139
+ }
140
+ return next;
141
+ };
142
+
143
+ /**
144
+ * Count one task that hit a wall, and hand back the new total.
145
+ *
146
+ * 🔴 **One recorder, called by three adapters — not three counters.** Every
147
+ * adapter's `escalate()` needs this, and three copies of read-then-increment is
148
+ * how the number means something different depending on which tracker the rig
149
+ * happens to use (`invariants.md`, "one mechanism, one implementation"). The
150
+ * adapters own *how a tracker records an escalation*; the count of them in this
151
+ * run is not tracker business at all.
152
+ *
153
+ * 🔴 **Where to call it from, stated because the next adapter's author cannot
154
+ * infer it:** the count rises **after** the tracker has been mutated, and
155
+ * unconditionally only where the adapter mutates no tracker at all. So
156
+ * `github-issues` and `jira` call this last, once the comment and the label
157
+ * have landed; `plan-md` calls it immediately, because a flat list has nothing
158
+ * to write. Called first, the count would claim an escalation the tracker never
159
+ * received — and it is the count, not the tracker, that ends the run.
160
+ *
161
+ * A run that declared no directory records nothing and **does not throw**: an
162
+ * attended session escalating an item by hand is an ordinary thing to do, and a
163
+ * recorder that refused there would push callers into not calling it.
164
+ *
165
+ * A stored value that is not a number reads as none. It is the same permissive
166
+ * read as {@link readState} and for the same reason — a hand-edited state file
167
+ * must not be able to make the count `NaN`, which compares false against every
168
+ * threshold and silently disables the stop it feeds.
169
+ */
170
+ export const recordEscalation = (runDir) => {
171
+ if (!runDir) return 0;
172
+ const onDisk = readState(runDir).escalations;
173
+ const current = Number.isInteger(onDisk) && onDisk >= 0 ? onDisk : 0;
174
+ try {
175
+ updateState(runDir, { escalations: current + 1 });
176
+ return current + 1;
177
+ } catch (error) {
178
+ // 🔴 **This one call tolerates a failed write, and the reason is the caller,
179
+ // not the value.** Two of the three adapters run this AFTER the tracker is
180
+ // already mutated — the comment posted, the `escalated` label applied — so a
181
+ // stale `RIG_RUN_DIR` (an export surviving the last run, a cleaned run
182
+ // directory, a worktree the loop's `mkdir -p` never reached) would turn a
183
+ // fully successful escalation into a thrown error. The caller then retries,
184
+ // and the retry double-posts the diagnosis onto the issue.
185
+ //
186
+ // Loud, not silent: swallowing this quietly would trade a crash for a lie,
187
+ // and the count feeding the two-in-a-row stop would drift below the truth
188
+ // with nothing to show for it. Stderr, never stdout — a stray line on stdout
189
+ // lands inside `queue next --json`'s document.
190
+ //
191
+ // `updateState` itself keeps throwing. The CLI writers below refuse loudly
192
+ // on purpose, and only this call site has a mutated tracker behind it.
193
+ process.stderr.write(
194
+ `run state: the escalation was NOT counted in ${runDir} — ${error.message}\n` +
195
+ ' the tracker was updated; only the local count was lost. Fix the run ' +
196
+ 'directory before the next task, or the two-in-a-row stop reads low.\n',
197
+ );
198
+ // The count actually on disk, not the one intended: a caller journalling
199
+ // this number must not record a streak the state file does not carry.
200
+ return current;
201
+ }
202
+ };
203
+
204
+ /**
205
+ * The take-up snapshot: the selected item's `updatedAt` marker, keyed by id, as
206
+ * seen at SELECT. `queue/core.mjs` › revalidationOf compares the next selection
207
+ * against it, so a stale take-up is reported rather than silently continued.
208
+ *
209
+ * Per run, like everything else here — a snapshot from yesterday's run is not
210
+ * a take-up this run made. Merged by id, so a second item does not erase the
211
+ * first; re-recording an id moves its baseline forward. A marker that is not a
212
+ * string is not recorded at all: `plan-md` has none, and writing `null` would
213
+ * later compare equal to `null` and read as "unchanged".
214
+ *
215
+ * No run directory → nothing written, `null` back, no throw: an attended
216
+ * selection has no run to snapshot into. (Like {@link recordEscalation} it
217
+ * refuses to throw there; unlike it, there is no count to hand back, so `null`.)
218
+ */
219
+ export const recordTakeUp = (runDir, { id, updatedAt } = {}) => {
220
+ if (!runDir || typeof updatedAt !== 'string' || id === undefined || id === null) return null;
221
+ const state = readState(runDir);
222
+ const takeUps = typeof state.takeUps === 'object' && state.takeUps !== null ? state.takeUps : {};
223
+ return updateState(runDir, { takeUps: { ...takeUps, [String(id)]: updatedAt } });
224
+ };
225
+
226
+ /**
227
+ * The take-up an EARLIER run recorded for this item, or null (AR-138).
228
+ *
229
+ * {@link recordTakeUp} is per run on purpose, and that left a hole RX1
230
+ * measured: an item taken up yesterday and re-offered today compared against
231
+ * nothing and reported a first sight, however far its marker had moved. So
232
+ * when this run has no take-up for the item, SELECT asks the sibling run
233
+ * directories — newest first by name, which is the `YYYYMMDD-HHMMSS` the
234
+ * `loop` skill declares — and takes the first that recorded one. The answer
235
+ * names the run it came from, so the revalidation event can say whose
236
+ * baseline it compared against.
237
+ *
238
+ * 🔴 A sibling is a run only by NAME — `YYYYMMDD-HHMMSS`, the shape the `loop`
239
+ * skill declares — and so is the run asking. The first version took every
240
+ * sibling directory, and two measurements showed why that is wrong. On CI,
241
+ * with `RIG_RUN_DIR` under a shared temp root, a neighbouring test's scratch
242
+ * directory carrying a `state.json` was read as yesterday's run, and a first
243
+ * sight came back as a hold against a marker nobody had taken up. Locally,
244
+ * the same temp root held 454 000 entries, and every SELECT paid a 540 ms
245
+ * directory read for a question it could not answer. So a run declared under
246
+ * another naming looks at no siblings at all, and the limit is the mirror
247
+ * image: an earlier run declared under another naming is not seen here.
248
+ *
249
+ * Bounded and fail-soft: the runs root is walked through one directory handle
250
+ * and at most 10 000 entries are looked at, whatever is in there; at most 200
251
+ * candidate runs are read, an unreadable state is skipped rather than trusted,
252
+ * and the answer is `null` for no run directory, an unnamed one, or no runs
253
+ * root. Never this run's own state — that is {@link readState}'s answer, and
254
+ * the caller asks it first.
255
+ */
256
+ const RUN_DIR_NAME = /^\d{8}-\d{6}$/;
257
+ const RUNS_ROOT_ENTRY_BUDGET = 10_000;
258
+ const RUNS_READ_CAP = 200;
259
+
260
+ export const previousTakeUp = (runDir, id) => {
261
+ if (!runDir || id === undefined || id === null) return null;
262
+ const root = dirname(runDir);
263
+ const self = basename(runDir);
264
+ if (!RUN_DIR_NAME.test(self)) return null;
265
+ const names = [];
266
+ let dir;
267
+ try {
268
+ dir = opendirSync(root);
269
+ } catch {
270
+ return null;
271
+ }
272
+ try {
273
+ for (let seen = 0; seen < RUNS_ROOT_ENTRY_BUDGET; seen += 1) {
274
+ const entry = dir.readSync();
275
+ if (entry === null) break;
276
+ if (entry.isDirectory() && entry.name !== self && RUN_DIR_NAME.test(entry.name)) {
277
+ names.push(entry.name);
278
+ }
279
+ }
280
+ } catch {
281
+ return null;
282
+ } finally {
283
+ dir.closeSync();
284
+ }
285
+ names.sort().reverse();
286
+ for (const name of names.slice(0, RUNS_READ_CAP)) {
287
+ const candidate = join(root, name);
288
+ let state;
289
+ try {
290
+ state = JSON.parse(readFileSync(statePathIn(candidate), 'utf8'));
291
+ } catch {
292
+ continue;
293
+ }
294
+ const updatedAt = state?.takeUps?.[String(id)];
295
+ if (typeof updatedAt === 'string') return { updatedAt, runDir: candidate };
296
+ }
297
+ return null;
298
+ };
299
+
300
+ /**
301
+ * The stop inputs, read out of a state object that anything may have written.
302
+ *
303
+ * 🔴 **An uninterpretable value is never "no stop".** The writers here are
304
+ * careful — `recordEscalation` refuses a non-integer, the CLI checks its
305
+ * vocabulary before writing — but the file is documented as hand-editable, and
306
+ * the reader used to pass whatever JSON held straight into JS comparisons.
307
+ * Measured, before this existed: `{"escalations":{}}` selected work, because
308
+ * `{} >= 2` is `NaN >= 2`; so did a `lastDeployVerdict` that was an object,
309
+ * because `=== 'REGRESSION'` is false for one. Both are the dangerous
310
+ * direction — a value that *looks* like a recorded stop and silently disables
311
+ * it.
312
+ *
313
+ * So this throws, naming the field, and the caller refuses to select. The
314
+ * permissive readings that remain are the ones where absence is a defined
315
+ * state: no file, no key, or an explicit `null`.
316
+ */
317
+ export const stopInputsOf = (state = {}) => {
318
+ const refuse = (field, value) => {
319
+ // 🔴 The rendering has a CONSTANT failure path, and that is not fussiness.
320
+ // `JSON.stringify` recurses, so a deeply nested value overflows the stack
321
+ // *inside the refusal* — the operator then gets `Maximum call stack size
322
+ // exceeded` from a function whose entire job is to name the field that is
323
+ // wrong. `String(value)` is no fallback: on an array it recurses too, via
324
+ // `join`. Measured: `JSON.stringify` gives out around 6 800 levels deep.
325
+ let rendered;
326
+ try {
327
+ rendered = JSON.stringify(value);
328
+ } catch {
329
+ rendered = '(a value too deeply nested to print)';
330
+ }
331
+ throw new Error(
332
+ `run state: ${field} is ${rendered ?? typeof value}, which is not a value this ` +
333
+ `can act on. An input a stop condition cannot read must not be read as ` +
334
+ `"no stop" — fix or delete the field rather than leaving it.`,
335
+ );
336
+ };
337
+
338
+ // 🔴 **A type gate ahead of the coercion — because `Number()` decides this
339
+ // question on the wrong axis.** The rule the three fields share is: coerce
340
+ // where any coercion fails safe, refuse where it fails dangerous. `Number()`
341
+ // gets that right for `"5"`, and wrong for everything that quietly becomes 0
342
+ // or 1: measured, `true` read as one escalation, and `false`, `''`, `' '` and
343
+ // `[]` all read as none — a present value silently disabling the stop it was
344
+ // written to enforce, which is the exact case this function exists for. What
345
+ // survives the gate is still coerced, and every one of those coercions rounds
346
+ // toward stopping earlier (`"1e3"` → 1000, `"0x10"` → 16).
347
+ //
348
+ // A hand-edit reaching for `"2"` means two; one reaching for `false` does not
349
+ // mean zero escalations, it means the file is not saying anything this can
350
+ // act on.
351
+ //
352
+ // **An array is refused rather than unwrapped**, though a one-element array
353
+ // used to read as its contents. Nothing writes one — `recordEscalation`
354
+ // writes an integer, `recordCompletedTier` writes `0`, and the CLI writes
355
+ // only the other three fields — and the branch that
356
+ // accepted it carried three defects of the very kind above: `[null]` read as
357
+ // zero, a multi-element array was refused only by an untested length check,
358
+ // and the unwrapping recursed as deep as its input. A shape with no writer is
359
+ // not a shape worth interpreting.
360
+ const countOf = (value) => {
361
+ if (typeof value === 'number') return Number.isInteger(value) && value >= 0 ? value : null;
362
+ if (typeof value !== 'string' || value.trim() === '') return null;
363
+ const parsed = Number(value);
364
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
365
+ };
366
+
367
+ const escalations = state.escalations ?? 0;
368
+ const count = countOf(escalations);
369
+ if (count === null) refuse('escalations', escalations);
370
+
371
+ const verdict = state.lastDeployVerdict ?? null;
372
+ if (verdict !== null && typeof verdict !== 'string') refuse('lastDeployVerdict', verdict);
373
+ // A verdict outside the vocabulary is the same defect wearing a plausible
374
+ // spelling: `REGRESSED` would sit in the file looking recorded and matching
375
+ // nothing. Case is normalised, exactly as the CLI normalises it on the way in.
376
+ const normalised = verdict === null ? null : verdict.toUpperCase();
377
+ if (normalised !== null && !DEPLOY_VERDICTS.includes(normalised)) {
378
+ refuse('lastDeployVerdict', verdict);
379
+ }
380
+
381
+ // `triggersFired` is deliberately absent from this, and its absence is a
382
+ // decision rather than an oversight: `core.mjs` compares `fired !== true`
383
+ // strictly, so a string, a number or a nonsense shape can only ever leave an
384
+ // item held back. Every misreading fails in the safe direction, which is the
385
+ // one case this function has nothing to add to.
386
+ return {
387
+ consecutiveEscalations: count,
388
+ lastDeployVerdict: normalised,
389
+ // Any truthy value means exhausted: the only writer stores `true`, and a
390
+ // hand-edit reaching for `"yes"` means yes. A flag has an honest `false`,
391
+ // unlike a count — so unlike `escalations` above, nothing here needs
392
+ // refusing: no present value silently disables the stop.
393
+ budgetExhausted: Boolean(state.budgetExhausted),
394
+ };
395
+ };
396
+
397
+ /**
398
+ * The post-deploy verdict, as `autonomy.md` defines it: binary, and the only
399
+ * two words `stopConditionOf` can act on.
400
+ *
401
+ * `HEALTHY` is not decoration. The field is `lastDeployVerdict` and "last" is
402
+ * the whole of it — without a word that clears a regression, one bad deploy
403
+ * ends every later selection in the run and the operator's only way out is to
404
+ * hand-edit a state file.
405
+ */
406
+ export const DEPLOY_VERDICTS = Object.freeze(['REGRESSION', 'HEALTHY']);
407
+
408
+ /**
409
+ * The budget vocabulary — one word, and **no word that lifts it**.
410
+ *
411
+ * The asymmetry with {@link DEPLOY_VERDICTS} is deliberate. `lastDeployVerdict`
412
+ * is about the *last* deploy, so a revert-and-redeploy inside the same run
413
+ * genuinely changes the answer and `HEALTHY` names that event. Spend only
414
+ * accumulates: nothing a run does later refunds it, so "un-exhaust" would name
415
+ * no event at all — it would name a decision to keep going, taken by the run
416
+ * that declared the stop. This is the one stop a run could lift on its own
417
+ * authority, which is exactly why it cannot.
418
+ *
419
+ * The escape hatch already exists and is cheaper than a word: a new run gets a
420
+ * new run directory, and therefore a clean state.
421
+ */
422
+ export const BUDGET_WORDS = Object.freeze(['EXHAUSTED']);
423
+
424
+ const invokedDirectly = () => {
425
+ if (!process.argv[1]) return false;
426
+ const real = (p) => {
427
+ try {
428
+ return realpathSync(p);
429
+ } catch {
430
+ return p;
431
+ }
432
+ };
433
+ return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
434
+ };
435
+
436
+ /**
437
+ * What each command writes, and what it refuses first.
438
+ *
439
+ * Two shapes share this table: `deploy` and `budget` take a word from a closed
440
+ * vocabulary, `trigger` takes an item id, which is whatever the tracker calls
441
+ * it — so it validates presence instead of membership (`words: null`). That
442
+ * sentinel leaks into three places below, which is the price of one table over
443
+ * two handlers; a fourth command is where that stops being worth it.
444
+ */
445
+ const COMMANDS = Object.freeze({
446
+ deploy: {
447
+ words: DEPLOY_VERDICTS,
448
+ field: 'lastDeployVerdict',
449
+ // The verdict is stored as the word itself: `stopConditionOf` compares it
450
+ // to `'REGRESSION'`, so the value on disk has to be the canonical spelling.
451
+ valueOf: (word) => word,
452
+ missingDir:
453
+ 'there is no run to record this verdict against. The run declares it in ' +
454
+ 'preflight; a verdict written nowhere would read as a healthy deploy to the ' +
455
+ 'next selection.',
456
+ },
457
+ budget: {
458
+ words: BUDGET_WORDS,
459
+ field: 'budgetExhausted',
460
+ valueOf: () => true,
461
+ missingDir:
462
+ 'there is no run whose budget this could exhaust. A budget belongs to one ' +
463
+ 'run — that is why a new run starts with a clean one.',
464
+ },
465
+ // The one command whose argument is not a vocabulary: an item id is whatever
466
+ // the tracker calls it. So it validates presence rather than membership —
467
+ // `trigger` with nothing after it must refuse rather than fire everything.
468
+ trigger: {
469
+ words: null,
470
+ field: 'triggersFired',
471
+ // Merged, not replaced: the shallow merge in `updateState` would drop the
472
+ // triggers already fired this run, and a second declaration must not
473
+ // silently retract the first.
474
+ valueOf: (id, state) => ({ ...(state.triggersFired ?? {}), [id]: true }),
475
+ missingDir:
476
+ 'there is no run for this trigger to be fired in. A declaration belongs to ' +
477
+ 'one run, which is what stops it carrying into tomorrow.',
478
+ missingWord:
479
+ 'trigger needs the id of the item whose trigger fired. Firing nothing in ' +
480
+ 'particular would either do nothing or arm every gated item at once, and ' +
481
+ 'both are worse than refusing.',
482
+ },
483
+ });
484
+
485
+ // The CLI the post-deploy step and the budget check call. It exists because both
486
+ // values are produced by a judgement taken outside any module — and a stop
487
+ // condition nothing can write is the state this whole file was added to end.
488
+ //
489
+ // 🔴 **Reading is permissive; writing refuses.** `readState` turns an absent or
490
+ // corrupt file into `{}` because an absent stop input is a defined state. A
491
+ // write has nowhere to go instead, and exiting 0 would tell the operator the
492
+ // regression was filed while the next selection hands out work on top of it —
493
+ // the one move `autonomy.md` names as never fix-forward.
494
+ if (invokedDirectly()) {
495
+ const [command, word] = process.argv.slice(2);
496
+ const runDir = process.env.RIG_RUN_DIR;
497
+ const spec = Object.hasOwn(COMMANDS, String(command)) ? COMMANDS[command] : null;
498
+
499
+ if (!spec) {
500
+ const names = Object.entries(COMMANDS).map(
501
+ ([name, { words }]) => `\`${name} <${words ? words.join('|').toLowerCase() : 'item-id'}>\``,
502
+ );
503
+ process.stderr.write(
504
+ `unknown command: ${command ?? '(none)'}. This CLI has ${names.length}: ` +
505
+ `${names.slice(0, -1).join(', ')} and ${names.at(-1)}. Everything else in this ` +
506
+ 'module is an import, not a command.\n',
507
+ );
508
+ process.exit(1);
509
+ }
510
+ if (!runDir) {
511
+ process.stderr.write(`RIG_RUN_DIR is not set, so ${spec.missingDir}\n`);
512
+ process.exit(1);
513
+ }
514
+
515
+ // Case is normalised rather than refused: a lowercase word is not a typo of
516
+ // meaning, while `REGRESSED` is — it matches nothing in `stopConditionOf` and
517
+ // would sit in the file looking recorded and stopping nothing. An item id is
518
+ // not a vocabulary, so it is taken as given and only checked for presence.
519
+ const given = String(word ?? '');
520
+ if (spec.words) {
521
+ const normalised = given.toUpperCase();
522
+ if (!spec.words.includes(normalised)) {
523
+ process.stderr.write(
524
+ `unknown ${command} word: ${word ?? '(none)'}. It must be one of ` +
525
+ `${spec.words.join(', ')} — the vocabulary the stop conditions compare ` +
526
+ 'against. A word outside it would be stored, read back, and match nothing.\n',
527
+ );
528
+ process.exit(1);
529
+ }
530
+ } else if (given === '') {
531
+ process.stderr.write(`${spec.missingWord}\n`);
532
+ process.exit(1);
533
+ }
534
+
535
+ const argument = spec.words ? given.toUpperCase() : given;
536
+ const value = spec.valueOf(argument, readState(runDir));
537
+ updateState(runDir, { [spec.field]: value });
538
+ process.stdout.write(`run state: ${spec.field} = ${JSON.stringify(value)}\n`);
539
+ }
@@ -21,19 +21,26 @@ import { existsSync } from 'node:fs';
21
21
  import { homedir, userInfo } from 'node:os';
22
22
  import { delimiter, join } from 'node:path';
23
23
 
24
- /** Every path that arms the brake. The machine-level default is always first. */
25
- export const stopFlags = (env = process.env) => {
26
- // BOTH homes: `homedir()` honours $HOME, which `.claude/settings.json` can set
27
- // pointing it at an empty directory disarmed the brake. `userInfo()` reads the
28
- // password database and ignores the environment, so the operator's real flag is
29
- // always among the paths checked.
30
- const homes = new Set([homedir()]);
24
+ /**
25
+ * BOTH homes, env-derived first: `$HOME` (what `.claude/settings.json` can set —
26
+ * pointing it at an empty directory once disarmed the brake) and the password
27
+ * database's, which ignores the environment, so the operator's real flag is
28
+ * always among the paths checked. Shared with the unattended flag (AR-51): one
29
+ * lookup, one place to be wrong.
30
+ */
31
+ export const homesOf = (env = process.env) => {
32
+ const homes = new Set([env.HOME || homedir()]);
31
33
  try {
32
34
  homes.add(userInfo().homedir);
33
35
  } catch {
34
36
  // no password entry — the env-derived home is all there is
35
37
  }
36
- const paths = [...homes].map((home) => join(home, '.claude', '__PROJECT_NAME__-loop-STOP'));
38
+ return [...homes];
39
+ };
40
+
41
+ /** Every path that arms the brake. The machine-level default is always first. */
42
+ export const stopFlags = (env = process.env) => {
43
+ const paths = homesOf(env).map((home) => join(home, '.claude', '__PROJECT_NAME__-loop-STOP'));
37
44
  const extra = env.AGENT_LOOP_STOP;
38
45
  if (extra) {
39
46
  // Filtered and CAPPED before the spread, never after. Spreading an