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.
- package/CHANGELOG.md +207 -8
- package/README.md +50 -17
- package/package.json +2 -1
- package/packages/cli/dist/commands/create.js +8 -3
- package/packages/cli/dist/commands/init.js +82 -34
- package/packages/cli/dist/commands/upgrade.js +112 -30
- package/packages/cli/dist/index.js +38 -14
- package/packages/cli/dist/lib/copy-tree.js +35 -6
- package/packages/cli/dist/lib/init-settings.js +12 -0
- package/packages/cli/dist/lib/install-set.js +6 -8
- package/packages/cli/dist/lib/manifest.js +21 -9
- package/packages/cli/dist/lib/safe-path.js +30 -0
- package/templates/agent-os/init/AGENTS.md +191 -0
- package/templates/agent-os/init/CLAUDE.md +61 -9
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +98 -0
- package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +34 -3
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +34 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +233 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +676 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +338 -0
- package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +33 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +64 -1
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +33 -0
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +274 -24
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +480 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +61 -1
- package/templates/agent-os/universal/.claude/rules/invariants.md +71 -16
- package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1242 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +433 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +6 -16
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +89 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +334 -23
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +7 -2
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +413 -8
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +75 -13
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +222 -21
- package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
- package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +441 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -0
- package/templates/agent-os/universal/.claude/settings.json +7 -2
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +115 -7
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +378 -31
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +300 -25
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
- package/templates/agent-os/universal/.codex/hooks.json +65 -0
- package/templates/agent-os/universal/AGENTS.md +164 -0
- package/templates/agent-os/universal/CLAUDE.md +47 -14
- package/templates/agent-os/universal/PLAN.md +7 -40
- package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
- package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
- package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
- package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
- package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
- package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
- package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +62 -0
- package/templates/agent-os/universal/journal/README.md +101 -0
- package/templates/agent-os/universal/layers.json +36 -2
- package/templates/hash-history.json +2 -1
- package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
- package/templates/skeleton/aws-serverless/README.md +91 -9
- package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
- package/templates/skeleton/aws-serverless/gitignore +37 -0
- package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
- package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
- package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
- package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
- package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
- package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
- package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
- package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
- package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
- package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
- package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
- package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
- package/templates/skeleton/node-service/README.md +11 -1
- package/templates/skeleton/node-service/gitignore +34 -0
- package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
- package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
- package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
- package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
- package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
- package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
- 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
|
|
48
|
-
|
|
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
|
+
};
|