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.
- package/CHANGELOG.md +316 -11
- package/README.md +58 -19
- package/package.json +2 -1
- package/packages/cli/dist/commands/create.js +8 -3
- package/packages/cli/dist/commands/init.js +85 -34
- package/packages/cli/dist/commands/upgrade.js +112 -30
- package/packages/cli/dist/index.js +81 -19
- 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 +31 -9
- package/packages/cli/dist/lib/safe-path.js +30 -0
- package/scripts/prepare.mjs +1 -1
- package/templates/agent-os/init/AGENTS.md +199 -0
- package/templates/agent-os/init/CLAUDE.md +69 -9
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +105 -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 +41 -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 +41 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +236 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +958 -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 +396 -0
- package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +40 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +71 -1
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +40 -0
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +301 -26
- 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-rulebook.mjs +127 -0
- 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 +503 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +69 -1
- package/templates/agent-os/universal/.claude/rules/invariants.md +104 -19
- package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1260 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
- package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
- package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +462 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +33 -17
- package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +149 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +809 -28
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +95 -16
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +543 -15
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +457 -47
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +289 -25
- package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
- package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +539 -0
- package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +198 -0
- package/templates/agent-os/universal/.claude/settings.json +11 -2
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +118 -7
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +667 -38
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +359 -26
- 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 +70 -0
- package/templates/agent-os/universal/AGENTS.md +166 -0
- package/templates/agent-os/universal/CLAUDE.md +49 -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/gate-coverage.md +83 -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 +74 -0
- package/templates/agent-os/universal/journal/README.md +101 -0
- package/templates/agent-os/universal/layers.json +45 -2
- package/templates/hash-history.json +310 -49
- package/templates/release-ledger.json +9 -0
- 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,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
|
|
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
|
|
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, {
|
|
@@ -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;
|