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,462 @@
1
+ /**
2
+ * The verdict schema — one shape every gate in this rulebook answers in.
3
+ *
4
+ * Before this module, a gate's verdict was a sentence the calling session
5
+ * pattern-matched by eye: `VERDICT: HOLD`, `PREMISE FALSE`, "not
6
+ * security-relevant". A reviewer that rambled a paragraph and never wrote the
7
+ * line was read as whatever the caller expected.
8
+ *
9
+ * Two of the properties the rulebook states are decided here: a blocker names
10
+ * what it violates, and a verdict that stops the change lists at least one. The
11
+ * third — one verdict per report — is asked for by each gate's spec and is not
12
+ * decided here: this reads the LAST block and ignores every earlier one
13
+ * (limit 3), which is what lets a reviewer quote the shape and then answer.
14
+ *
15
+ * So a gate now ends its report with exactly one fenced ```json block, and
16
+ * `parseVerdict` is the single place that decides whether such a block says
17
+ * anything. `verdict.mjs` (the CLI beside this file) is how `pr-ship` calls it;
18
+ * `run-journal.mjs` records the parsed value rather than the prose.
19
+ *
20
+ * 🔴 **Both directions of "the blocker list disagrees with the word" are
21
+ * refused, and the second is the dangerous one.** A `HOLD` naming no blocker
22
+ * gives the author nothing to act on, so the guess is usually "nothing". A
23
+ * `SHIP` that carries blockers reads as a pass while listing the reasons it
24
+ * should not have been one — same defect, wearing the answer everyone wanted.
25
+ *
26
+ * ⚠ **What this module is not.** It decides whether a verdict is WELL-FORMED,
27
+ * never whether it is right: a reviewer that saw nothing and answers `SHIP`
28
+ * passes here exactly like one that read the whole diff. It also does not run,
29
+ * launch or count gates — `pr-ship` runs the fan-out, `queue/index.mjs
30
+ * gate-round` counts the rounds.
31
+ *
32
+ * ⚠ **The limits, stated rather than implied.**
33
+ *
34
+ * 1. **An unknown gate name is accepted, with any word from `VERDICT_WORDS`.**
35
+ * A project adds reviewers of its own, and a schema that refused every name
36
+ * it had not been told about would be deleted by the first project that
37
+ * extended it. Such a gate is checked against the shared list and not against
38
+ * a per-gate one; a gate name with a typo in it is not caught either, and
39
+ * that is the price of the limit.
40
+ *
41
+ * **One shipped gate is in that position on purpose**, and it is a reviewer
42
+ * a stack layer adds. This file travels to every target, and the shared layer
43
+ * may not print a provider's name anywhere — so a gate whose own name carries
44
+ * one cannot be listed here, whatever else is true of it, and reaches the
45
+ * schema as an unknown gate. `GATE_VOCABULARY` therefore names the gates
46
+ * whose NAMES this layer may carry, which is not the same set as the gates a
47
+ * given target runs: a stack gate whose name is neutral is listed.
48
+ * 2. **A blocker may name no file.** A failing required check and a
49
+ * Definition-of-Done line have no `file:line`, and demanding one would make
50
+ * every run invent a location to satisfy the schema — worse than the prose
51
+ * this replaces. What every blocker does name is the `rule` it violates.
52
+ * 3. **It reads the LAST block in the report.** A spec shows the shape it wants
53
+ * and the reviewer then answers, so the first block in a report is usually
54
+ * the example — somebody else's gate, and usually the opposite word.
55
+ * 4. **Nothing launches it.** Like every gate in this layer it holds because a
56
+ * skill says to call it, not because a hook fires it.
57
+ * 5. **A second ``` after the opening fence makes the block's end a guess, and
58
+ * the guess is refused.** Markdown has no escape for a fence, so a reviewer
59
+ * quoting a fenced snippet in a `note` writes a block whose end is
60
+ * ambiguous — and reading it to the first closing fence would silently keep
61
+ * the front half, which on a measured case was a `SHIP` standing in front of
62
+ * the `HOLD` behind it. The test is deliberately coarser than that one case:
63
+ * **any** further fence is refused, including a well-formed block followed by
64
+ * a fenced snippet in trailing prose, because from here the two are the same
65
+ * text. Every spec says the block is the last thing in the report, so the
66
+ * remedy is the same either way — move the fenced snippet above the block,
67
+ * or name it in `evidence`, and answer again.
68
+ * 6. **`headSha` is optional, and an absent one means the gate did not say
69
+ * which commit it answered for — never that it answered for the current
70
+ * one.** A caller that read absence as "the head I am holding" would take a
71
+ * verdict about a diff nobody named as a verdict about the diff it is about
72
+ * to merge, so a caller that needs the answer keyed to a commit handles
73
+ * absence itself — `lib/gate-coverage.mjs` is the one that does, and it puts
74
+ * such a verdict in its own list rather than counting it either way. When
75
+ * present the value is a commit SHAPE, not free text: see `isCommitId`.
76
+ */
77
+
78
+ /** Every word any gate in this rulebook may return. */
79
+ export const VERDICT_WORDS = Object.freeze([
80
+ 'SHIP',
81
+ 'HOLD',
82
+ 'HEALTHY',
83
+ 'REGRESSION',
84
+ 'PREMISES_HOLD',
85
+ 'PREMISE_FALSE',
86
+ 'UNVERIFIABLE',
87
+ 'UNMEASURED',
88
+ 'NOT_APPLICABLE',
89
+ ]);
90
+
91
+ /**
92
+ * The part of that vocabulary each gate can actually mean.
93
+ *
94
+ * A shared list alone would let `code-reviewer` answer `HEALTHY` and
95
+ * `post-deploy-verify` answer `SHIP` — words whose meaning belongs to another
96
+ * gate, and which a caller reading only the word would act on.
97
+ *
98
+ * 🔴 `check-premises` writes its verdicts with spaces in prose (`PREMISES
99
+ * HOLD`) and as one token inside the block. Both forms are the contract; this
100
+ * is the machine one.
101
+ */
102
+ export const GATE_VOCABULARY = Object.freeze({
103
+ 'pr-ship': Object.freeze(['SHIP', 'HOLD']),
104
+ 'code-reviewer': Object.freeze(['SHIP', 'HOLD', 'NOT_APPLICABLE']),
105
+ 'prose-reviewer': Object.freeze(['SHIP', 'HOLD', 'NOT_APPLICABLE']),
106
+ 'security-scanner': Object.freeze(['SHIP', 'HOLD', 'NOT_APPLICABLE']),
107
+ 'check-premises': Object.freeze([
108
+ 'PREMISES_HOLD',
109
+ 'PREMISE_FALSE',
110
+ 'UNVERIFIABLE',
111
+ 'UNMEASURED',
112
+ ]),
113
+ 'post-deploy-verify': Object.freeze(['HEALTHY', 'REGRESSION']),
114
+ });
115
+
116
+ /**
117
+ * The words that mean stop — each one has to name what stopped it.
118
+ *
119
+ * `UNVERIFIABLE` and `UNMEASURED` are here for the same reason `HOLD` is: each
120
+ * is about a specific claim or sentence, and a verdict that will not say which
121
+ * one leaves the caller exactly where the free prose did.
122
+ */
123
+ export const BLOCKING_VERDICTS = Object.freeze([
124
+ 'HOLD',
125
+ 'REGRESSION',
126
+ 'PREMISE_FALSE',
127
+ 'UNVERIFIABLE',
128
+ 'UNMEASURED',
129
+ ]);
130
+
131
+ /** The only keys a block may carry. */
132
+ const SHAPE_KEYS = Object.freeze([
133
+ 'gate',
134
+ 'verdict',
135
+ 'blockers',
136
+ 'advisories',
137
+ 'evidence',
138
+ 'headSha',
139
+ ]);
140
+
141
+ const FENCE = '```json';
142
+
143
+ /** How much of one reviewer-written value a diagnosis will carry. */
144
+ const DIAGNOSIS_LIMIT = 120;
145
+
146
+ /**
147
+ * One reviewer-written value, made safe to print in a diagnosis.
148
+ *
149
+ * 🔴 **Every message below that quotes the report interpolates through this** — the
150
+ * key, the verdict word, the gate, and the parse error, whose message carries a
151
+ * snippet of the block the reviewer wrote — and there is exactly one of it.
152
+ *
153
+ * The values are written by a subagent and read by a
154
+ * human in a terminal: a `gate` carrying `ESC[2K ESC[1A … ESC[0m` erases the line
155
+ * above and repaints a refusal as a pass for whoever is watching the scrollback. The
156
+ * exit code and the empty stdout are unaffected — the target is the person, not the
157
+ * caller — which is exactly why it is the person's copy that has to be cleaned.
158
+ *
159
+ * It strips what a terminal *acts on* (C0, DEL, C1) rather than what it shows, so
160
+ * the text the operator has to read survives: a repainting payload arrives as its
161
+ * own visible words. Long values are cut to `DIAGNOSIS_LIMIT` with a `…`, so one
162
+ * field cannot push the rest of the diagnosis off the screen.
163
+ *
164
+ * ⚠ **What it does not remove, and why that is the line.** Bidirectional overrides
165
+ * (`U+202E` and family), zero-width characters, the soft hyphen and combining marks
166
+ * survive. None of them can address the cursor, so none can reach outside the value
167
+ * — every site prints it inside backticks with a constant sentence after it, and the
168
+ * worst they buy is a misread within those backticks. Escape sequences are the ones
169
+ * that rewrite the operator's screen, and those are what this removes.
170
+ *
171
+ * ⚠ **Two of the CLI's own arguments are passed through it, and two are not.**
172
+ * The report's path and the subcommand are printed exactly as the caller wrote
173
+ * them, because their whole job is to be pasted back into a command. The
174
+ * expected-gate argument IS sanitised, because it is printed beside a
175
+ * reviewer-written gate as one of two names the operator compares, and a value
176
+ * that can repaint that comparison defeats it. So is `coverage`'s commit
177
+ * argument, which is printed beside journal-written reviewer names for the same
178
+ * reason.
179
+ *
180
+ * It never throws — it is called only where something has already gone wrong, and a
181
+ * sanitiser that throws there turns a diagnosis into a crash the caller reads as
182
+ * "the gate did not answer".
183
+ */
184
+ export const safeForDiagnosis = (value) => {
185
+ let text;
186
+ try {
187
+ text = typeof value === 'string' ? value : String(value);
188
+ } catch {
189
+ return '<unprintable>';
190
+ }
191
+
192
+ // One forward pass over CODE POINTS, and the cap counts the units a terminal
193
+ // and a JSON writer count. Slicing the accumulated string instead would cut an
194
+ // astral character in half and hand the next consumer a lone surrogate.
195
+ const kept = [];
196
+ let units = 0;
197
+ for (const character of text) {
198
+ const code = character.codePointAt(0) ?? 0;
199
+ if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) continue;
200
+
201
+ if (units + character.length > DIAGNOSIS_LIMIT) {
202
+ // Something is left over, so the value is cut and says so. Drop whole
203
+ // characters until the ellipsis fits inside the cap.
204
+ while (units > DIAGNOSIS_LIMIT - 1) units -= kept.pop().length;
205
+ return `${kept.join('')}…`;
206
+ }
207
+ kept.push(character);
208
+ units += character.length;
209
+ }
210
+ return kept.join('');
211
+ };
212
+
213
+ const isPlainObject = (value) =>
214
+ typeof value === 'object' && value !== null && !Array.isArray(value);
215
+
216
+ const isText = (value) => typeof value === 'string' && value.trim() !== '';
217
+
218
+ /**
219
+ * A commit id, checked as a SHAPE rather than as text (AR-79).
220
+ *
221
+ * `headSha` arrived as "any non-blank text". **No consumer interpolates it
222
+ * today** — the only reader compares it as a string (`lib/gate-coverage.mjs`),
223
+ * and the merge criterion takes its SHA from `gh pr view --json headRefOid`, not
224
+ * from a verdict. The shape is fixed here so that the first consumer that does
225
+ * put it in an argument position inherits the check instead of having to
226
+ * remember it; `--upload-pack=…`, a leading `-` and `../…` are not commits, and
227
+ * a field that only ever holds commits is the cheap way to keep them out.
228
+ *
229
+ * ⚠ **This covers the value inside a verdict block, and nothing else.**
230
+ * `run-journal.mjs` takes `headSha` on a decision record as any non-blank
231
+ * string, and `pr-ship`'s fan-out record reaches it without passing through
232
+ * `parseVerdict` at all — so a commit id that never came from a reviewer's block
233
+ * is not shaped by this.
234
+ *
235
+ * **Checked on the raw value — never trimmed, never lowercased.** A value that
236
+ * needs rewriting to pass is a value the reviewer did not write, and rewriting it
237
+ * silently is how a near-miss becomes a match.
238
+ */
239
+ export const isCommitId = (value) => typeof value === 'string' && /^[0-9a-f]{7,64}$/i.test(value);
240
+
241
+ /**
242
+ * The last fenced ```json block, as one of three answers: `{ raw }` for a block
243
+ * whose extent is unambiguous, `{ ambiguous: true }` for one carrying a fence of
244
+ * its own (limit 5), and `null` when the report has no such block at all.
245
+ *
246
+ * One backward scan for the opening fence and one forward pass counting the
247
+ * closing ones — no regular expression over model-written text, which is
248
+ * unbounded and arrives from a subagent.
249
+ */
250
+ const lastJsonBlock = (text) => {
251
+ const opening = text.lastIndexOf(FENCE);
252
+ if (opening === -1) return null;
253
+
254
+ let start = opening + FENCE.length;
255
+ while (start < text.length && (text[start] === ' ' || text[start] === '\t')) start += 1;
256
+ if (text[start] === '\r') start += 1;
257
+ if (text[start] === '\n') start += 1;
258
+
259
+ const closing = text.indexOf('```', start);
260
+ // No closing fence at all: the rest of the report is the body, and JSON tells
261
+ // the reader what is wrong with it. One closing fence is the ordinary block.
262
+ if (closing === -1) return { raw: text.slice(start) };
263
+ if (text.indexOf('```', closing + 3) === -1) return { raw: text.slice(start, closing) };
264
+
265
+ // 🔴 More than one: the block's end is a guess, and the two guesses disagree
266
+ // about the verdict rather than about whitespace. Refuse instead.
267
+ return { ambiguous: true };
268
+ };
269
+
270
+ const checkBlocker = (blocker, index, problems) => {
271
+ const where = `blocker #${index + 1}`;
272
+ if (!isPlainObject(blocker)) {
273
+ problems.push(`${where} is not an object — a blocker names a rule and a note.`);
274
+ return;
275
+ }
276
+
277
+ if (!isText(blocker.rule)) {
278
+ problems.push(
279
+ `${where} names no \`rule\`: every blocker says what it violates — a checklist ` +
280
+ 'item, a required check by name, or the Definition-of-Done line that does not hold.',
281
+ );
282
+ }
283
+ if (!isText(blocker.note)) {
284
+ problems.push(`${where} names no \`note\`: what is wrong, in the author's terms.`);
285
+ }
286
+
287
+ const hasFile = blocker.file !== undefined && blocker.file !== null;
288
+ const hasLine = blocker.line !== undefined && blocker.line !== null;
289
+
290
+ if (hasFile && !isText(blocker.file)) {
291
+ problems.push(`${where} has a \`file\` that is not a path.`);
292
+ } else if (hasFile && !(Number.isInteger(blocker.line) && blocker.line >= 1)) {
293
+ problems.push(
294
+ `${where} names a \`file\` but no usable \`line\`: a location is a file AND the ` +
295
+ 'line inside it, so a reader lands where the finding is.',
296
+ );
297
+ } else if (hasLine && !hasFile) {
298
+ problems.push(`${where} names a \`line\` but no \`file\`, so the line points nowhere.`);
299
+ }
300
+ };
301
+
302
+ /**
303
+ * Read a gate's report and decide whether it ended in a verdict.
304
+ *
305
+ * Returns `{ ok: true, verdict }` or `{ ok: false, problems }` — never both, and
306
+ * never a throw: the input is a subagent's report, so malformed IS the expected
307
+ * case and it has to arrive as an answer rather than as a crash.
308
+ *
309
+ * Every problem in the block is reported together. One per round would cost the
310
+ * gate a round per problem, and `pr-ship` has a counted, finite number of them.
311
+ */
312
+ export function parseVerdict(text) {
313
+ const problems = [];
314
+ if (typeof text !== 'string') {
315
+ return { ok: false, problems: ['no report was supplied to read a verdict from.'] };
316
+ }
317
+
318
+ const block = lastJsonBlock(text);
319
+ if (block === null) {
320
+ return {
321
+ ok: false,
322
+ problems: [
323
+ 'the report carries no fenced ```json block, so it states no verdict this gate ' +
324
+ 'can act on. Every gate ends with exactly one.',
325
+ ],
326
+ };
327
+ }
328
+ if (block.ambiguous) {
329
+ return {
330
+ ok: false,
331
+ problems: [
332
+ 'a second ``` follows the opening fence of the final json block, so where that ' +
333
+ 'block ends is a guess — either backticks inside it, or a fenced snippet after ' +
334
+ 'it, and from here the two are the same text. The block is the last thing in ' +
335
+ 'the report: move the snippet above it, or name it in `evidence`, and answer ' +
336
+ 'again.',
337
+ ],
338
+ };
339
+ }
340
+
341
+ let parsed;
342
+ try {
343
+ parsed = JSON.parse(block.raw);
344
+ } catch (error) {
345
+ return {
346
+ ok: false,
347
+ // 🔴 Through the sanitiser like every other quoted value, and this one is
348
+ // the least obvious of them: V8 puts a ~20-character snippet of the input
349
+ // INTO the SyntaxError's message, so the reviewer chooses what a failed
350
+ // parse prints just as directly as it chooses a gate name.
351
+ problems: [
352
+ `the final fenced json block is not JSON ` +
353
+ `(${safeForDiagnosis(error?.message ?? 'parse failed')}).`,
354
+ ],
355
+ };
356
+ }
357
+
358
+ if (!isPlainObject(parsed)) {
359
+ return {
360
+ ok: false,
361
+ problems: ['the final fenced json block is not an object naming a gate and a verdict.'],
362
+ };
363
+ }
364
+
365
+ for (const key of Object.keys(parsed)) {
366
+ if (!SHAPE_KEYS.includes(key)) {
367
+ problems.push(
368
+ `the block carries \`${safeForDiagnosis(key)}\`, which this shape does not define. ` +
369
+ 'Refused rather ' +
370
+ 'than dropped: a silently ignored field is one the reviewer believes it reported.',
371
+ );
372
+ }
373
+ }
374
+
375
+ const gate = parsed.gate;
376
+ if (!isText(gate)) {
377
+ problems.push('the block names no `gate`, so nothing says which gate this verdict is from.');
378
+ }
379
+
380
+ const verdict = parsed.verdict;
381
+ const known = typeof verdict === 'string' && VERDICT_WORDS.includes(verdict);
382
+ if (!known) {
383
+ problems.push(
384
+ `\`${safeForDiagnosis(verdict)}\` is not a verdict any gate in this rulebook returns ` +
385
+ `(${VERDICT_WORDS.join(', ')}).`,
386
+ );
387
+ } else if (isText(gate) && Object.hasOwn(GATE_VOCABULARY, gate)) {
388
+ const allowed = GATE_VOCABULARY[gate];
389
+ if (!allowed.includes(verdict)) {
390
+ problems.push(
391
+ `${safeForDiagnosis(gate)} cannot return ${verdict} — that word belongs to another ` +
392
+ 'gate. It returns ' +
393
+ `one of: ${allowed.join(', ')}.`,
394
+ );
395
+ }
396
+ }
397
+
398
+ const blockers = parsed.blockers;
399
+ if (!Array.isArray(blockers)) {
400
+ problems.push(
401
+ 'the block names no `blockers` list. It is required and may be empty; absent, a ' +
402
+ 'reader cannot tell "this gate found nothing" from "this gate did not say".',
403
+ );
404
+ } else {
405
+ blockers.forEach((blocker, index) => checkBlocker(blocker, index, problems));
406
+
407
+ if (known && BLOCKING_VERDICTS.includes(verdict) && blockers.length === 0) {
408
+ problems.push(
409
+ `${verdict} is a verdict that stops the change, and this one names no blocker. A ` +
410
+ 'stop nobody can act on is worse than no stop: the author has to guess what to ' +
411
+ 'fix, and the guess is usually "nothing".',
412
+ );
413
+ }
414
+ if (known && !BLOCKING_VERDICTS.includes(verdict) && blockers.length > 0) {
415
+ problems.push(
416
+ `${verdict} means proceed, and this one lists ${blockers.length} blocker(s). Either ` +
417
+ 'the word or the list is wrong, and a pass carrying the reasons it should not ' +
418
+ 'have been one is the more expensive of the two to discover later.',
419
+ );
420
+ }
421
+ }
422
+
423
+ const advisories = parsed.advisories;
424
+ if (advisories !== undefined && !Array.isArray(advisories)) {
425
+ problems.push('`advisories` is not a list — it is optional, and a list when present.');
426
+ }
427
+
428
+ const headSha = parsed.headSha;
429
+ if (headSha !== undefined && !isCommitId(headSha)) {
430
+ problems.push(
431
+ `\`headSha\` is \`${safeForDiagnosis(headSha)}\`, which is not a commit this verdict ` +
432
+ 'could have answered for. It is optional, and 7 to 64 hex characters (0-9a-f) when ' +
433
+ 'present — nothing else, and with no surrounding space.',
434
+ );
435
+ }
436
+
437
+ const evidence = parsed.evidence;
438
+ if (evidence !== undefined) {
439
+ if (!Array.isArray(evidence)) {
440
+ problems.push('`evidence` is not a list of lines — it is optional, and a list when present.');
441
+ } else if (!evidence.every((line) => typeof line === 'string')) {
442
+ problems.push('`evidence` carries an entry that is not a line of text.');
443
+ }
444
+ }
445
+
446
+ if (problems.length > 0) return { ok: false, problems };
447
+
448
+ return {
449
+ ok: true,
450
+ verdict: {
451
+ gate,
452
+ verdict,
453
+ blockers,
454
+ advisories: advisories ?? [],
455
+ evidence: evidence ?? [],
456
+ // Spread, never a hardcoded key: a verdict that named no commit must come
457
+ // back without the key at all, so a caller can tell "answered for this
458
+ // commit" from "said nothing about which commit".
459
+ ...(headSha === undefined ? {} : { headSha }),
460
+ },
461
+ };
462
+ }
@@ -11,7 +11,7 @@
11
11
  // reason it is safe to script half a checklist. The honest objection to a partial
12
12
  // script — "a script that half-checks is worse than a list the run actually
13
13
  // reads" — is true exactly while the boundary is invisible. A silent script would
14
- // let a GO on three items read as a pass on six.
14
+ // let a GO on four items read as a pass on seven.
15
15
  //
16
16
  // 🔴 **`unknown` never becomes `pass`.** A probe that could not run tells you
17
17
  // nothing, and "I could not look" recorded as "it is fine" is the failure this
@@ -43,23 +43,13 @@ export const UNCHECKED = [
43
43
  *
44
44
  * 🔴 Limit: only repository *location* is stripped. `gh` inherits the rest of
45
45
  * the environment on purpose — its credentials live there.
46
+ *
47
+ * It lives in `git-env.mjs` and is re-exported here, so callers that already
48
+ * import it from this file keep working while the queue seam — which must not
49
+ * pull a CLI script into its read path — imports the small module directly.
46
50
  */
47
- export const withoutGitLocation = (env = process.env) => {
48
- const sanitised = { ...env };
49
- for (const key of [
50
- 'GIT_DIR',
51
- 'GIT_WORK_TREE',
52
- 'GIT_INDEX_FILE',
53
- 'GIT_COMMON_DIR',
54
- 'GIT_OBJECT_DIRECTORY',
55
- 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
56
- 'GIT_NAMESPACE',
57
- 'GIT_PREFIX',
58
- ]) {
59
- delete sanitised[key];
60
- }
61
- return sanitised;
62
- };
51
+ export { withoutGitLocation } from './git-env.mjs';
52
+ import { withoutGitLocation } from './git-env.mjs';
63
53
 
64
54
  const run = (command, args) =>
65
55
  execFileSync(command, args, {
@@ -105,6 +95,31 @@ export const checkDefaultBranchFresh = () => {
105
95
  }
106
96
  };
107
97
 
98
+ /**
99
+ * `RIG_RUN_DIR` must not already be exported when preflight runs (AR-139).
100
+ *
101
+ * Preflight walks BEFORE this run declares its directory (`loop` §1), so a
102
+ * value already in the environment is a leak — an `export` that outlived an
103
+ * earlier run, or a shell that inherited one. Everything the run then spawns
104
+ * inherits it too: the queue CLI under test, the gate scripts, and the real
105
+ * run's append-only trace receives their fixture records. Measured at 38
106
+ * fixture selections and 22 fixture revalidation events, plus two tests
107
+ * exiting 1 and blamed on load. A hard failure, because starting on it puts
108
+ * this run's stops in somebody else's file.
109
+ */
110
+ export const checkRunDirNotExported = (env = process.env) => {
111
+ const value = env.RIG_RUN_DIR;
112
+ return value
113
+ ? {
114
+ ok: false,
115
+ detail:
116
+ `RIG_RUN_DIR is already exported (${value}) — a leak from an earlier run. ` +
117
+ '`unset RIG_RUN_DIR`, then declare this run\'s own directory; an inherited ' +
118
+ "one lands this run's trace and stop conditions in somebody else's file",
119
+ }
120
+ : { ok: true, detail: 'not exported' };
121
+ };
122
+
108
123
  /** The last deploy must have concluded successfully — never start on a broken runtime. */
109
124
  export const checkLastDeploy = ({ workflow = 'deploy' } = {}) => {
110
125
  try {
@@ -182,6 +197,7 @@ const invokedDirectly = () => {
182
197
  if (invokedDirectly()) {
183
198
  const checks = {
184
199
  killSwitch: checkKillSwitch(),
200
+ runDirNotExported: checkRunDirNotExported(),
185
201
  defaultBranchFresh: checkDefaultBranchFresh(),
186
202
  lastDeploy: checkLastDeploy(),
187
203
  };
@@ -0,0 +1,51 @@
1
+ // The two git questions a proposal's `asOf` needs answered (AR-116):
2
+ // which commit is HEAD, and what changed between a commit and HEAD.
3
+ //
4
+ // Kept out of `core.mjs`, which is pure, and out of the adapters, which would
5
+ // otherwise each spawn git their own way. Both answers are `null` when git
6
+ // cannot answer — no checkout, an unknown commit, a shallow clone — and
7
+ // `overtakenOf` in core.mjs turns that `null` into an "unanswerable" finding
8
+ // rather than a clean one.
9
+
10
+ import { execFileSync } from 'node:child_process';
11
+ import { dirname, join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { withoutGitLocation } from '../git-env.mjs';
14
+
15
+ const git = (args, cwd) =>
16
+ execFileSync('git', args, {
17
+ cwd,
18
+ encoding: 'utf8',
19
+ stdio: ['ignore', 'pipe', 'ignore'],
20
+ env: withoutGitLocation(),
21
+ }).trim();
22
+
23
+ /** HEAD of the checkout at `cwd`, or null where there is none. */
24
+ export const headShaOf = ({ cwd = process.cwd() } = {}) => {
25
+ try {
26
+ const sha = git(['rev-parse', 'HEAD'], cwd);
27
+ return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ };
32
+
33
+ /** Paths changed between `asOf` and `head`, or null when git cannot say. */
34
+ export const changedSinceOf = ({ cwd = process.cwd(), asOf, head = 'HEAD' } = {}) => {
35
+ if (typeof asOf !== 'string' || !/^[0-9a-f]{7,40}$/.test(asOf)) return null;
36
+ try {
37
+ return git(['diff', '--name-only', '-z', asOf, head], cwd).split('\0').filter(Boolean);
38
+ } catch {
39
+ return null;
40
+ }
41
+ };
42
+
43
+ /**
44
+ * The commit a proposal is measured against: what the caller says, or HEAD of
45
+ * the project this script belongs to. `null` files without one — and hygiene
46
+ * then reports the proposal as unanswerable rather than current. One
47
+ * implementation for all three adapters, so they cannot answer differently.
48
+ */
49
+ const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
50
+ export const withAsOf = (proposal) =>
51
+ proposal.asOf === undefined ? { ...proposal, asOf: headShaOf({ cwd: PROJECT_ROOT }) } : proposal;