create-agent-rig 0.5.0 → 0.6.1
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 +170 -34
- package/README.md +12 -6
- package/package.json +1 -1
- package/packages/cli/dist/commands/init.js +6 -3
- package/packages/cli/dist/commands/upgrade.js +2 -2
- package/packages/cli/dist/index.js +46 -8
- package/packages/cli/dist/lib/manifest.js +10 -0
- package/scripts/prepare.mjs +1 -1
- package/templates/agent-os/init/AGENTS.md +15 -5
- package/templates/agent-os/init/CLAUDE.md +15 -5
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +1 -1
- 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 +4 -1
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +330 -16
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +64 -6
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +18 -6
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +8 -1
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +42 -17
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +2 -1
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +187 -0
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +72 -65
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +33 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +13 -3
- package/templates/agent-os/universal/.claude/rules/invariants.md +45 -17
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +20 -1
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +354 -0
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +1 -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 +29 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +4 -1
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +37 -8
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -1
- package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +62 -2
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +479 -9
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +89 -15
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +282 -19
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +395 -46
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +68 -5
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +317 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +181 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +101 -3
- package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +436 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -4
- package/templates/agent-os/universal/.claude/settings.json +5 -1
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +4 -1
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +330 -16
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +64 -6
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +1 -1
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
- package/templates/agent-os/universal/.codex/hooks.json +6 -1
- package/templates/agent-os/universal/AGENTS.md +3 -1
- package/templates/agent-os/universal/CLAUDE.md +3 -1
- package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
- package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +18 -6
- package/templates/agent-os/universal/layers.json +9 -0
- package/templates/hash-history.json +404 -51
- package/templates/release-ledger.json +10 -0
- package/templates/skeleton/aws-serverless/gitignore +2 -0
- package/templates/skeleton/node-service/gitignore +2 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate coverage — did every reviewer this round asked for actually answer, and
|
|
3
|
+
* for the commit being merged?
|
|
4
|
+
*
|
|
5
|
+
* Three writers already record the three halves of a gate round, and until this
|
|
6
|
+
* module existed nothing compared them:
|
|
7
|
+
*
|
|
8
|
+
* - `decision-router.mjs` journals the set the route ASKED FOR, on the lane it
|
|
9
|
+
* actually took (`gate: "review-routing:<lane>"`, with `reviewers`);
|
|
10
|
+
* - `pr-ship` journals the set it actually LAUNCHED, with the head it launched
|
|
11
|
+
* against (`gate: "reviewer-fan-out"`);
|
|
12
|
+
* - each verdict that PARSED is journalled under the reviewer's own name.
|
|
13
|
+
*
|
|
14
|
+
* The two failures that hide in the gap between them both end in a merge that
|
|
15
|
+
* reads as fully gated: a reviewer the router named and nobody started, and a
|
|
16
|
+
* reviewer that answered — about a commit two pushes ago.
|
|
17
|
+
*
|
|
18
|
+
* 🔴 **A verdict that names no commit is not coverage of this one.** `headSha` is
|
|
19
|
+
* optional in the schema — every report written before it existed omits one — and
|
|
20
|
+
* reading absence as "it must have meant the head I am holding" is the inference
|
|
21
|
+
* the schema's own limit 6 forbids. It gets its own list rather than a pass.
|
|
22
|
+
*
|
|
23
|
+
* 🔴 **No fan-out record is not a clean round.** A run whose journal records no
|
|
24
|
+
* fan-out has not been shown to be covered, it has been shown to be unreadable —
|
|
25
|
+
* and four empty lists are exactly what a clean round looks like. That case
|
|
26
|
+
* answers `ok: false` and says why in `reason`. A missing fan-out, a missing route,
|
|
27
|
+
* an unconsumed route, a fan-out missing its head commit, and a fan-out for a different head
|
|
28
|
+
* commit are the unreadable-round answers; each reason names the evidence boundary the journal
|
|
29
|
+
* could not establish.
|
|
30
|
+
*
|
|
31
|
+
* 🔴 **A round is judged against its OWN route, not the run's last one.** The
|
|
32
|
+
* answers are scoped to the records after the last fan-out; the route needs the
|
|
33
|
+
* mirror of that, and not having it made the refusal above vacuous in the case it
|
|
34
|
+
* was written for. A run directory spans several rounds and several PRs, so an
|
|
35
|
+
* earlier round's route would otherwise satisfy a later round that journalled
|
|
36
|
+
* none — and its *set* would be inherited too, which after a `deterministic` lane
|
|
37
|
+
* means `neverLaunched` is empty for the rest of the run whatever the route asked
|
|
38
|
+
* for. So the route that counts is the last one carrying `reviewers` **between the
|
|
39
|
+
* previous fan-out and the last one**: the route that produced the fan-out being
|
|
40
|
+
* judged.
|
|
41
|
+
*
|
|
42
|
+
* 🔴 **An unconsumed route after the last fan-out is an unreadable boundary.**
|
|
43
|
+
* The journal cannot say whether this round's fan-out record is missing or the
|
|
44
|
+
* router was re-run after a completed fan-out. Both readings refuse coverage;
|
|
45
|
+
* re-running the fan-out for this head records the boundary either one needs.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** The gate name `pr-ship` writes its fan-out under. */
|
|
49
|
+
const FAN_OUT = 'reviewer-fan-out';
|
|
50
|
+
|
|
51
|
+
/** Every lane the router reports on shares this prefix; only the taken one carries a set. */
|
|
52
|
+
const ROUTING = 'review-routing:';
|
|
53
|
+
|
|
54
|
+
const gateOf = (record) => (typeof record?.gate === 'string' ? record.gate : '');
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The reviewer names in a journalled set.
|
|
58
|
+
*
|
|
59
|
+
* `recordDecision` checks for a list of strings and nothing about what a name
|
|
60
|
+
* is, so a blank one is dropped here rather than reported as a reviewer nobody
|
|
61
|
+
* can launch.
|
|
62
|
+
*/
|
|
63
|
+
const namesOf = (value) =>
|
|
64
|
+
Array.isArray(value) ? value.filter((name) => typeof name === 'string' && name.trim() !== '') : [];
|
|
65
|
+
|
|
66
|
+
/** Order-preserving, because the output is read by a human looking for a name. */
|
|
67
|
+
const uniq = (names) => [...new Set(names)];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The shortest commit id the verdict schema accepts, and the floor a prefix
|
|
71
|
+
* match needs to be worth anything (`lib/verdict.mjs`, `isCommitId`).
|
|
72
|
+
*/
|
|
73
|
+
const SHORTEST_COMMIT = 7;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The lengths a COMPLETE commit id has: sha-1 and sha-256.
|
|
77
|
+
*
|
|
78
|
+
* An abbreviation resolves against a complete id and nothing else. Without this,
|
|
79
|
+
* any strict extension of a full id prefix-matched it — a 50-character value
|
|
80
|
+
* built by appending to a 40-character commit was counted as coverage of that
|
|
81
|
+
* commit, and it passes the schema (7–64 hex), so it arrives through the fully
|
|
82
|
+
* validated path rather than as obvious junk.
|
|
83
|
+
*/
|
|
84
|
+
const COMPLETE_COMMIT_LENGTHS = new Set([40, 64]);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Are these two ids the same commit?
|
|
88
|
+
*
|
|
89
|
+
* 🔴 **Not string equality, and the difference is a false HOLD.** The schema
|
|
90
|
+
* accepts an abbreviated id, so a reviewer may answer `b4e3ef0` about the commit
|
|
91
|
+
* `git rev-parse HEAD` prints in full — the same commit, written shorter. Exact
|
|
92
|
+
* comparison reports that as answered-for-another-commit and holds the merge on
|
|
93
|
+
* honest work, which is the way a guard loses the room.
|
|
94
|
+
*
|
|
95
|
+
* So: equal ids match, and otherwise the shorter must be a prefix of a
|
|
96
|
+
* **complete** one, case-insensitively — the way git resolves an abbreviation.
|
|
97
|
+
* Two bounds, each closing one direction:
|
|
98
|
+
*
|
|
99
|
+
* - **the floor.** Both must reach `SHORTEST_COMMIT`. A shorter value is one the
|
|
100
|
+
* verdict schema refuses, and stretching it into a match would let a value
|
|
101
|
+
* nothing accepted decide that a gate was covered. ⚠ The journal is the other
|
|
102
|
+
* way in and it does not apply that schema (`run-journal.mjs` takes `headSha`
|
|
103
|
+
* as any non-blank string), so this floor is enforced here on its own account,
|
|
104
|
+
* not on the strength of an upstream check.
|
|
105
|
+
* - **the ceiling.** The longer must be a complete id. An abbreviation resolves
|
|
106
|
+
* against a whole commit; a value that merely *extends* one is not that commit
|
|
107
|
+
* written shorter, and counting it as coverage fails in the unsafe direction.
|
|
108
|
+
*
|
|
109
|
+
* One case is decided rather than left ambiguous: a 40-character value that
|
|
110
|
+
* prefixes a 64-character one matches, because the longer is complete. It could
|
|
111
|
+
* be a sha-256 abbreviated to 40 or a sha-1 with characters appended, and nothing
|
|
112
|
+
* here can resolve which — a pure function has no repository to ask. The first
|
|
113
|
+
* reading is the one an honest run produces.
|
|
114
|
+
*/
|
|
115
|
+
const sameCommit = (one, other) => {
|
|
116
|
+
if (one.length < SHORTEST_COMMIT || other.length < SHORTEST_COMMIT) return false;
|
|
117
|
+
const first = one.toLowerCase();
|
|
118
|
+
const second = other.toLowerCase();
|
|
119
|
+
if (first === second) return true;
|
|
120
|
+
const [shorter, longer] = first.length <= second.length ? [first, second] : [second, first];
|
|
121
|
+
if (!COMPLETE_COMMIT_LENGTHS.has(longer.length)) return false;
|
|
122
|
+
return longer.startsWith(shorter);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Which reviewers are outstanding for `headSha`, and in which of the four ways.
|
|
127
|
+
*
|
|
128
|
+
* @param {{ records?: unknown, headSha?: unknown }} input
|
|
129
|
+
* `records` is `readRun(...).decisions` — the run's decision journal, in
|
|
130
|
+
* journal order. `headSha` is the commit the round is about.
|
|
131
|
+
* @returns {{
|
|
132
|
+
* ok: boolean, routed: string[], launched: string[],
|
|
133
|
+
* neverLaunched: string[], unanswered: string[],
|
|
134
|
+
* unattributed: string[], stale: string[], reason?: string,
|
|
135
|
+
* }}
|
|
136
|
+
*/
|
|
137
|
+
export const coverageOf = ({ records, headSha } = {}) => {
|
|
138
|
+
const journal = Array.isArray(records) ? records : [];
|
|
139
|
+
const target = typeof headSha === 'string' ? headSha : '';
|
|
140
|
+
|
|
141
|
+
// One forward pass, keeping the LAST of each: a branch gets a second gate
|
|
142
|
+
// round after fixes, and the round's whole question is whether THIS round's
|
|
143
|
+
// reviewers answered. An earlier fan-out answers about a round already over.
|
|
144
|
+
//
|
|
145
|
+
// `pendingRoute` is what makes the route round-scoped: a routing record is held
|
|
146
|
+
// until a fan-out consumes it, and each fan-out takes only what was journalled
|
|
147
|
+
// since the previous one. Nothing survives a fan-out, so no round can inherit
|
|
148
|
+
// the round before it.
|
|
149
|
+
let pendingRoute = null;
|
|
150
|
+
let routeOfRound = null;
|
|
151
|
+
let fanOutAt = -1;
|
|
152
|
+
let launchedNames = [];
|
|
153
|
+
let fanOutHead = null;
|
|
154
|
+
|
|
155
|
+
for (let index = 0; index < journal.length; index += 1) {
|
|
156
|
+
const record = journal[index];
|
|
157
|
+
const gate = gateOf(record);
|
|
158
|
+
// Only the taken lane carries a set; a declined lane's line has no
|
|
159
|
+
// `reviewers` key at all, and reading one off it would compare the answers
|
|
160
|
+
// against a lane nobody took.
|
|
161
|
+
if (gate.startsWith(ROUTING) && Array.isArray(record?.reviewers)) {
|
|
162
|
+
// A set was RECORDED, which is a different fact from the set being empty.
|
|
163
|
+
// The `deterministic` lane legitimately routes nobody; a round that never
|
|
164
|
+
// journalled a route knows nothing about who should have been launched.
|
|
165
|
+
pendingRoute = namesOf(record.reviewers);
|
|
166
|
+
}
|
|
167
|
+
if (gate === FAN_OUT) {
|
|
168
|
+
fanOutAt = index;
|
|
169
|
+
launchedNames = namesOf(record?.reviewers);
|
|
170
|
+
fanOutHead = typeof record?.headSha === 'string' ? record.headSha : null;
|
|
171
|
+
routeOfRound = pendingRoute;
|
|
172
|
+
pendingRoute = null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (pendingRoute !== null) {
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
routed: uniq(pendingRoute),
|
|
180
|
+
launched: [],
|
|
181
|
+
neverLaunched: [],
|
|
182
|
+
unanswered: [],
|
|
183
|
+
unattributed: [],
|
|
184
|
+
stale: [],
|
|
185
|
+
reason:
|
|
186
|
+
'a routed reviewer set was journalled after the last fan-out, so the round ' +
|
|
187
|
+
'boundary is unreadable: either this round\'s fan-out record is missing, or the ' +
|
|
188
|
+
'router ran again after the fan-out. Rerun the fan-out step for this head so ' +
|
|
189
|
+
'the round it belongs to has a record.',
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const routeRecorded = routeOfRound !== null;
|
|
194
|
+
const routedNames = routeOfRound ?? [];
|
|
195
|
+
|
|
196
|
+
const routed = uniq(routedNames);
|
|
197
|
+
|
|
198
|
+
if (fanOutAt === -1) {
|
|
199
|
+
return {
|
|
200
|
+
ok: false,
|
|
201
|
+
routed,
|
|
202
|
+
launched: [],
|
|
203
|
+
neverLaunched: [],
|
|
204
|
+
unanswered: [],
|
|
205
|
+
unattributed: [],
|
|
206
|
+
stale: [],
|
|
207
|
+
reason:
|
|
208
|
+
'this run journalled no reviewer fan-out, so there is no set to check the verdicts ' +
|
|
209
|
+
'against. That is not a round with nothing outstanding — it is a round nothing can ' +
|
|
210
|
+
'read, and the two look identical from the lists alone.',
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (fanOutHead === null) {
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
routed: [],
|
|
218
|
+
launched: [],
|
|
219
|
+
neverLaunched: [],
|
|
220
|
+
unanswered: [],
|
|
221
|
+
unattributed: [],
|
|
222
|
+
stale: [],
|
|
223
|
+
reason:
|
|
224
|
+
'the last reviewer fan-out names no head commit, so it cannot be coverage of ' +
|
|
225
|
+
'this one. Rerun the fan-out step for this head and record the commit it launched.',
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!sameCommit(fanOutHead, target)) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
routed: [],
|
|
233
|
+
launched: [],
|
|
234
|
+
neverLaunched: [],
|
|
235
|
+
unanswered: [],
|
|
236
|
+
unattributed: [],
|
|
237
|
+
stale: [],
|
|
238
|
+
reason:
|
|
239
|
+
'the last reviewer fan-out names a different head commit, so its launched set ' +
|
|
240
|
+
'does not cover this one. Rerun the fan-out step for this head before reading verdicts.',
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const launched = uniq(launchedNames);
|
|
245
|
+
// Answers are the records that come AFTER the fan-out. A verdict from the
|
|
246
|
+
// previous round is still in the journal, and a reader that scanned the whole
|
|
247
|
+
// file would find an answer and report the current round covered.
|
|
248
|
+
const after = journal.slice(fanOutAt + 1);
|
|
249
|
+
|
|
250
|
+
// The lane is a floor and never a ceiling — the triggers may only ADD
|
|
251
|
+
// reviewers — so a launched set larger than the routed one is the ordinary
|
|
252
|
+
// case and is not a finding. Only the other direction is.
|
|
253
|
+
const neverLaunched = routed.filter((name) => !launched.includes(name));
|
|
254
|
+
|
|
255
|
+
const unanswered = [];
|
|
256
|
+
const unattributed = [];
|
|
257
|
+
const stale = [];
|
|
258
|
+
|
|
259
|
+
for (const name of launched) {
|
|
260
|
+
// Matched by gate name, which is what keeps another writer's record — the
|
|
261
|
+
// router's own line, `pr-ship`'s, a deploy verdict — from being read as a
|
|
262
|
+
// reviewer's answer.
|
|
263
|
+
const commits = after
|
|
264
|
+
.filter((record) => gateOf(record) === name)
|
|
265
|
+
.map((record) => record?.headSha)
|
|
266
|
+
.filter((commit) => typeof commit === 'string' && commit !== '');
|
|
267
|
+
|
|
268
|
+
const answered = after.some((record) => gateOf(record) === name);
|
|
269
|
+
if (!answered) {
|
|
270
|
+
unanswered.push(name);
|
|
271
|
+
} else if (commits.some((commit) => sameCommit(commit, target))) {
|
|
272
|
+
// Covered. A HOLD counts here exactly as a SHIP does: coverage is about
|
|
273
|
+
// who spoke for which commit, never about what they said.
|
|
274
|
+
} else if (commits.length === 0) {
|
|
275
|
+
unattributed.push(name);
|
|
276
|
+
} else {
|
|
277
|
+
stale.push(name);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const outstanding =
|
|
282
|
+
neverLaunched.length + unanswered.length + unattributed.length + stale.length;
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
ok: routeRecorded && outstanding === 0,
|
|
286
|
+
routed,
|
|
287
|
+
launched,
|
|
288
|
+
neverLaunched,
|
|
289
|
+
unanswered,
|
|
290
|
+
unattributed,
|
|
291
|
+
stale,
|
|
292
|
+
// Unlike the missing fan-out, this one still has a launched set and answers
|
|
293
|
+
// to compare, so the four lists are computed and returned — the reason says
|
|
294
|
+
// which half of the check could not run, rather than replacing the half that
|
|
295
|
+
// could.
|
|
296
|
+
...(routeRecorded
|
|
297
|
+
? {}
|
|
298
|
+
: {
|
|
299
|
+
reason:
|
|
300
|
+
'this round journalled no routed reviewer set, so `neverLaunched` was compared ' +
|
|
301
|
+
'against nothing: a reviewer the route asked for and nobody launched would not ' +
|
|
302
|
+
'appear. An empty route that WAS recorded is a different answer, and so is a ' +
|
|
303
|
+
'route belonging to an earlier round — neither is this one.',
|
|
304
|
+
}),
|
|
305
|
+
};
|
|
306
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The revalidation points — ONE spelling of the fact (AR-137).
|
|
3
|
+
* All upstream test pointers in this script name the generator suite, absent in a generated rig.
|
|
4
|
+
*
|
|
5
|
+
* `queue/index.mjs next` writes the SELECT record; `revalidate.mjs` writes the
|
|
6
|
+
* other two and answers outcomes at all three; `revalidation-report.mjs`
|
|
7
|
+
* counts all three. Before this module each carried its own list, and one
|
|
8
|
+
* computed a fourth (`['SELECT', ...POINTS]`). Four spellings of one fact is
|
|
9
|
+
* how a point gets added to the report and not to the script, silently.
|
|
10
|
+
*
|
|
11
|
+
* `REVALIDATES` is DERIVED, not restated: the asymmetry that SELECT is written
|
|
12
|
+
* elsewhere is encoded here once, where a reader looks for it.
|
|
13
|
+
*
|
|
14
|
+
* The prose that names these points is kept in step by a two-direction check —
|
|
15
|
+
* the generator's test/template/correspondence.test.ts › "every point the
|
|
16
|
+
* module knows is named by the loop or pr-ship skill, and vice versa".
|
|
17
|
+
*
|
|
18
|
+
* Maintenance and noise cost, recorded (AR-137): adding a point touches this
|
|
19
|
+
* list and the prose that names it as `--point X` or `point: X`, and the check
|
|
20
|
+
* says which side was forgotten. The check reads only the `loop` and `pr-ship`
|
|
21
|
+
* skills — a point named anywhere else is invisible to it — and it parses
|
|
22
|
+
* those two by that exact spelling, so a rewording breaks it loudly, as a named
|
|
23
|
+
* failure, never silently.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const POINTS = Object.freeze(['SELECT', 'BEFORE_PR', 'BEFORE_CLOSE']);
|
|
27
|
+
|
|
28
|
+
/** What `revalidate.mjs` can revalidate itself: every point but SELECT. */
|
|
29
|
+
export const REVALIDATES = Object.freeze(POINTS.filter((point) => point !== 'SELECT'));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The credential vocabulary, decided once, so that everything which refuses a
|
|
2
2
|
// credential refuses the same set.
|
|
3
|
+
// All upstream test pointers in this script name the generator suite, absent in a generated rig.
|
|
3
4
|
//
|
|
4
5
|
// Every layer that refuses one reads THIS module — which layers exist is a
|
|
5
6
|
// question about the project, not about this file. A freshly generated rig has
|
|
@@ -18,7 +19,9 @@
|
|
|
18
19
|
// GENERATOR this rig came from, not in this repository — the same arrangement
|
|
19
20
|
// `.claude/rules/invariants.md` describes for the hooks themselves under "About
|
|
20
21
|
// the hooks you were given". They are where a claim is proven, not cover you
|
|
21
|
-
// have here.
|
|
22
|
+
// have here. A manifest-backed generator upgrade remains inherited while
|
|
23
|
+
// `.claude/.rig-manifest.json` matches; once the hash differs, the local test is
|
|
24
|
+
// yours.
|
|
22
25
|
//
|
|
23
26
|
// It answers two different questions, and keeping them apart matters:
|
|
24
27
|
//
|
|
@@ -70,7 +70,9 @@
|
|
|
70
70
|
* one.** A caller that read absence as "the head I am holding" would take a
|
|
71
71
|
* verdict about a diff nobody named as a verdict about the diff it is about
|
|
72
72
|
* to merge, so a caller that needs the answer keyed to a commit handles
|
|
73
|
-
* absence itself.
|
|
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`.
|
|
74
76
|
*/
|
|
75
77
|
|
|
76
78
|
/** Every word any gate in this rulebook may return. */
|
|
@@ -166,11 +168,14 @@ const DIAGNOSIS_LIMIT = 120;
|
|
|
166
168
|
* worst they buy is a misread within those backticks. Escape sequences are the ones
|
|
167
169
|
* that rewrite the operator's screen, and those are what this removes.
|
|
168
170
|
*
|
|
169
|
-
* ⚠ **
|
|
170
|
-
* subcommand are printed exactly as the caller wrote
|
|
171
|
-
* is to be pasted back into a command
|
|
172
|
-
* because it is printed beside a
|
|
173
|
-
*
|
|
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.
|
|
174
179
|
*
|
|
175
180
|
* It never throws — it is called only where something has already gone wrong, and a
|
|
176
181
|
* sanitiser that throws there turns a diagnosis into a crash the caller reads as
|
|
@@ -210,6 +215,29 @@ const isPlainObject = (value) =>
|
|
|
210
215
|
|
|
211
216
|
const isText = (value) => typeof value === 'string' && value.trim() !== '';
|
|
212
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
|
+
|
|
213
241
|
/**
|
|
214
242
|
* The last fenced ```json block, as one of three answers: `{ raw }` for a block
|
|
215
243
|
* whose extent is unambiguous, `{ ambiguous: true }` for one carrying a fence of
|
|
@@ -398,10 +426,11 @@ export function parseVerdict(text) {
|
|
|
398
426
|
}
|
|
399
427
|
|
|
400
428
|
const headSha = parsed.headSha;
|
|
401
|
-
if (headSha !== undefined && !
|
|
429
|
+
if (headSha !== undefined && !isCommitId(headSha)) {
|
|
402
430
|
problems.push(
|
|
403
431
|
`\`headSha\` is \`${safeForDiagnosis(headSha)}\`, which is not a commit this verdict ` +
|
|
404
|
-
'could have answered for. It is optional, and
|
|
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.',
|
|
405
434
|
);
|
|
406
435
|
}
|
|
407
436
|
|
|
@@ -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
|
|
@@ -95,6 +95,31 @@ export const checkDefaultBranchFresh = () => {
|
|
|
95
95
|
}
|
|
96
96
|
};
|
|
97
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
|
+
|
|
98
123
|
/** The last deploy must have concluded successfully — never start on a broken runtime. */
|
|
99
124
|
export const checkLastDeploy = ({ workflow = 'deploy' } = {}) => {
|
|
100
125
|
try {
|
|
@@ -172,6 +197,7 @@ const invokedDirectly = () => {
|
|
|
172
197
|
if (invokedDirectly()) {
|
|
173
198
|
const checks = {
|
|
174
199
|
killSwitch: checkKillSwitch(),
|
|
200
|
+
runDirNotExported: checkRunDirNotExported(),
|
|
175
201
|
defaultBranchFresh: checkDefaultBranchFresh(),
|
|
176
202
|
lastDeploy: checkLastDeploy(),
|
|
177
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;
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { execFileSync } from 'node:child_process';
|
|
17
|
-
import { dirname } from 'node:path';
|
|
17
|
+
import { dirname, resolve } from 'node:path';
|
|
18
18
|
|
|
19
19
|
// The sanitiser is IMPORTED, never re-derived. This repo had already ruled on
|
|
20
20
|
// which git variables to strip, exported the answer with its stated limit, and
|
|
@@ -76,7 +76,9 @@ export const mainCheckoutRoot = (startDir) => {
|
|
|
76
76
|
env: { ...withoutGitLocation(), LC_ALL: 'C', LANGUAGE: '' },
|
|
77
77
|
},
|
|
78
78
|
).trim();
|
|
79
|
-
|
|
79
|
+
// `resolve`, because git answers with forward slashes on Windows
|
|
80
|
+
// (`C:/Users/...`) and every caller compares against a native path.
|
|
81
|
+
return gitDir ? resolve(dirname(gitDir)) : startDir;
|
|
80
82
|
} catch (error) {
|
|
81
83
|
if (error?.code === 'ENOENT') return startDir; // git is not installed
|
|
82
84
|
const stderr = String(error?.stderr ?? '');
|
|
@@ -87,3 +89,61 @@ export const mainCheckoutRoot = (startDir) => {
|
|
|
87
89
|
);
|
|
88
90
|
}
|
|
89
91
|
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Is this checkout in a state a gate round may be counted against (AR-141)?
|
|
95
|
+
*
|
|
96
|
+
* A round is counted per branch and the fan-out's verdicts name a head. On one
|
|
97
|
+
* branch two rounds were counted before a commit that pre-commit then refused,
|
|
98
|
+
* so the counter and the records named a head that never shipped. The three
|
|
99
|
+
* states that make a head unshippable are decidable from git alone: a dirty
|
|
100
|
+
* working tree (tracked or untracked), a branch with no upstream, and commits
|
|
101
|
+
* the upstream has not seen. `{ ok: true }` otherwise; a git that cannot answer
|
|
102
|
+
* is reported as such, never as clean.
|
|
103
|
+
*
|
|
104
|
+
* Bounded: two git calls, each with a timeout, and the porcelain output is
|
|
105
|
+
* read only for emptiness. The upstream question has one catch for three
|
|
106
|
+
* states — a detached HEAD, a branch with no upstream, an upstream that is
|
|
107
|
+
* gone — and names none of them apart; the refusal is right for all three.
|
|
108
|
+
*/
|
|
109
|
+
export const checkoutIsShippable = (root) => {
|
|
110
|
+
const git = (args) =>
|
|
111
|
+
execFileSync('git', args, {
|
|
112
|
+
cwd: root,
|
|
113
|
+
encoding: 'utf8',
|
|
114
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
115
|
+
env: withoutGitLocation(),
|
|
116
|
+
timeout: 30_000,
|
|
117
|
+
}).trim();
|
|
118
|
+
let status;
|
|
119
|
+
try {
|
|
120
|
+
status = git(['status', '--porcelain']);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
return { ok: false, why: `git could not read the working tree at ${root}: ${error.message}` };
|
|
123
|
+
}
|
|
124
|
+
if (status !== '') {
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
why: 'the working tree is dirty — a round counted now would name a head that has not ' +
|
|
128
|
+
'been committed; commit (and push) first',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
let ahead;
|
|
132
|
+
try {
|
|
133
|
+
ahead = git(['rev-list', '--count', '@{upstream}..HEAD']);
|
|
134
|
+
} catch {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
why: 'HEAD has no upstream — push the branch first, so the round names a head the ' +
|
|
138
|
+
'reviewers and CI can see',
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (ahead !== '0') {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
why: `HEAD is ${ahead} commit(s) ahead of its upstream — push first, so the round names ` +
|
|
145
|
+
'the head that ships',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return { ok: true };
|
|
149
|
+
};
|