create-agent-rig 0.4.0 β†’ 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +207 -8
  2. package/README.md +50 -17
  3. package/package.json +2 -1
  4. package/packages/cli/dist/commands/create.js +8 -3
  5. package/packages/cli/dist/commands/init.js +82 -34
  6. package/packages/cli/dist/commands/upgrade.js +112 -30
  7. package/packages/cli/dist/index.js +38 -14
  8. package/packages/cli/dist/lib/copy-tree.js +35 -6
  9. package/packages/cli/dist/lib/init-settings.js +12 -0
  10. package/packages/cli/dist/lib/install-set.js +6 -8
  11. package/packages/cli/dist/lib/manifest.js +21 -9
  12. package/packages/cli/dist/lib/safe-path.js +30 -0
  13. package/templates/agent-os/init/AGENTS.md +191 -0
  14. package/templates/agent-os/init/CLAUDE.md +61 -9
  15. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +98 -0
  16. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
  17. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +34 -3
  18. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
  19. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +34 -0
  20. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
  21. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +233 -0
  22. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +676 -0
  23. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
  24. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
  25. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  26. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +338 -0
  27. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
  28. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +33 -1
  29. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +64 -1
  30. package/templates/agent-os/universal/.claude/agents/security-scanner.md +33 -0
  31. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +274 -24
  32. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
  33. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
  34. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
  35. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
  36. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
  37. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +480 -0
  38. package/templates/agent-os/universal/.claude/rules/autonomy.md +61 -1
  39. package/templates/agent-os/universal/.claude/rules/invariants.md +71 -16
  40. package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
  41. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1242 -0
  42. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
  43. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
  44. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
  45. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +433 -0
  46. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +6 -16
  47. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +89 -0
  48. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +334 -23
  49. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
  50. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +7 -2
  51. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +413 -8
  52. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +75 -13
  53. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +222 -21
  54. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
  55. package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
  56. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +441 -0
  57. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -0
  58. package/templates/agent-os/universal/.claude/settings.json +7 -2
  59. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +115 -7
  60. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +378 -31
  61. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +300 -25
  62. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
  63. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
  64. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
  65. package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
  66. package/templates/agent-os/universal/.codex/hooks.json +65 -0
  67. package/templates/agent-os/universal/AGENTS.md +164 -0
  68. package/templates/agent-os/universal/CLAUDE.md +47 -14
  69. package/templates/agent-os/universal/PLAN.md +7 -40
  70. package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
  71. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
  72. package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
  73. package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
  74. package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
  75. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
  76. package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
  77. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +62 -0
  78. package/templates/agent-os/universal/journal/README.md +101 -0
  79. package/templates/agent-os/universal/layers.json +36 -2
  80. package/templates/hash-history.json +2 -1
  81. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
  82. package/templates/skeleton/aws-serverless/README.md +91 -9
  83. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
  84. package/templates/skeleton/aws-serverless/gitignore +37 -0
  85. package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
  86. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
  87. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
  88. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
  89. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
  90. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
  91. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
  92. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
  93. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
  94. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
  95. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
  96. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
  97. package/templates/skeleton/node-service/README.md +11 -1
  98. package/templates/skeleton/node-service/gitignore +34 -0
  99. package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
  100. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
  101. package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
  102. package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
  103. package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
  104. package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
  105. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
@@ -0,0 +1,433 @@
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.
74
+ */
75
+
76
+ /** Every word any gate in this rulebook may return. */
77
+ export const VERDICT_WORDS = Object.freeze([
78
+ 'SHIP',
79
+ 'HOLD',
80
+ 'HEALTHY',
81
+ 'REGRESSION',
82
+ 'PREMISES_HOLD',
83
+ 'PREMISE_FALSE',
84
+ 'UNVERIFIABLE',
85
+ 'UNMEASURED',
86
+ 'NOT_APPLICABLE',
87
+ ]);
88
+
89
+ /**
90
+ * The part of that vocabulary each gate can actually mean.
91
+ *
92
+ * A shared list alone would let `code-reviewer` answer `HEALTHY` and
93
+ * `post-deploy-verify` answer `SHIP` β€” words whose meaning belongs to another
94
+ * gate, and which a caller reading only the word would act on.
95
+ *
96
+ * πŸ”΄ `check-premises` writes its verdicts with spaces in prose (`PREMISES
97
+ * HOLD`) and as one token inside the block. Both forms are the contract; this
98
+ * is the machine one.
99
+ */
100
+ export const GATE_VOCABULARY = Object.freeze({
101
+ 'pr-ship': Object.freeze(['SHIP', 'HOLD']),
102
+ 'code-reviewer': Object.freeze(['SHIP', 'HOLD', 'NOT_APPLICABLE']),
103
+ 'prose-reviewer': Object.freeze(['SHIP', 'HOLD', 'NOT_APPLICABLE']),
104
+ 'security-scanner': Object.freeze(['SHIP', 'HOLD', 'NOT_APPLICABLE']),
105
+ 'check-premises': Object.freeze([
106
+ 'PREMISES_HOLD',
107
+ 'PREMISE_FALSE',
108
+ 'UNVERIFIABLE',
109
+ 'UNMEASURED',
110
+ ]),
111
+ 'post-deploy-verify': Object.freeze(['HEALTHY', 'REGRESSION']),
112
+ });
113
+
114
+ /**
115
+ * The words that mean stop β€” each one has to name what stopped it.
116
+ *
117
+ * `UNVERIFIABLE` and `UNMEASURED` are here for the same reason `HOLD` is: each
118
+ * is about a specific claim or sentence, and a verdict that will not say which
119
+ * one leaves the caller exactly where the free prose did.
120
+ */
121
+ export const BLOCKING_VERDICTS = Object.freeze([
122
+ 'HOLD',
123
+ 'REGRESSION',
124
+ 'PREMISE_FALSE',
125
+ 'UNVERIFIABLE',
126
+ 'UNMEASURED',
127
+ ]);
128
+
129
+ /** The only keys a block may carry. */
130
+ const SHAPE_KEYS = Object.freeze([
131
+ 'gate',
132
+ 'verdict',
133
+ 'blockers',
134
+ 'advisories',
135
+ 'evidence',
136
+ 'headSha',
137
+ ]);
138
+
139
+ const FENCE = '```json';
140
+
141
+ /** How much of one reviewer-written value a diagnosis will carry. */
142
+ const DIAGNOSIS_LIMIT = 120;
143
+
144
+ /**
145
+ * One reviewer-written value, made safe to print in a diagnosis.
146
+ *
147
+ * πŸ”΄ **Every message below that quotes the report interpolates through this** β€” the
148
+ * key, the verdict word, the gate, and the parse error, whose message carries a
149
+ * snippet of the block the reviewer wrote β€” and there is exactly one of it.
150
+ *
151
+ * The values are written by a subagent and read by a
152
+ * human in a terminal: a `gate` carrying `ESC[2K ESC[1A … ESC[0m` erases the line
153
+ * above and repaints a refusal as a pass for whoever is watching the scrollback. The
154
+ * exit code and the empty stdout are unaffected β€” the target is the person, not the
155
+ * caller β€” which is exactly why it is the person's copy that has to be cleaned.
156
+ *
157
+ * It strips what a terminal *acts on* (C0, DEL, C1) rather than what it shows, so
158
+ * the text the operator has to read survives: a repainting payload arrives as its
159
+ * own visible words. Long values are cut to `DIAGNOSIS_LIMIT` with a `…`, so one
160
+ * field cannot push the rest of the diagnosis off the screen.
161
+ *
162
+ * ⚠ **What it does not remove, and why that is the line.** Bidirectional overrides
163
+ * (`U+202E` and family), zero-width characters, the soft hyphen and combining marks
164
+ * survive. None of them can address the cursor, so none can reach outside the value
165
+ * β€” every site prints it inside backticks with a constant sentence after it, and the
166
+ * worst they buy is a misread within those backticks. Escape sequences are the ones
167
+ * that rewrite the operator's screen, and those are what this removes.
168
+ *
169
+ * ⚠ **The CLI's own argv is not passed through it.** The report's path and the
170
+ * subcommand are printed exactly as the caller wrote them, because their whole job
171
+ * is to be pasted back into a command; the expected-gate argument IS sanitised,
172
+ * because it is printed beside a reviewer-written gate as one of two names the
173
+ * operator compares, and a value that can repaint that comparison defeats it.
174
+ *
175
+ * It never throws β€” it is called only where something has already gone wrong, and a
176
+ * sanitiser that throws there turns a diagnosis into a crash the caller reads as
177
+ * "the gate did not answer".
178
+ */
179
+ export const safeForDiagnosis = (value) => {
180
+ let text;
181
+ try {
182
+ text = typeof value === 'string' ? value : String(value);
183
+ } catch {
184
+ return '<unprintable>';
185
+ }
186
+
187
+ // One forward pass over CODE POINTS, and the cap counts the units a terminal
188
+ // and a JSON writer count. Slicing the accumulated string instead would cut an
189
+ // astral character in half and hand the next consumer a lone surrogate.
190
+ const kept = [];
191
+ let units = 0;
192
+ for (const character of text) {
193
+ const code = character.codePointAt(0) ?? 0;
194
+ if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) continue;
195
+
196
+ if (units + character.length > DIAGNOSIS_LIMIT) {
197
+ // Something is left over, so the value is cut and says so. Drop whole
198
+ // characters until the ellipsis fits inside the cap.
199
+ while (units > DIAGNOSIS_LIMIT - 1) units -= kept.pop().length;
200
+ return `${kept.join('')}…`;
201
+ }
202
+ kept.push(character);
203
+ units += character.length;
204
+ }
205
+ return kept.join('');
206
+ };
207
+
208
+ const isPlainObject = (value) =>
209
+ typeof value === 'object' && value !== null && !Array.isArray(value);
210
+
211
+ const isText = (value) => typeof value === 'string' && value.trim() !== '';
212
+
213
+ /**
214
+ * The last fenced ```json block, as one of three answers: `{ raw }` for a block
215
+ * whose extent is unambiguous, `{ ambiguous: true }` for one carrying a fence of
216
+ * its own (limit 5), and `null` when the report has no such block at all.
217
+ *
218
+ * One backward scan for the opening fence and one forward pass counting the
219
+ * closing ones β€” no regular expression over model-written text, which is
220
+ * unbounded and arrives from a subagent.
221
+ */
222
+ const lastJsonBlock = (text) => {
223
+ const opening = text.lastIndexOf(FENCE);
224
+ if (opening === -1) return null;
225
+
226
+ let start = opening + FENCE.length;
227
+ while (start < text.length && (text[start] === ' ' || text[start] === '\t')) start += 1;
228
+ if (text[start] === '\r') start += 1;
229
+ if (text[start] === '\n') start += 1;
230
+
231
+ const closing = text.indexOf('```', start);
232
+ // No closing fence at all: the rest of the report is the body, and JSON tells
233
+ // the reader what is wrong with it. One closing fence is the ordinary block.
234
+ if (closing === -1) return { raw: text.slice(start) };
235
+ if (text.indexOf('```', closing + 3) === -1) return { raw: text.slice(start, closing) };
236
+
237
+ // πŸ”΄ More than one: the block's end is a guess, and the two guesses disagree
238
+ // about the verdict rather than about whitespace. Refuse instead.
239
+ return { ambiguous: true };
240
+ };
241
+
242
+ const checkBlocker = (blocker, index, problems) => {
243
+ const where = `blocker #${index + 1}`;
244
+ if (!isPlainObject(blocker)) {
245
+ problems.push(`${where} is not an object β€” a blocker names a rule and a note.`);
246
+ return;
247
+ }
248
+
249
+ if (!isText(blocker.rule)) {
250
+ problems.push(
251
+ `${where} names no \`rule\`: every blocker says what it violates β€” a checklist ` +
252
+ 'item, a required check by name, or the Definition-of-Done line that does not hold.',
253
+ );
254
+ }
255
+ if (!isText(blocker.note)) {
256
+ problems.push(`${where} names no \`note\`: what is wrong, in the author's terms.`);
257
+ }
258
+
259
+ const hasFile = blocker.file !== undefined && blocker.file !== null;
260
+ const hasLine = blocker.line !== undefined && blocker.line !== null;
261
+
262
+ if (hasFile && !isText(blocker.file)) {
263
+ problems.push(`${where} has a \`file\` that is not a path.`);
264
+ } else if (hasFile && !(Number.isInteger(blocker.line) && blocker.line >= 1)) {
265
+ problems.push(
266
+ `${where} names a \`file\` but no usable \`line\`: a location is a file AND the ` +
267
+ 'line inside it, so a reader lands where the finding is.',
268
+ );
269
+ } else if (hasLine && !hasFile) {
270
+ problems.push(`${where} names a \`line\` but no \`file\`, so the line points nowhere.`);
271
+ }
272
+ };
273
+
274
+ /**
275
+ * Read a gate's report and decide whether it ended in a verdict.
276
+ *
277
+ * Returns `{ ok: true, verdict }` or `{ ok: false, problems }` β€” never both, and
278
+ * never a throw: the input is a subagent's report, so malformed IS the expected
279
+ * case and it has to arrive as an answer rather than as a crash.
280
+ *
281
+ * Every problem in the block is reported together. One per round would cost the
282
+ * gate a round per problem, and `pr-ship` has a counted, finite number of them.
283
+ */
284
+ export function parseVerdict(text) {
285
+ const problems = [];
286
+ if (typeof text !== 'string') {
287
+ return { ok: false, problems: ['no report was supplied to read a verdict from.'] };
288
+ }
289
+
290
+ const block = lastJsonBlock(text);
291
+ if (block === null) {
292
+ return {
293
+ ok: false,
294
+ problems: [
295
+ 'the report carries no fenced ```json block, so it states no verdict this gate ' +
296
+ 'can act on. Every gate ends with exactly one.',
297
+ ],
298
+ };
299
+ }
300
+ if (block.ambiguous) {
301
+ return {
302
+ ok: false,
303
+ problems: [
304
+ 'a second ``` follows the opening fence of the final json block, so where that ' +
305
+ 'block ends is a guess β€” either backticks inside it, or a fenced snippet after ' +
306
+ 'it, and from here the two are the same text. The block is the last thing in ' +
307
+ 'the report: move the snippet above it, or name it in `evidence`, and answer ' +
308
+ 'again.',
309
+ ],
310
+ };
311
+ }
312
+
313
+ let parsed;
314
+ try {
315
+ parsed = JSON.parse(block.raw);
316
+ } catch (error) {
317
+ return {
318
+ ok: false,
319
+ // πŸ”΄ Through the sanitiser like every other quoted value, and this one is
320
+ // the least obvious of them: V8 puts a ~20-character snippet of the input
321
+ // INTO the SyntaxError's message, so the reviewer chooses what a failed
322
+ // parse prints just as directly as it chooses a gate name.
323
+ problems: [
324
+ `the final fenced json block is not JSON ` +
325
+ `(${safeForDiagnosis(error?.message ?? 'parse failed')}).`,
326
+ ],
327
+ };
328
+ }
329
+
330
+ if (!isPlainObject(parsed)) {
331
+ return {
332
+ ok: false,
333
+ problems: ['the final fenced json block is not an object naming a gate and a verdict.'],
334
+ };
335
+ }
336
+
337
+ for (const key of Object.keys(parsed)) {
338
+ if (!SHAPE_KEYS.includes(key)) {
339
+ problems.push(
340
+ `the block carries \`${safeForDiagnosis(key)}\`, which this shape does not define. ` +
341
+ 'Refused rather ' +
342
+ 'than dropped: a silently ignored field is one the reviewer believes it reported.',
343
+ );
344
+ }
345
+ }
346
+
347
+ const gate = parsed.gate;
348
+ if (!isText(gate)) {
349
+ problems.push('the block names no `gate`, so nothing says which gate this verdict is from.');
350
+ }
351
+
352
+ const verdict = parsed.verdict;
353
+ const known = typeof verdict === 'string' && VERDICT_WORDS.includes(verdict);
354
+ if (!known) {
355
+ problems.push(
356
+ `\`${safeForDiagnosis(verdict)}\` is not a verdict any gate in this rulebook returns ` +
357
+ `(${VERDICT_WORDS.join(', ')}).`,
358
+ );
359
+ } else if (isText(gate) && Object.hasOwn(GATE_VOCABULARY, gate)) {
360
+ const allowed = GATE_VOCABULARY[gate];
361
+ if (!allowed.includes(verdict)) {
362
+ problems.push(
363
+ `${safeForDiagnosis(gate)} cannot return ${verdict} β€” that word belongs to another ` +
364
+ 'gate. It returns ' +
365
+ `one of: ${allowed.join(', ')}.`,
366
+ );
367
+ }
368
+ }
369
+
370
+ const blockers = parsed.blockers;
371
+ if (!Array.isArray(blockers)) {
372
+ problems.push(
373
+ 'the block names no `blockers` list. It is required and may be empty; absent, a ' +
374
+ 'reader cannot tell "this gate found nothing" from "this gate did not say".',
375
+ );
376
+ } else {
377
+ blockers.forEach((blocker, index) => checkBlocker(blocker, index, problems));
378
+
379
+ if (known && BLOCKING_VERDICTS.includes(verdict) && blockers.length === 0) {
380
+ problems.push(
381
+ `${verdict} is a verdict that stops the change, and this one names no blocker. A ` +
382
+ 'stop nobody can act on is worse than no stop: the author has to guess what to ' +
383
+ 'fix, and the guess is usually "nothing".',
384
+ );
385
+ }
386
+ if (known && !BLOCKING_VERDICTS.includes(verdict) && blockers.length > 0) {
387
+ problems.push(
388
+ `${verdict} means proceed, and this one lists ${blockers.length} blocker(s). Either ` +
389
+ 'the word or the list is wrong, and a pass carrying the reasons it should not ' +
390
+ 'have been one is the more expensive of the two to discover later.',
391
+ );
392
+ }
393
+ }
394
+
395
+ const advisories = parsed.advisories;
396
+ if (advisories !== undefined && !Array.isArray(advisories)) {
397
+ problems.push('`advisories` is not a list β€” it is optional, and a list when present.');
398
+ }
399
+
400
+ const headSha = parsed.headSha;
401
+ if (headSha !== undefined && !isText(headSha)) {
402
+ problems.push(
403
+ `\`headSha\` is \`${safeForDiagnosis(headSha)}\`, which is not a commit this verdict ` +
404
+ 'could have answered for. It is optional, and a line of text when present.',
405
+ );
406
+ }
407
+
408
+ const evidence = parsed.evidence;
409
+ if (evidence !== undefined) {
410
+ if (!Array.isArray(evidence)) {
411
+ problems.push('`evidence` is not a list of lines β€” it is optional, and a list when present.');
412
+ } else if (!evidence.every((line) => typeof line === 'string')) {
413
+ problems.push('`evidence` carries an entry that is not a line of text.');
414
+ }
415
+ }
416
+
417
+ if (problems.length > 0) return { ok: false, problems };
418
+
419
+ return {
420
+ ok: true,
421
+ verdict: {
422
+ gate,
423
+ verdict,
424
+ blockers,
425
+ advisories: advisories ?? [],
426
+ evidence: evidence ?? [],
427
+ // Spread, never a hardcoded key: a verdict that named no commit must come
428
+ // back without the key at all, so a caller can tell "answered for this
429
+ // commit" from "said nothing about which commit".
430
+ ...(headSha === undefined ? {} : { headSha }),
431
+ },
432
+ };
433
+ }
@@ -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, {
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Which checkout are we in?
3
+ *
4
+ * Its own module on purpose, and the reason is a defect this seam already had:
5
+ * the writer resolved its file from `process.cwd()` while the reader resolved
6
+ * from the script's own location, so a close run inside `.claude/worktrees/<slug>/`
7
+ * wrote a tier the next selection could not see β€” and `worktree-task` then
8
+ * deleted the directory it had written to. Two answers to "which checkout" is
9
+ * one answer too many, so both sides import this.
10
+ *
11
+ * It sits apart from `state.mjs` because the CLI needs only this much. Pulling
12
+ * the whole tier computation (and `detect-missed-gate.mjs` behind it) into the
13
+ * read path would make selection depend on a module it never calls.
14
+ */
15
+
16
+ import { execFileSync } from 'node:child_process';
17
+ import { dirname } from 'node:path';
18
+
19
+ // The sanitiser is IMPORTED, never re-derived. This repo had already ruled on
20
+ // which git variables to strip, exported the answer with its stated limit, and
21
+ // built a sweep for call sites that forget it β€” and this file still shipped a
22
+ // third copy, wider and wrong, which is the "two files enforcing one invariant"
23
+ // `invariants.md` names by rule. It comes from the small shared module rather
24
+ // than from `preflight.mjs`, because a CLI script has no business on the queue's
25
+ // read path: importing one there broke every fixture that copies only `queue/`.
26
+ import { withoutGitLocation } from '../git-env.mjs';
27
+
28
+ /**
29
+ * The root of the MAIN checkout, from anywhere β€” main, a linked worktree, or a
30
+ * subdirectory of either.
31
+ *
32
+ * `--git-common-dir` is what makes this a lookup rather than a guess: inside a
33
+ * linked worktree it points at the main repository's `.git`, not the worktree's
34
+ * own gitdir.
35
+ *
36
+ * πŸ”΄ **The probe runs under `LC_ALL: 'C'`, because the catch CLASSIFIES git's
37
+ * stderr and git translates it.** Under `fr_FR` the "no repository here" message
38
+ * is *"ni ceci ni aucun de ses rΓ©pertoires parents n'est un dΓ©pΓ΄t git"*; the
39
+ * regex misses, and the legitimate fallback turns into a hard refusal from the
40
+ * queue CLI β€” in a non-git directory, for anyone whose shell is not English. CI
41
+ * runs under `C`, so CI can never see it. Note this SETS a variable rather than
42
+ * stripping one: `GIT_CONFIG_*` is untouched.
43
+ *
44
+ * **Two failures fall back to `startDir`, and only those two:** there is no
45
+ * repository above it, or git is not installed. A queue works fine in a plain
46
+ * directory, and refusing there would break every non-git use to protect
47
+ * nothing. **Every other failure is raised.** A bare catch here would turn
48
+ * *dubious ownership*, a git too old for `--path-format`, or a broken binary
49
+ * into the permissive answer β€” silently, with git's own explanation discarded.
50
+ * That is the shape this whole module exists to remove, and swallowing it one
51
+ * level up would just move it.
52
+ *
53
+ * πŸ”΄ **The child's environment is sanitised through the SHARED list.** A git
54
+ * hook exports `GIT_DIR` and `GIT_INDEX_FILE`; inherited here, they make this
55
+ * function answer confidently about a checkout the caller is not in. A hook in
56
+ * a linked worktree exports them **absolute**, so changing `cwd` does not save
57
+ * you β€” see `git-env.mjs` for the measurements. This repository has the scar:
58
+ * NOTES.md's `GIT_DIR` incident, 19 junk commits across two branches.
59
+ *
60
+ * ⚠ **Only *location* is stripped, deliberately.** A `GIT_*` prefix sweep would
61
+ * also take `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_COUNT`, which is how containers and
62
+ * CI inject `safe.directory` for a checkout owned by another uid β€” remove those
63
+ * and git exits 128 on *dubious ownership*, which under the old bare catch came
64
+ * back as the pre-fix behaviour with nothing printed.
65
+ */
66
+ export const mainCheckoutRoot = (startDir) => {
67
+ try {
68
+ const gitDir = execFileSync(
69
+ 'git',
70
+ ['rev-parse', '--path-format=absolute', '--git-common-dir'],
71
+ {
72
+ cwd: startDir,
73
+ encoding: 'utf8',
74
+ stdio: ['ignore', 'pipe', 'pipe'],
75
+ // `LC_ALL: 'C'` β€” see the note on classifying stderr, above.
76
+ env: { ...withoutGitLocation(), LC_ALL: 'C', LANGUAGE: '' },
77
+ },
78
+ ).trim();
79
+ return gitDir ? dirname(gitDir) : startDir;
80
+ } catch (error) {
81
+ if (error?.code === 'ENOENT') return startDir; // git is not installed
82
+ const stderr = String(error?.stderr ?? '');
83
+ if (/not a git repository|not a work tree/i.test(stderr)) return startDir;
84
+ throw new Error(
85
+ `could not determine the main checkout for ${startDir}: ${stderr.trim() || error?.message}`,
86
+ { cause: error },
87
+ );
88
+ }
89
+ };