dev-loops 0.7.2 → 0.8.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/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +1 -1
- package/.claude/agents/refiner.md +2 -2
- package/.claude/agents/review.md +9 -12
- package/.claude/commands/loop-continue.md +2 -1
- package/.claude/commands/loop-enqueue.md +8 -1
- package/.claude/commands/loop-info.md +1 -1
- package/.claude/hooks/_bash-command-classify.mjs +7 -6
- package/.claude/hooks/_hook-decisions.mjs +5 -4
- package/.claude/skills/copilot-pr-followup/SKILL.md +5 -5
- package/.claude/skills/dev-loop/SKILL.md +7 -7
- package/.claude/skills/docs/acceptance-criteria-verification.md +12 -4
- package/.claude/skills/docs/anti-patterns.md +3 -3
- package/.claude/skills/docs/artifact-authority-contract.md +6 -4
- package/.claude/skills/docs/confirmation-rules.md +1 -1
- package/.claude/skills/docs/copilot-loop-operations.md +4 -2
- package/.claude/skills/docs/issue-intake-procedure.md +12 -3
- package/.claude/skills/docs/merge-preconditions.md +5 -3
- package/.claude/skills/docs/pr-lifecycle-contract.md +1 -1
- package/.claude/skills/docs/public-dev-loop-contract.md +2 -1
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +9 -6
- package/.claude/skills/docs/validation-policy.md +1 -1
- package/.claude/skills/local-implementation/SKILL.md +3 -3
- package/.claude/skills/loop-grill/SKILL.md +34 -14
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +30 -0
- package/README.md +95 -189
- package/agents/refiner.agent.md +2 -2
- package/agents/review.agent.md +9 -12
- package/extension/README.md +5 -4
- package/package.json +5 -4
- package/scripts/docs/validate-state-machine-conformance.mjs +78 -1
- package/scripts/github/_gate-names.mjs +5 -0
- package/scripts/github/capture-review-threads.mjs +2 -2
- package/scripts/github/detect-checkpoint-evidence.mjs +7 -7
- package/scripts/github/edit-issue.mjs +259 -0
- package/scripts/github/probe-ci-status.mjs +18 -0
- package/scripts/github/probe-copilot-review.mjs +24 -3
- package/scripts/github/reconcile-draft-gate.mjs +13 -13
- package/scripts/github/request-copilot-review.mjs +17 -16
- package/scripts/github/upsert-checkpoint-verdict.mjs +25 -23
- package/scripts/github/verify-briefing-prefixes.mjs +224 -33
- package/scripts/github/write-gate-context.mjs +8 -4
- package/scripts/loop/_post-convergence-change.mjs +2 -2
- package/scripts/loop/_pr-runner-coordination.mjs +112 -13
- package/scripts/loop/check-retro-tooling.mjs +14 -9
- package/scripts/loop/copilot-pr-handoff.mjs +18 -14
- package/scripts/loop/detect-copilot-loop-state.mjs +11 -11
- package/scripts/loop/detect-internal-only-pr.mjs +6 -6
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +117 -15
- package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
- package/scripts/loop/run-watch-cycle.mjs +42 -7
- package/scripts/loop/sanctioned-commands.mjs +1 -0
- package/scripts/pages/build-state-atlas.mjs +15 -0
- package/scripts/projects/add-queue-item.mjs +87 -4
- package/skills/copilot-pr-followup/SKILL.md +5 -5
- package/skills/dev-loop/SKILL.md +2 -2
- package/skills/docs/acceptance-criteria-verification.md +12 -4
- package/skills/docs/anti-patterns.md +3 -3
- package/skills/docs/artifact-authority-contract.md +6 -4
- package/skills/docs/confirmation-rules.md +1 -1
- package/skills/docs/copilot-loop-operations.md +4 -2
- package/skills/docs/issue-intake-procedure.md +12 -3
- package/skills/docs/merge-preconditions.md +5 -3
- package/skills/docs/pr-lifecycle-contract.md +1 -1
- package/skills/docs/public-dev-loop-contract.md +2 -1
- package/skills/docs/required-rules.json +17 -1
- package/skills/docs/retrospective-checkpoint-contract.md +9 -6
- package/skills/docs/validation-policy.md +1 -1
- package/skills/local-implementation/SKILL.md +3 -3
- package/skills/loop-grill/SKILL.md +38 -17
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
5
6
|
import { JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
7
|
+
import { GATE_NAMES } from "./_gate-names.mjs";
|
|
6
8
|
|
|
7
9
|
const USAGE = `Usage: verify-briefing-prefixes.mjs --head-sha <sha> [--help]
|
|
8
10
|
Fan-in enforcement for GATE-EXEC-BRIEFING-PREFIX (docs/gate-review-sub-loop-contract.md):
|
|
9
|
-
fails closed when
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
fails closed when a reviewer sentinel's (written by verify-fresh-review-context.mjs
|
|
12
|
+
--prefix-hash/--prefix-file) recorded prefix hash matches no on-disk per-gate
|
|
13
|
+
briefing-prefix record for this head, matches a DIFFERENT gate than the sentinel's
|
|
14
|
+
scope declares, or is missing outright. When no per-gate records exist it falls
|
|
15
|
+
back to requiring all of this round's sentinels to share ONE hash. Deterministic
|
|
16
|
+
and offline: reads only the sentinel and record files already on disk under tmp/,
|
|
17
|
+
keyed by the given head SHA.
|
|
13
18
|
|
|
14
19
|
Required:
|
|
15
20
|
--head-sha <sha> The FULL 40-char reviewed head SHA (git rev-parse HEAD); sentinels are read from
|
|
@@ -17,25 +22,43 @@ Required:
|
|
|
17
22
|
|
|
18
23
|
Output (stdout, JSON):
|
|
19
24
|
{ "ok": true, "verified": true, "headSha": "...", "reviewerCount": <n>, "prefixHash": "..." }
|
|
25
|
+
{ "ok": true, "verified": true, "headSha": "...", "reviewerCount": <n>, "prefixHash": "...", "gates": [{ "gate": "draft_gate", "prefixHash": "...", "reviewerCount": <n> }] }
|
|
26
|
+
{ "ok": true, "verified": true, "headSha": "...", "reviewerCount": <n>, "gates": [{ "gate": "draft_gate", "prefixHash": "...", "reviewerCount": <n> }, { "gate": "pre_approval_gate", "prefixHash": "...", "reviewerCount": <n> }] }
|
|
20
27
|
{ "ok": true, "verified": true, "headSha": "...", "reviewerCount": 0, "reason": "no sentinels found for this round" }
|
|
21
28
|
{ "ok": true, "verified": false, "headSha": "...", "reviewerCount": <n>, "reason": "...", "missing": [...], "mismatched": [...] }
|
|
22
29
|
On error (stderr, JSON):
|
|
23
30
|
{ "ok": false, "error": "...", "usage": "..." }
|
|
24
31
|
${JQ_OUTPUT_USAGE}
|
|
25
32
|
Exit codes:
|
|
26
|
-
0 Verified: no sentinels found for this round (nothing to check),
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
hash
|
|
33
|
+
0 Verified: no sentinels found for this round (nothing to check); OR, with
|
|
34
|
+
on-disk per-gate briefing records present, every sentinel's hash matches a
|
|
35
|
+
record AND matches the gate its scope declares (two gates reviewed at one
|
|
36
|
+
head each match their own record, so a verified round can involve
|
|
37
|
+
different per-gate hashes); OR, with no records, all sentinels share one
|
|
38
|
+
identical hash
|
|
39
|
+
1 Fail closed: a sentinel hash matches no on-disk gate record, or matches a
|
|
40
|
+
DIFFERENT gate than its scope declares (wrong-gate briefing), or any
|
|
41
|
+
sentinel records no prefix hash — never grandfathered; or two or more
|
|
42
|
+
sentinels attributed to the SAME gate recorded DIFFERENT prefix hashes
|
|
43
|
+
(within-gate briefings were not byte-identical); or, with no records,
|
|
44
|
+
two or more sentinels record different hashes
|
|
32
45
|
2 Usage or internal error, or invalid --jq filter
|
|
33
46
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
47
|
+
Gate scoping: each reviewer sentinel's recorded prefix hash is verified against
|
|
48
|
+
the on-disk per-gate briefing-prefix records (<gate>-<headSha>.briefing-prefix.txt
|
|
49
|
+
under tmp/gate-context/, written by write-gate-context.mjs). Two gates reviewed
|
|
50
|
+
at the SAME head (e.g. a small change clearing draft_gate and pre_approval_gate
|
|
51
|
+
without an intervening push) each verify against their own record instead of
|
|
52
|
+
colliding into a spurious mismatch, and a hash matching no record fails closed.
|
|
53
|
+
A sentinel whose scope self-declares a gate (e.g. "draft-gate-coverage") must
|
|
54
|
+
also match THAT gate's record — a hash that matches a DIFFERENT gate's record
|
|
55
|
+
is a wrong-gate briefing and fails closed even though the hash itself is known.
|
|
56
|
+
Beyond per-sentinel matching, all reviewers attributed to ONE gate must share
|
|
57
|
+
ONE identical prefix hash — two DIFFERENT hashes for the same gate fails closed
|
|
58
|
+
as a within-gate byte-identity violation, even when each hash individually
|
|
59
|
+
matches a known record. When no records are present the check falls back to
|
|
60
|
+
the conservative flat rule (all sentinels must share one hash).
|
|
61
|
+
Never manually clear sentinels.`.trim();
|
|
39
62
|
|
|
40
63
|
// Full 40-char SHA required: sentinel filenames embed the full `git rev-parse
|
|
41
64
|
// HEAD` value, so a short prefix would glob zero sentinels and read as a
|
|
@@ -106,21 +129,117 @@ async function readRoundSentinels(tmpRoot, headSha) {
|
|
|
106
129
|
return results;
|
|
107
130
|
}
|
|
108
131
|
|
|
132
|
+
const BRIEFING_PREFIX_SUFFIX = ".briefing-prefix.txt";
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Read the per-gate invariant-briefing records for this head from
|
|
136
|
+
* `<tmpRoot>/gate-context/**` (written by write-gate-context.mjs as
|
|
137
|
+
* `<gate>-<headSha>.briefing-prefix.txt`). Returns a Map from the record's
|
|
138
|
+
* sha256 (the exact bytes reviewers hash via `--prefix-file`) to the set of
|
|
139
|
+
* gate names whose record has that exact hash (normally one gate; a shared
|
|
140
|
+
* hash across gates is possible in principle and must not false-fail the
|
|
141
|
+
* wrong-gate check). These records are the authoritative per-(gate, headSha)
|
|
142
|
+
* proof each reviewer sentinel is verified against — a sentinel hash that
|
|
143
|
+
* matches no record is a contaminated/stale briefing. Empty Map when none
|
|
144
|
+
* exist (offline/legacy), which routes evaluation to the conservative flat
|
|
145
|
+
* fallback.
|
|
146
|
+
* @param {string} tmpRoot
|
|
147
|
+
* @param {string} headSha — lowercase hex, already validated
|
|
148
|
+
* @returns {Promise<Map<string, Set<string>>>} sha256 -> set of gates
|
|
149
|
+
*/
|
|
150
|
+
async function readGateBriefingRecords(tmpRoot, headSha) {
|
|
151
|
+
const root = path.join(tmpRoot, "gate-context");
|
|
152
|
+
let entries;
|
|
153
|
+
try {
|
|
154
|
+
entries = await readdir(root, { withFileTypes: true, recursive: true });
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if (err.code === "ENOENT") return new Map();
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
const suffix = `-${headSha}${BRIEFING_PREFIX_SUFFIX}`;
|
|
160
|
+
const records = new Map();
|
|
161
|
+
const matches = entries
|
|
162
|
+
.filter((e) => e.isFile() && e.name.endsWith(suffix) && e.name.length > suffix.length)
|
|
163
|
+
// readdir order is filesystem-dependent; sort by name so the hash->Set<gate>
|
|
164
|
+
// index is built in a deterministic order across runs.
|
|
165
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
166
|
+
for (const e of matches) {
|
|
167
|
+
const gate = e.name.slice(0, -suffix.length);
|
|
168
|
+
// Only canonical gate records are trusted: a stray/leftover file whose
|
|
169
|
+
// prefix isn't a real gate name must not be able to satisfy record-matching.
|
|
170
|
+
if (!GATE_NAMES.includes(gate)) continue;
|
|
171
|
+
const dir = e.parentPath ?? root;
|
|
172
|
+
let bytes;
|
|
173
|
+
try {
|
|
174
|
+
bytes = await readFile(path.join(dir, e.name));
|
|
175
|
+
} catch {
|
|
176
|
+
continue; // unreadable record: skip; a sentinel relying on it fails closed as unknown
|
|
177
|
+
}
|
|
178
|
+
const hash = createHash("sha256").update(bytes).digest("hex");
|
|
179
|
+
if (!records.has(hash)) records.set(hash, new Set());
|
|
180
|
+
records.get(hash).add(gate);
|
|
181
|
+
}
|
|
182
|
+
return records;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Gate a reviewer scope self-declares, matched against the canonical gate
|
|
187
|
+
* vocabulary (hyphenated to the `--scope` form, since scopes forbid
|
|
188
|
+
* underscores). Matching is case-insensitive (`--scope` permits mixed case;
|
|
189
|
+
* a mis-cased scope must still be attributed to its gate rather than falling
|
|
190
|
+
* through to the bare-scope path and bypassing the wrong-gate check). Uses
|
|
191
|
+
* the LONGEST matching prefix so a future gate whose name string-extends
|
|
192
|
+
* another is attributed correctly. Returns null for a bare/legacy scope with
|
|
193
|
+
* no recognized gate prefix — those are matched by hash alone.
|
|
194
|
+
* Exported for direct testing.
|
|
195
|
+
* @param {string} scope
|
|
196
|
+
* @param {string[]} [gateNames]
|
|
197
|
+
* @returns {string|null}
|
|
198
|
+
*/
|
|
199
|
+
export function declaredGateOf(scope, gateNames = GATE_NAMES) {
|
|
200
|
+
const s = scope.toLowerCase();
|
|
201
|
+
let best = null;
|
|
202
|
+
let bestLen = -1;
|
|
203
|
+
for (const gate of gateNames) {
|
|
204
|
+
const prefix = gate.replace(/_/g, "-");
|
|
205
|
+
if ((s === prefix || s.startsWith(`${prefix}-`)) && prefix.length > bestLen) {
|
|
206
|
+
best = gate;
|
|
207
|
+
bestLen = prefix.length;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return best;
|
|
211
|
+
}
|
|
212
|
+
|
|
109
213
|
/**
|
|
110
|
-
* Pure comparison:
|
|
214
|
+
* Pure comparison: decide verified/reason/missing/mismatched for the round.
|
|
111
215
|
* Exported for direct unit testing without touching the filesystem.
|
|
216
|
+
*
|
|
217
|
+
* When `gateRecords` (sha256 -> Set<gate>, from the on-disk `<gate>-<headSha>`
|
|
218
|
+
* briefing-prefix records) is non-empty, EVERY sentinel's recorded prefix hash
|
|
219
|
+
* must match one of those authoritative records; a hash matching none is a
|
|
220
|
+
* contaminated/stale briefing and fails closed. This is what lets two gates
|
|
221
|
+
* legitimately reviewed at ONE head both pass — each verifies against its own
|
|
222
|
+
* gate's record — without a spurious cross-gate mismatch. When a sentinel's
|
|
223
|
+
* scope self-declares a gate (against the canonical GATE_NAMES vocabulary),
|
|
224
|
+
* its matched record's gate set must contain that SAME gate: a known hash that
|
|
225
|
+
* instead belongs only to a different gate's record fails closed as a
|
|
226
|
+
* wrong-gate briefing (the fix for the false fail-closed AND the fail-open the
|
|
227
|
+
* scope-prefix approach would have introduced). Beyond per-sentinel matching,
|
|
228
|
+
* every reviewer attributed to the SAME gate must share ONE identical prefix
|
|
229
|
+
* hash — two distinct hashes for one gate fails closed as a within-gate
|
|
230
|
+
* byte-identity violation (AC2), even though each hash individually matches a
|
|
231
|
+
* known record for that gate. When no records exist (offline/legacy) it falls
|
|
232
|
+
* back to the conservative flat check: all sentinels must share one hash.
|
|
233
|
+
* A missing/hashless sentinel always fails closed (never grandfathered).
|
|
234
|
+
*
|
|
112
235
|
* @param {Array<{ scope: string, prefixHash: string|null }>} sentinels
|
|
113
|
-
* @
|
|
236
|
+
* @param {Map<string, Set<string>>|null} [gateRecords] — sha256 -> set of gates
|
|
237
|
+
* @returns {{ verified: boolean, reason?: string, missing?: string[], mismatched?: Array<{scope:string, prefixHash:string}>, prefixHash?: string, gates?: Array<{gate:string, prefixHash:string, reviewerCount:number}> }}
|
|
114
238
|
*/
|
|
115
|
-
export function evaluateBriefingPrefixes(sentinels) {
|
|
239
|
+
export function evaluateBriefingPrefixes(sentinels, gateRecords = null) {
|
|
116
240
|
if (sentinels.length === 0) {
|
|
117
241
|
return { verified: true, reason: "no sentinels found for this round" };
|
|
118
242
|
}
|
|
119
|
-
// A hashless sentinel fails closed even when it is the ONLY sentinel (a
|
|
120
|
-
// one-angle Phase-5 retry round is a real case): "never grandfathered" means
|
|
121
|
-
// the invariant-prefix proof must exist for every reviewer, not just when a
|
|
122
|
-
// sibling exists to compare against. A single HASHED sentinel stays verified —
|
|
123
|
-
// with one recorded hash there is nothing to mismatch.
|
|
124
243
|
const missing = sentinels.filter((s) => s.prefixHash === null).map((s) => s.scope);
|
|
125
244
|
if (missing.length > 0) {
|
|
126
245
|
return {
|
|
@@ -129,20 +248,90 @@ export function evaluateBriefingPrefixes(sentinels) {
|
|
|
129
248
|
missing,
|
|
130
249
|
};
|
|
131
250
|
}
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
251
|
+
const records = gateRecords instanceof Map ? gateRecords : new Map();
|
|
252
|
+
if (records.size === 0) {
|
|
253
|
+
// Flat fallback (pre-record behavior, conservative fail-closed): with no
|
|
254
|
+
// authoritative records to attribute sentinels to gates, all sentinels must
|
|
255
|
+
// record ONE identical hash. Two distinct hashes fail closed.
|
|
256
|
+
const distinct = new Map();
|
|
257
|
+
for (const { scope, prefixHash } of sentinels) {
|
|
258
|
+
if (!distinct.has(prefixHash)) distinct.set(prefixHash, []);
|
|
259
|
+
distinct.get(prefixHash).push(scope);
|
|
260
|
+
}
|
|
261
|
+
if (distinct.size > 1) {
|
|
262
|
+
return {
|
|
263
|
+
verified: false,
|
|
264
|
+
reason: `Reviewer sentinels for this round recorded ${distinct.size} DIFFERENT invariant-briefing prefix hashes and no on-disk gate briefing-prefix records were found to attribute them per gate — the seeded briefings were not byte-identical (GATE-EXEC-BRIEFING-PREFIX).`,
|
|
265
|
+
mismatched: sentinels.map(({ scope, prefixHash }) => ({ scope, prefixHash })),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
return { verified: true, prefixHash: sentinels[0].prefixHash };
|
|
136
269
|
}
|
|
137
|
-
|
|
138
|
-
|
|
270
|
+
// Record-matching: every sentinel hash must match an on-disk record; a scope
|
|
271
|
+
// that declares a gate must match a record for THAT gate; and all reviewers
|
|
272
|
+
// attributed to one gate must share ONE hash (within-gate byte-identity, AC2).
|
|
273
|
+
const unknown = sentinels.filter((s) => !records.has(s.prefixHash));
|
|
274
|
+
if (unknown.length > 0) {
|
|
139
275
|
return {
|
|
140
276
|
verified: false,
|
|
141
|
-
reason:
|
|
142
|
-
mismatched,
|
|
277
|
+
reason: `${unknown.length} of ${sentinels.length} reviewer sentinel(s) recorded an invariant-briefing prefix hash that matches no gate briefing-prefix record for this head — a contaminated, stale, or hand-edited briefing (GATE-EXEC-BRIEFING-PREFIX). Never grandfathered.`,
|
|
278
|
+
mismatched: unknown.map(({ scope, prefixHash }) => ({ scope, prefixHash })),
|
|
143
279
|
};
|
|
144
280
|
}
|
|
145
|
-
|
|
281
|
+
// Attribute each sentinel to a gate and detect wrong-gate briefings.
|
|
282
|
+
const wrongGate = [];
|
|
283
|
+
const attributed = []; // { scope, prefixHash, gate }
|
|
284
|
+
for (const s of sentinels) {
|
|
285
|
+
const gatesForHash = records.get(s.prefixHash); // Set<gate>, non-empty
|
|
286
|
+
const declared = declaredGateOf(s.scope);
|
|
287
|
+
if (declared !== null) {
|
|
288
|
+
if (!gatesForHash.has(declared)) {
|
|
289
|
+
wrongGate.push({ scope: s.scope, prefixHash: s.prefixHash });
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
attributed.push({ scope: s.scope, prefixHash: s.prefixHash, gate: declared });
|
|
293
|
+
} else {
|
|
294
|
+
// Bare/legacy scope: attribute by the record's gate. A hash mapping to
|
|
295
|
+
// multiple gates (byte-identical briefings across gates — practically
|
|
296
|
+
// impossible since the gate is embedded in the hashed bytes) is resolved
|
|
297
|
+
// deterministically to the alphabetically-first gate for a stable summary.
|
|
298
|
+
const gate = gatesForHash.size === 1 ? [...gatesForHash][0] : [...gatesForHash].sort()[0];
|
|
299
|
+
attributed.push({ scope: s.scope, prefixHash: s.prefixHash, gate });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (wrongGate.length > 0) {
|
|
303
|
+
return {
|
|
304
|
+
verified: false,
|
|
305
|
+
reason: `${wrongGate.length} of ${sentinels.length} reviewer sentinel(s) recorded a prefix hash belonging to a DIFFERENT gate than their scope declares — a wrong-gate briefing (GATE-EXEC-BRIEFING-PREFIX).`,
|
|
306
|
+
mismatched: wrongGate,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
// Within-gate byte-identity: every reviewer attributed to one gate must share
|
|
310
|
+
// one hash. Two distinct hashes for the same gate (e.g. multiple same-gate
|
|
311
|
+
// records at this head) fails closed — the byte-identity invariant (AC2).
|
|
312
|
+
const gateHashes = new Map(); // gate -> Set<hash>
|
|
313
|
+
const gateCounts = new Map(); // gate -> count
|
|
314
|
+
for (const a of attributed) {
|
|
315
|
+
if (!gateHashes.has(a.gate)) gateHashes.set(a.gate, new Set());
|
|
316
|
+
gateHashes.get(a.gate).add(a.prefixHash);
|
|
317
|
+
gateCounts.set(a.gate, (gateCounts.get(a.gate) ?? 0) + 1);
|
|
318
|
+
}
|
|
319
|
+
const split = [...gateHashes.entries()].find(([, hs]) => hs.size > 1);
|
|
320
|
+
if (split) {
|
|
321
|
+
const [g] = split;
|
|
322
|
+
return {
|
|
323
|
+
verified: false,
|
|
324
|
+
reason: `Reviewer sentinels for gate ${g} recorded ${split[1].size} DIFFERENT invariant-briefing prefix hashes — within-gate briefings were not byte-identical (GATE-EXEC-BRIEFING-PREFIX).`,
|
|
325
|
+
mismatched: attributed.filter((a) => a.gate === g).map(({ scope, prefixHash }) => ({ scope, prefixHash })),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
const gates = [...gateHashes.entries()]
|
|
329
|
+
.map(([gate, hs]) => ({ gate, prefixHash: [...hs][0], reviewerCount: gateCounts.get(gate) }))
|
|
330
|
+
.sort((a, b) => a.gate.localeCompare(b.gate));
|
|
331
|
+
if (gates.length === 1) {
|
|
332
|
+
return { verified: true, prefixHash: gates[0].prefixHash, gates };
|
|
333
|
+
}
|
|
334
|
+
return { verified: true, gates };
|
|
146
335
|
}
|
|
147
336
|
|
|
148
337
|
export async function main(argv = process.argv.slice(2), { tmpRoot = path.join(process.cwd(), "tmp") } = {}) {
|
|
@@ -167,7 +356,8 @@ export async function main(argv = process.argv.slice(2), { tmpRoot = path.join(p
|
|
|
167
356
|
const silent = argv.includes("--silent") || argv.includes("-s");
|
|
168
357
|
|
|
169
358
|
const sentinels = await readRoundSentinels(tmpRoot, headSha);
|
|
170
|
-
const
|
|
359
|
+
const gateRecords = await readGateBriefingRecords(tmpRoot, headSha);
|
|
360
|
+
const verdict = evaluateBriefingPrefixes(sentinels, gateRecords);
|
|
171
361
|
const payload = {
|
|
172
362
|
ok: true,
|
|
173
363
|
verified: verdict.verified,
|
|
@@ -177,6 +367,7 @@ export async function main(argv = process.argv.slice(2), { tmpRoot = path.join(p
|
|
|
177
367
|
...(verdict.missing ? { missing: verdict.missing } : {}),
|
|
178
368
|
...(verdict.mismatched ? { mismatched: verdict.mismatched } : {}),
|
|
179
369
|
...(verdict.prefixHash ? { prefixHash: verdict.prefixHash } : {}),
|
|
370
|
+
...(verdict.gates ? { gates: verdict.gates } : {}),
|
|
180
371
|
};
|
|
181
372
|
return emitResult(payload, { jq, silent, ok: verdict.verified });
|
|
182
373
|
}
|
|
@@ -39,6 +39,7 @@ import { resolveGateAnglesDynamic } from "@dev-loops/core/config";
|
|
|
39
39
|
import { parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
|
|
40
40
|
import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
41
41
|
import { buildAdjacentBundle, DEFAULT_MAX_FILE_BYTES } from "./build-adjacent-bundle.mjs";
|
|
42
|
+
import { GATE_NAMES } from "./_gate-names.mjs";
|
|
42
43
|
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
43
44
|
|
|
44
45
|
/**
|
|
@@ -132,7 +133,7 @@ function parseError(message) {
|
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
function normalizeGate(value) {
|
|
135
|
-
const gates = new Set(
|
|
136
|
+
const gates = new Set(GATE_NAMES);
|
|
136
137
|
const normalized = String(value).trim().toLowerCase();
|
|
137
138
|
return gates.has(normalized) ? normalized : null;
|
|
138
139
|
}
|
|
@@ -152,6 +153,9 @@ function normalizeHeadSha(value) {
|
|
|
152
153
|
// with our own "<base>...HEAD" triple-dot construction). Everything else —
|
|
153
154
|
// including HEAD@{upstream}, main@{1}, tag-peel v1.0.0^{commit}, HEAD~3 — is
|
|
154
155
|
// accepted.
|
|
156
|
+
// Revisit toward an explicit allowlist if `base` ever reaches a shell (or any
|
|
157
|
+
// call without execFileSync's no-shell argv guarantee), or if a malformed ref
|
|
158
|
+
// shape is found slipping past these checks into `git diff`.
|
|
155
159
|
function normalizeBaseRef(value) {
|
|
156
160
|
const trimmed = String(value).trim();
|
|
157
161
|
if (trimmed.length === 0 || trimmed.startsWith("-") || trimmed.includes("..")) return null;
|
|
@@ -375,8 +379,8 @@ export function buildGateContextPath({ repo, pr, gate, headSha, tmpRoot = "tmp"
|
|
|
375
379
|
* @returns {{ pr: number, gate: string, headSha: string }}
|
|
376
380
|
*/
|
|
377
381
|
function validatePathSegments({ pr, gate, headSha }) {
|
|
378
|
-
if (gate
|
|
379
|
-
throw new Error(`--gate segment ${JSON.stringify(gate)} is unsafe (expected
|
|
382
|
+
if (!GATE_NAMES.includes(gate)) {
|
|
383
|
+
throw new Error(`--gate segment ${JSON.stringify(gate)} is unsafe (expected ${GATE_NAMES.join(" or ")})`);
|
|
380
384
|
}
|
|
381
385
|
// Require a CANONICAL positive integer: the trimmed string must be all digits
|
|
382
386
|
// (`/^\d+$/`) and > 0. This mirrors the CLI's parsePrNumber rule so the path
|
|
@@ -522,7 +526,7 @@ export function renderBriefingPrefix({
|
|
|
522
526
|
lines.push(`prefixMode: ${prefixMode}`);
|
|
523
527
|
lines.push("");
|
|
524
528
|
lines.push(
|
|
525
|
-
`Mandatory: before doing any angle-specific work, run \`node scripts/github/verify-fresh-review-context.mjs --scope
|
|
529
|
+
`Mandatory: before doing any angle-specific work, run \`node scripts/github/verify-fresh-review-context.mjs --scope ${gate.replace(/_/g, "-")}-<your-angle> --context-path ${contextPath} --prefix-file ${briefingPrefixPath}\`. Refuse to proceed on contamination or a missing artifact.`,
|
|
526
530
|
);
|
|
527
531
|
lines.push("");
|
|
528
532
|
lines.push("## PR body");
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// It lives in scripts/loop/ (not packages/core) because significance is derived
|
|
9
9
|
// from a `gh api .../compare` diff — gh I/O that does not belong in core.
|
|
10
10
|
import { extractReviewCommitSha, isCopilotLogin, parseJsonText } from "../_core-helpers.mjs";
|
|
11
|
-
import { runChild } from "../_cli-primitives.mjs";
|
|
11
|
+
import { runChild as defaultRunChild } from "../_cli-primitives.mjs";
|
|
12
12
|
|
|
13
13
|
export function getLatestSubmittedCopilotReviewHeadSha(reviews) {
|
|
14
14
|
const copilotSubmitted = (Array.isArray(reviews) ? reviews : [])
|
|
@@ -149,7 +149,7 @@ export function isCommentOnlyFileChange(file) {
|
|
|
149
149
|
|
|
150
150
|
export async function detectPostConvergenceSignificantChange(
|
|
151
151
|
{ repo, pr, currentHeadSha, reviews, changedFiles, roundCapReached, regularCopilotRounds },
|
|
152
|
-
{ env = process.env, ghCommand = "gh" } = {},
|
|
152
|
+
{ env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {},
|
|
153
153
|
) {
|
|
154
154
|
if (!roundCapReached || !regularCopilotRounds) {
|
|
155
155
|
return false;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
1
3
|
import path from "node:path";
|
|
2
4
|
import process from "node:process";
|
|
3
5
|
import { parseRepoSlugParts } from "@dev-loops/core/github/repo-slug";
|
|
@@ -9,6 +11,7 @@ import {
|
|
|
9
11
|
} from "./_steering-state-file.mjs";
|
|
10
12
|
export const RUNNER_COORDINATION_SCHEMA_VERSION = 2;
|
|
11
13
|
export const RUNNER_COORDINATION_SUPPORTED_SCHEMA_VERSIONS = Object.freeze([1, 2]);
|
|
14
|
+
export const RUNNER_COORDINATION_HISTORY_LIMIT = 50; // cap audit trail; heartbeats append per-round, keep the most recent 50 events
|
|
12
15
|
export const RUNNER_OWNERSHIP_ERROR = Object.freeze({
|
|
13
16
|
ACTIVE_RUN_EXISTS: "active_run_exists",
|
|
14
17
|
OWNERSHIP_LOST: "ownership_lost",
|
|
@@ -50,7 +53,10 @@ async function loadRunnerStateFile(filePath) {
|
|
|
50
53
|
}
|
|
51
54
|
async function saveRunnerStateFile(filePath, state) {
|
|
52
55
|
try {
|
|
53
|
-
|
|
56
|
+
const capped = Array.isArray(state.history) && state.history.length > RUNNER_COORDINATION_HISTORY_LIMIT
|
|
57
|
+
? { ...state, history: state.history.slice(-RUNNER_COORDINATION_HISTORY_LIMIT) }
|
|
58
|
+
: state;
|
|
59
|
+
return await saveSharedStateFile(filePath, capped);
|
|
54
60
|
} catch (error) {
|
|
55
61
|
const message = error instanceof Error ? error.message : String(error);
|
|
56
62
|
throw new Error(`Failed to write runner coordination state file '${filePath}': ${message}`);
|
|
@@ -64,13 +70,62 @@ async function withRunnerStateFileLock(filePath, callback) {
|
|
|
64
70
|
throw new Error(`Failed to acquire runner coordination state lock for '${filePath}': ${message}`);
|
|
65
71
|
}
|
|
66
72
|
}
|
|
73
|
+
const coordinationRootCache = new Map();
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the single stable coordination root for a checkout, independent of CWD.
|
|
76
|
+
*
|
|
77
|
+
* `git rev-parse --git-common-dir` yields the shared git dir that is identical for
|
|
78
|
+
* a repo and all its linked worktrees, so its parent is the one main-checkout root
|
|
79
|
+
* that a worktree runner and a repo-root detector both anchor to — eliminating the
|
|
80
|
+
* split-copy false-stale stall where each read a different `.pi/runner-coordination`
|
|
81
|
+
* file. Falls back to the canonicalized (realpath'd) `cwd` when git is
|
|
82
|
+
* unavailable or the dir is not a checkout, so symlinked and realpath'd
|
|
83
|
+
* spellings of the same non-git dir still converge on one coordination path.
|
|
84
|
+
*
|
|
85
|
+
* `cwd` is realpath'd once at entry: git returns a relative `--git-common-dir` from
|
|
86
|
+
* a main checkout but an already-realpath'd absolute one from a linked worktree, so
|
|
87
|
+
* resolving against a symlinked cwd (e.g. macOS /tmp -> /private/tmp) would make the
|
|
88
|
+
* two sides compute different roots for the same physical repo. Canonicalizing first
|
|
89
|
+
* makes both sides converge, and doubles as the cache key so symlink-variant cwd
|
|
90
|
+
* spellings share one cache entry. Cached per canonical cwd for the process.
|
|
91
|
+
*/
|
|
92
|
+
function resolveRepoCoordinationRoot(cwd) {
|
|
93
|
+
let canonicalCwd = cwd;
|
|
94
|
+
try {
|
|
95
|
+
canonicalCwd = fs.realpathSync(cwd);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
// realpathSync on an existing checkout dir virtually never fails;
|
|
98
|
+
// warn (don't throw) so the rare transient failure — which can desync the
|
|
99
|
+
// coordination path across worktrees — is diagnosable instead of silent.
|
|
100
|
+
console.warn(
|
|
101
|
+
`[runner-coordination] realpathSync(${cwd}) failed; using raw cwd, coordination path may diverge across worktrees: ${err instanceof Error ? err.message : String(err)}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (coordinationRootCache.has(canonicalCwd)) return coordinationRootCache.get(canonicalCwd);
|
|
105
|
+
let root = canonicalCwd;
|
|
106
|
+
try {
|
|
107
|
+
const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
|
|
108
|
+
cwd: canonicalCwd,
|
|
109
|
+
encoding: "utf8",
|
|
110
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
111
|
+
}).trim();
|
|
112
|
+
if (commonDir) {
|
|
113
|
+
root = path.dirname(path.resolve(canonicalCwd, commonDir));
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// not a git checkout / git unavailable — anchor at the canonical (realpath'd) cwd
|
|
117
|
+
}
|
|
118
|
+
coordinationRootCache.set(canonicalCwd, root);
|
|
119
|
+
return root;
|
|
120
|
+
}
|
|
67
121
|
export function defaultRunnerCoordinationFilePathForTarget({ repo, pr }, cwd = process.cwd()) {
|
|
68
122
|
const { owner, name } = parseRepoSlugParts(repo, {
|
|
69
123
|
errorMessage: `Invalid repo slug for coordination target path: ${JSON.stringify(repo)}`,
|
|
70
124
|
lowercase: true,
|
|
71
125
|
});
|
|
72
126
|
const normalizedPr = normalizePr(pr);
|
|
73
|
-
|
|
127
|
+
const root = resolveRepoCoordinationRoot(cwd);
|
|
128
|
+
return path.join(root, ".pi", "runner-coordination", owner, name, `pr-${normalizedPr}.json`);
|
|
74
129
|
}
|
|
75
130
|
export function createRunnerCoordinationState({ repo, pr, runId = null, now = new Date().toISOString() }) {
|
|
76
131
|
const normalizedRepo = normalizeRepoSlug(repo);
|
|
@@ -324,6 +379,18 @@ export async function claimRunnerOwnership({
|
|
|
324
379
|
};
|
|
325
380
|
});
|
|
326
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* Verify the caller still owns the PR's runner claim.
|
|
384
|
+
*
|
|
385
|
+
* A successful owner-confirmed assert (the active owner's runId matches the
|
|
386
|
+
* caller's) refreshes `activeRun.updatedAt` — it acts as a heartbeat. Every
|
|
387
|
+
* other outcome (no record, no active run, ownership lost, missing run id)
|
|
388
|
+
* is a pure lock-free read with no write. Long-running loops must assert (or
|
|
389
|
+
* claim) at least once within the stale-max-age window
|
|
390
|
+
* (`STALE_RUNNER_DEFAULT_MAX_AGE_MS`, 30 minutes by default, overridable via
|
|
391
|
+
* `DEVLOOPS_STALE_RUNNER_MAX_AGE_MS`; see `_stale-runner-detection.mjs`) to
|
|
392
|
+
* avoid being seen as stale during a long multi-round gate cycle.
|
|
393
|
+
*/
|
|
327
394
|
export async function assertRunnerOwnership({
|
|
328
395
|
repo,
|
|
329
396
|
pr,
|
|
@@ -331,6 +398,7 @@ export async function assertRunnerOwnership({
|
|
|
331
398
|
cwd = process.cwd(),
|
|
332
399
|
filePath = null,
|
|
333
400
|
requireExisting = false,
|
|
401
|
+
now = new Date().toISOString(),
|
|
334
402
|
} = {}) {
|
|
335
403
|
const normalizedRepo = normalizeRepoSlug(repo);
|
|
336
404
|
const normalizedPr = normalizePr(pr);
|
|
@@ -397,17 +465,48 @@ export async function assertRunnerOwnership({
|
|
|
397
465
|
});
|
|
398
466
|
}
|
|
399
467
|
if (state.activeRun.runId === normalizedRunId) {
|
|
400
|
-
return {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
runId
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
468
|
+
return withRunnerStateFileLock(resolvedPath, async () => {
|
|
469
|
+
const lockedRaw = await loadRunnerStateFile(resolvedPath);
|
|
470
|
+
const lockedState = lockedRaw === null
|
|
471
|
+
? null
|
|
472
|
+
: normalizeRunnerCoordinationState(lockedRaw, { repo: normalizedRepo, pr: normalizedPr });
|
|
473
|
+
if (lockedState?.activeRun?.runId !== normalizedRunId) {
|
|
474
|
+
// Ownership changed hands between the lockless read above and acquiring
|
|
475
|
+
// the lock (a concurrent takeover) — don't write; report the new owner.
|
|
476
|
+
return buildConflict({
|
|
477
|
+
error: RUNNER_OWNERSHIP_ERROR.OWNERSHIP_LOST,
|
|
478
|
+
repo: normalizedRepo,
|
|
479
|
+
pr: normalizedPr,
|
|
480
|
+
runId: normalizedRunId,
|
|
481
|
+
activeRun: lockedState?.activeRun ?? null,
|
|
482
|
+
filePath: resolvedPath,
|
|
483
|
+
exitSignals: lockedState?.exitSignals ?? [],
|
|
484
|
+
message: lockedState?.activeRun?.runId
|
|
485
|
+
? `PR ${normalizedRepo}#${normalizedPr} is now owned by run ${lockedState.activeRun.runId}; run ${normalizedRunId} must stop.`
|
|
486
|
+
: `PR ${normalizedRepo}#${normalizedPr} no longer has an active runner ownership record; run ${normalizedRunId} must stop.`,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
const nextState = {
|
|
490
|
+
...lockedState,
|
|
491
|
+
activeRun: {
|
|
492
|
+
...lockedState.activeRun,
|
|
493
|
+
updatedAt: now,
|
|
494
|
+
},
|
|
495
|
+
history: [...lockedState.history, { type: "heartbeat", runId: normalizedRunId, at: now }],
|
|
496
|
+
};
|
|
497
|
+
await saveRunnerStateFile(resolvedPath, nextState);
|
|
498
|
+
return {
|
|
499
|
+
ok: true,
|
|
500
|
+
status: "owner_confirmed",
|
|
501
|
+
repo: normalizedRepo,
|
|
502
|
+
pr: normalizedPr,
|
|
503
|
+
runId: normalizedRunId,
|
|
504
|
+
activeRun: nextState.activeRun,
|
|
505
|
+
previousRun: nextState.previousRun,
|
|
506
|
+
exitSignals: nextState.exitSignals,
|
|
507
|
+
filePath: resolvedPath,
|
|
508
|
+
};
|
|
509
|
+
});
|
|
411
510
|
}
|
|
412
511
|
return buildConflict({
|
|
413
512
|
error: RUNNER_OWNERSHIP_ERROR.OWNERSHIP_LOST,
|
|
@@ -24,10 +24,13 @@
|
|
|
24
24
|
* - dev-loops subcommands and `node scripts/....mjs` invocations. Those scripts
|
|
25
25
|
* legitimately call gh/GraphQL/etc. internally — that IS the tooling.
|
|
26
26
|
* - A small explicit allowlist of write-ops that have no internal wrapper today:
|
|
27
|
-
* `gh pr merge`, `gh pr ready`, `gh issue create
|
|
28
|
-
*
|
|
27
|
+
* `gh pr merge`, `gh pr ready`, `gh issue create`. Plus belt-and-suspenders
|
|
28
|
+
* entries that DO have wrappers — `gh issue edit` (scripts/github/edit-issue.mjs)
|
|
29
|
+
* and `gh label create` (scripts/github/create-label.mjs) — kept so a bare
|
|
30
|
+
* invocation surfaced from the wrapper's own subprocess is not a false violation.
|
|
31
|
+
* All are recorded as `allowedWriteOps` rather than violations so the gate is not
|
|
29
32
|
* blocked forever on an unavoidable gap. Document/close the gap with a wrapper
|
|
30
|
-
* to remove
|
|
33
|
+
* to remove the no-wrapper entries from the allowlist.
|
|
31
34
|
*
|
|
32
35
|
* VIOLATION (agent-level raw call):
|
|
33
36
|
* - `gh ...` at the start of a command segment (start of line, or after
|
|
@@ -75,19 +78,21 @@ Exit codes:
|
|
|
75
78
|
2 Argument/runtime error, or invalid --jq filter`;
|
|
76
79
|
|
|
77
80
|
/**
|
|
78
|
-
* Write-ops
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
+
* Write-ops recorded distinctly (as allowedWriteOps, not violations) so the gate
|
|
82
|
+
* is not blocked forever on an unavoidable gap. Most have no internal wrapper yet;
|
|
83
|
+
* a couple DO have wrappers and are kept belt-and-suspenders (see below).
|
|
84
|
+
* Keep this set SMALL and explicit; remove a no-wrapper entry once a wrapper exists.
|
|
81
85
|
* @type {ReadonlyArray<RegExp>}
|
|
82
86
|
*/
|
|
83
87
|
const ALLOWED_WRITE_OPS = Object.freeze([
|
|
84
88
|
/^gh\s+pr\s+merge\b/,
|
|
85
89
|
/^gh\s+pr\s+ready\b/,
|
|
86
90
|
/^gh\s+issue\s+create\b/,
|
|
91
|
+
// `gh issue edit` (scripts/github/edit-issue.mjs) and `gh label create`
|
|
92
|
+
// (scripts/github/create-label.mjs) both HAVE wrappers; these entries are
|
|
93
|
+
// belt-and-suspenders so a bare invocation (e.g. surfaced from the wrapper's
|
|
94
|
+
// own subprocess) classifies as an allowed write-op, not a violation.
|
|
87
95
|
/^gh\s+issue\s+edit\b/,
|
|
88
|
-
// `gh label create` HAS a wrapper (scripts/github/create-label.mjs); this
|
|
89
|
-
// entry is belt-and-suspenders so a bare invocation (e.g. surfaced from the
|
|
90
|
-
// wrapper's own subprocess) classifies as an allowed write-op, not a violation.
|
|
91
96
|
/^gh\s+label\s+create\b/,
|
|
92
97
|
]);
|
|
93
98
|
|