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,435 @@
1
+ /**
2
+ * The run journal — the machine-readable trace BEHIND the human journal in
3
+ * `journal/YYYY-MM.md`.
4
+ *
5
+ * Gate verdicts go to `decisions.jsonl`, everything else to `events.jsonl`, both
6
+ * append-only, both inside one per-run directory the caller hands over. It
7
+ * answers *what did the run decide, and on what basis*; it never answers *was
8
+ * that the right call*, and it replaces neither the month file nor `PLAN.md`.
9
+ *
10
+ * The two are opposites and stay that way: this trace is append-only and
11
+ * OLDEST-first, the month file is newest-on-top. Reading one as the other is
12
+ * how a reader concludes a run did nothing.
13
+ *
14
+ * 🔴 **A journal with no call site records nothing.** The writer is the easy
15
+ * half and the worthless one on its own — the shape this module is ported from
16
+ * shipped the writer plus the rule and no gate ever called it, so a run that
17
+ * looked instrumented produced empty directories. The caller ships with it:
18
+ * `queue/index.mjs` records its `item-selection` verdict.
19
+ *
20
+ * 🔴 **The ordering is asserted here, not documented somewhere.** A journal
21
+ * whose order the reader cannot trust is worse than none: a stale record reads
22
+ * as the current one, which is the exact failure a journal exists to prevent.
23
+ * So every record carries `seq`, the run-wide counter, and `readRun` REFUSES a
24
+ * run whose sequence has a gap or runs backwards rather than handing back
25
+ * records it cannot vouch for.
26
+ *
27
+ * ⚠ **`seq` is the authority on order, and the timestamp is data.** The clock is
28
+ * injected, so it is whatever the caller passed — a re-synced host clock, a
29
+ * caller stamping one value across a batch, or two callers on different
30
+ * machines all produce timestamps that do not order. A counter incremented by a
31
+ * single writer does. Which is the whole of the claim: with two writers the
32
+ * counter does not order either, it only makes the collision *visible* — see the
33
+ * one-writer assumption below.
34
+ *
35
+ * **What this module deliberately does NOT own:** the run-id convention, creating
36
+ * the directory, and rotation. Those belong to whatever drives the run — in this
37
+ * rig, the `loop` skill, which declares `RIG_RUN_DIR` in its preflight. This
38
+ * module is handed a `runDir` and uses it verbatim. Two owners of one convention
39
+ * disagree the first time either changes, and a rotation scheme for files nobody
40
+ * has accumulated is invention.
41
+ *
42
+ * ⚠ **It assumes one writer.** `seq` is read from the files and written back with
43
+ * no lock, so two processes sharing one run directory can compute the same
44
+ * number. That is *detected* on the next read or write and never silently
45
+ * accepted — but detection is where it ends: the journal then refuses records
46
+ * rather than repairing itself, and the run's trace stops there. One run
47
+ * directory per run is the caller's part of the contract.
48
+ */
49
+
50
+ import { appendFileSync, readFileSync, statSync } from 'node:fs';
51
+ import { join } from 'node:path';
52
+
53
+ const DECISIONS = 'decisions.jsonl';
54
+ const EVENTS = 'events.jsonl';
55
+
56
+ /** The end marker is an ordinary record; this is the field that makes it one. */
57
+ const RUN_END = 'run-end';
58
+
59
+ /**
60
+ * Why a journal call failed, as a value rather than a sentence.
61
+ *
62
+ * The caller has to act differently on two of these than on the rest, and the
63
+ * only other way to tell them apart is to match on the message text — which puts
64
+ * the decision in two files at once and lets them drift the day someone improves
65
+ * the wording.
66
+ */
67
+ export const JOURNAL_FAILURES = Object.freeze([
68
+ 'undeclared', // no runDir was passed at all
69
+ 'run-dir-missing', // a runDir was passed and there is no such directory
70
+ 'field-missing', // a record that would name neither its gate nor its verdict
71
+ 'field-invalid', // a field was supplied in a shape the record cannot carry
72
+ 'unusable', // the journal on disk cannot be trusted (sequence, or unreadable)
73
+ 'ended', // this run already carries its run-end marker
74
+ ]);
75
+
76
+ export class RunJournalError extends Error {
77
+ constructor(failure, message, options) {
78
+ super(message, options);
79
+ this.name = 'RunJournalError';
80
+ this.failure = failure;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Is this a failure of the TRACE alone, leaving the caller's own work valid?
86
+ *
87
+ * 🔴 The distinction is the difference between a lost trace and a stalled loop.
88
+ * A journal that cannot accept records — a directory two sessions shared, one
89
+ * reused after its run ended — is unrepairable by design: it is append-only, and
90
+ * every later write re-reads it. If that stopped the caller, one collision would
91
+ * make the queue unselectable **forever**, and the trace would have taken the
92
+ * work down with it. The other failures are the caller's own mis-declaration,
93
+ * caught before anything happened and fixed in a second.
94
+ */
95
+ export const isTraceExhausted = (error) =>
96
+ error instanceof RunJournalError && (error.failure === 'unusable' || error.failure === 'ended');
97
+
98
+ const requireRunDir = (runDir) => {
99
+ if (typeof runDir !== 'string' || runDir.trim() === '') {
100
+ throw new RunJournalError(
101
+ 'undeclared',
102
+ 'the run journal needs a runDir: the run directory is the caller\'s to choose ' +
103
+ '(the convention is `.claude/runs/<run-id>/`), and this module deliberately ' +
104
+ 'invents none — a default would be a second owner of that convention.',
105
+ );
106
+ }
107
+ return runDir;
108
+ };
109
+
110
+ /**
111
+ * The directory has to exist, and its absence is an absence — never an empty run.
112
+ *
113
+ * A missing file inside the run directory is zero records, honestly: the run has
114
+ * not written that kind yet. A missing DIRECTORY is a different fact entirely —
115
+ * a typo in the declaration, or a directory someone deleted — and folding it into
116
+ * the same answer would hand back "this run decided nothing and is still going"
117
+ * about a run whose trace is simply not there. Checked in one place, for both the
118
+ * read and the write path, so the two cannot disagree about it.
119
+ */
120
+ const requireRunDirExists = (runDir) => {
121
+ let stats;
122
+ try {
123
+ stats = statSync(runDir);
124
+ } catch (error) {
125
+ throw new RunJournalError(
126
+ 'run-dir-missing',
127
+ `the run directory ${runDir} does not exist (${error?.code ?? 'unknown error'}), so ` +
128
+ 'there is no trace to read or append to. An absent directory is not an empty ' +
129
+ 'run: it is a declaration nobody honoured.',
130
+ { cause: error },
131
+ );
132
+ }
133
+ if (!stats.isDirectory()) {
134
+ throw new RunJournalError(
135
+ 'run-dir-missing',
136
+ `the run directory ${runDir} exists but is not a directory, so this run's two ` +
137
+ 'journal files have nowhere to live.',
138
+ );
139
+ }
140
+ return runDir;
141
+ };
142
+
143
+ const requireClock = (now) => {
144
+ // 🔴 Reading the clock here instead would make every record untestable and
145
+ // every replay a different file. The caller stamps; this module records.
146
+ if (typeof now !== 'string' || now.trim() === '') {
147
+ throw new RunJournalError(
148
+ 'field-missing',
149
+ 'the run journal needs `now` (an ISO timestamp): the clock is injected, never ' +
150
+ 'read here, which is what makes these records reproducible in a test.',
151
+ );
152
+ }
153
+ return now;
154
+ };
155
+
156
+ /**
157
+ * A record has to say what it is about.
158
+ *
159
+ * A verdict naming no gate, or a marker naming no reason for stopping, is a line
160
+ * in a file that answers none of the questions the file exists for — and the
161
+ * argument this module makes for a record over a sentinel file is precisely that
162
+ * a record carries when and why. Writing one that carries neither would refute
163
+ * its own header.
164
+ */
165
+ const requireField = (name, value) => {
166
+ if (typeof value !== 'string' || value.trim() === '') {
167
+ throw new RunJournalError(
168
+ 'field-missing',
169
+ `the run journal needs \`${name}\`: a record that does not name its ${name} ` +
170
+ 'answers none of the questions the journal is read for.',
171
+ );
172
+ }
173
+ return value;
174
+ };
175
+
176
+ /**
177
+ * One journal file, parsed.
178
+ *
179
+ * An absent file is zero records — a run that has not written that kind yet is
180
+ * the normal state. A file that exists and does not parse is NOT: folding it
181
+ * into an empty list would silently shorten the trace, and a shorter trace of a
182
+ * run is indistinguishable from a trace of a shorter run.
183
+ */
184
+ const linesOf = (runDir, file) => {
185
+ const path = join(runDir, file);
186
+ let raw;
187
+ try {
188
+ raw = readFileSync(path, 'utf8');
189
+ } catch (error) {
190
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return [];
191
+ throw new RunJournalError(
192
+ 'unusable',
193
+ `${path} exists but could not be read (${error?.code ?? 'unknown error'}), so this ` +
194
+ 'run\'s trace cannot be trusted to be complete. Refusing rather than reading ' +
195
+ 'the readable half as the whole.',
196
+ { cause: error },
197
+ );
198
+ }
199
+
200
+ return raw
201
+ .split('\n')
202
+ .filter((line) => line.trim() !== '')
203
+ .map((line, index) => {
204
+ try {
205
+ return JSON.parse(line);
206
+ } catch (error) {
207
+ throw new RunJournalError(
208
+ 'unusable',
209
+ `${path} line ${index + 1} is not a journal record, so the sequence cannot be ` +
210
+ 'checked and the order of this run cannot be trusted. A journal degrades ' +
211
+ 'loudly or not at all.',
212
+ { cause: error },
213
+ );
214
+ }
215
+ });
216
+ };
217
+
218
+ /**
219
+ * Both files, with the ordering invariant enforced across them.
220
+ *
221
+ * Two checks, and they catch different things:
222
+ * - **within a file**, `seq` must increase down the lines. Append-only makes
223
+ * line order write order, so a decreasing pair means the file was edited or
224
+ * assembled — the case a reader cannot see by eye;
225
+ * - **across both files**, the union of `seq` must be exactly `1..N`. A gap is
226
+ * a lost record; a duplicate is two records claiming one position, which is
227
+ * what concurrent writers into one run directory produce.
228
+ */
229
+ const readBoth = (runDir) => {
230
+ const decisions = linesOf(runDir, DECISIONS);
231
+ const events = linesOf(runDir, EVENTS);
232
+
233
+ const refuse = (why) => {
234
+ throw new RunJournalError(
235
+ 'unusable',
236
+ `the run journal in ${runDir} cannot be trusted: ${why}. The sequence is what ` +
237
+ 'orders these records — with it broken, a stale record reads as the current ' +
238
+ 'one, which is the failure the journal exists to prevent.',
239
+ );
240
+ };
241
+
242
+ for (const [file, records] of [
243
+ [DECISIONS, decisions],
244
+ [EVENTS, events],
245
+ ]) {
246
+ for (const [index, record] of records.entries()) {
247
+ if (!Number.isInteger(record?.seq)) {
248
+ refuse(`${file} line ${index + 1} carries no integer seq`);
249
+ }
250
+ if (index > 0 && record.seq <= records[index - 1].seq) {
251
+ refuse(
252
+ `${file} runs backwards at line ${index + 1} (seq ${records[index - 1].seq} ` +
253
+ `then ${record.seq}), so its order is not its write order`,
254
+ );
255
+ }
256
+ }
257
+ }
258
+
259
+ const all = [...decisions, ...events].sort((a, b) => a.seq - b.seq);
260
+ for (const [index, record] of all.entries()) {
261
+ if (record.seq !== index + 1) {
262
+ refuse(
263
+ `the records across both files are not a whole sequence — expected seq ` +
264
+ `${index + 1}, found ${record.seq}`,
265
+ );
266
+ }
267
+ }
268
+
269
+ return { decisions, events, all };
270
+ };
271
+
272
+ /**
273
+ * Append one record, after the two questions every write has to answer first:
274
+ * is this run already over, and what number is this record.
275
+ *
276
+ * Both answers come from the files themselves rather than from memory, because
277
+ * every caller is its own short-lived process — the CLI that selects an item and
278
+ * the gate that returns a verdict never share a variable.
279
+ */
280
+ const append = (runDir, file, fields, now) => {
281
+ requireRunDir(runDir);
282
+ requireRunDirExists(runDir);
283
+ requireClock(now);
284
+
285
+ const { all } = readBoth(runDir);
286
+
287
+ // 🔴 A run whose end can be followed by more records has no end. The marker
288
+ // exists to remove exactly one ambiguity — "did this run stop, or is it still
289
+ // going" — and a late record puts it straight back.
290
+ if (all.some((record) => record.kind === RUN_END)) {
291
+ throw new RunJournalError(
292
+ 'ended',
293
+ `the run in ${runDir} already carries its run-end marker, so nothing more may be ` +
294
+ 'recorded against it. A second record after the end would make "when did this ' +
295
+ 'run stop" have two answers and give the reader no way to pick one.',
296
+ );
297
+ }
298
+
299
+ const record = { seq: all.length + 1, at: now, ...fields };
300
+ try {
301
+ appendFileSync(join(runDir, file), `${JSON.stringify(record)}\n`);
302
+ } catch (error) {
303
+ // 🔴 The write door needs the same classification as the read door, and it
304
+ // was the one fs call left unwrapped. A full disk or a file the run cannot
305
+ // write reached the caller as a plain error, missed `isTraceExhausted`, and
306
+ // withheld the work — reproducing from this side the exact failure the
307
+ // classification exists to prevent. A journal that cannot take this record
308
+ // will not take the next one either: the trace is over, the run is not.
309
+ throw new RunJournalError(
310
+ 'unusable',
311
+ `the run journal in ${runDir} could not be appended to ` +
312
+ `(${error?.code ?? 'unknown error'}), so this run's trace stops here. The ` +
313
+ 'record was not written and nothing was modified.',
314
+ { cause: error },
315
+ );
316
+ }
317
+ return record;
318
+ };
319
+
320
+ /**
321
+ * A gate verdict: what was decided, and on what basis.
322
+ *
323
+ * `blockers` is what a structured verdict carries beyond the word
324
+ * (`lib/verdict.mjs`), and it is written verbatim — the blocker's own `rule` is
325
+ * what the author acts on, so a record keeping only a count would be the prose
326
+ * verdict again wearing a field name.
327
+ *
328
+ * 🔴 **Omitted is not empty.** With no `blockers` argument the key is absent
329
+ * from the record; `[]` is a CLAIM — "this gate named none" — and a caller that
330
+ * never passed the field never made it. Writing one in would let a later reader
331
+ * conclude a gate ruled clean from a record whose writer said nothing at all.
332
+ */
333
+ export const recordDecision = ({
334
+ runDir,
335
+ gate,
336
+ verdict,
337
+ why = null,
338
+ blockers,
339
+ headSha,
340
+ reviewers,
341
+ now,
342
+ } = {}) => {
343
+ requireField('gate', gate);
344
+ requireField('verdict', verdict);
345
+
346
+ const fields = { gate, verdict, why };
347
+ if (headSha !== undefined) {
348
+ if (typeof headSha !== 'string' || headSha.trim() === '') {
349
+ throw new RunJournalError(
350
+ 'field-invalid',
351
+ 'the run journal takes `headSha` as the commit the gate answered for. Absent is the ' +
352
+ 'honest answer when the gate did not say which one; a blank stands in for a commit ' +
353
+ 'and names none.',
354
+ );
355
+ }
356
+ fields.headSha = headSha;
357
+ }
358
+ if (reviewers !== undefined) {
359
+ if (!Array.isArray(reviewers) || reviewers.some((name) => typeof name !== 'string')) {
360
+ throw new RunJournalError(
361
+ 'field-invalid',
362
+ 'the run journal takes `reviewers` as the list of reviewers a route asked for, or a ' +
363
+ 'gate launched. A summary sentence in its place cannot be compared against the ' +
364
+ 'verdicts that came back, which is the only thing this field is for.',
365
+ );
366
+ }
367
+ // 🔴 No length check: `[]` is the answer for a lane that launches nobody,
368
+ // and it has to be distinguishable from the key being absent. Absent means
369
+ // the writer said nothing about reviewers at all — which is what every
370
+ // record written before this field existed means.
371
+ fields.reviewers = reviewers;
372
+ }
373
+ if (blockers !== undefined) {
374
+ if (!Array.isArray(blockers)) {
375
+ throw new RunJournalError(
376
+ 'field-invalid',
377
+ 'the run journal takes `blockers` as a list of the blockers a verdict named. A ' +
378
+ 'summary sentence in its place is the prose verdict this field exists to replace.',
379
+ );
380
+ }
381
+ fields.blockers = blockers;
382
+ }
383
+
384
+ return append(runDir, DECISIONS, fields, now);
385
+ };
386
+
387
+ /**
388
+ * Everything that is not a gate verdict.
389
+ *
390
+ * 🔴 It may not forge the end of the run. `kind` is a free string from the
391
+ * caller and exactly one value is load-bearing, so without this an ordinary
392
+ * event closes the run — and every later record is refused, by a marker nobody
393
+ * meant to write and no reader can distinguish from a real one.
394
+ */
395
+ export const recordEvent = ({ runDir, kind, data = null, now } = {}) => {
396
+ requireField('kind', kind);
397
+ if (kind === RUN_END) {
398
+ throw new RunJournalError(
399
+ 'field-missing',
400
+ `\`${RUN_END}\` is the marker's own kind and only \`endRun\` may write it: an ` +
401
+ 'event carrying it would end the run without naming why it stopped, and ' +
402
+ 'nothing downstream could tell the two apart.',
403
+ );
404
+ }
405
+ return append(runDir, EVENTS, { kind, data }, now);
406
+ };
407
+
408
+ /**
409
+ * The run-end marker — shipped from day one, not added once someone is confused.
410
+ *
411
+ * It is a record and not a sentinel file on purpose: a touch-file would satisfy
412
+ * "did it end" while carrying no time, no position in the sequence and no
413
+ * reason, and the trace could not say when or why the run stopped. The failure it
414
+ * exists to prevent is an ordinary one — a journal entry reading *"stopped at —
415
+ * checkpoint, still running"* outliving by weeks the run that wrote it, with
416
+ * nothing in the file able to contradict it.
417
+ */
418
+ export const endRun = ({ runDir, stop, now } = {}) => {
419
+ requireField('stop', stop);
420
+ return append(runDir, EVENTS, { kind: RUN_END, stop }, now);
421
+ };
422
+
423
+ /**
424
+ * The run as a reader sees it: both files, and whether it ended.
425
+ *
426
+ * It refuses rather than returning a sequence it cannot vouch for — see
427
+ * `readBoth`. That refusal IS the ordering invariant; documenting the ordering
428
+ * and checking nothing is what this module was ported to stop doing.
429
+ */
430
+ export const readRun = ({ runDir } = {}) => {
431
+ requireRunDir(runDir);
432
+ requireRunDirExists(runDir);
433
+ const { decisions, events, all } = readBoth(runDir);
434
+ return { decisions, events, ended: all.some((record) => record.kind === RUN_END) };
435
+ };