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.
Files changed (71) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +1 -1
  3. package/.claude/agents/refiner.md +2 -2
  4. package/.claude/agents/review.md +9 -12
  5. package/.claude/commands/loop-continue.md +2 -1
  6. package/.claude/commands/loop-enqueue.md +8 -1
  7. package/.claude/commands/loop-info.md +1 -1
  8. package/.claude/hooks/_bash-command-classify.mjs +7 -6
  9. package/.claude/hooks/_hook-decisions.mjs +5 -4
  10. package/.claude/skills/copilot-pr-followup/SKILL.md +5 -5
  11. package/.claude/skills/dev-loop/SKILL.md +7 -7
  12. package/.claude/skills/docs/acceptance-criteria-verification.md +12 -4
  13. package/.claude/skills/docs/anti-patterns.md +3 -3
  14. package/.claude/skills/docs/artifact-authority-contract.md +6 -4
  15. package/.claude/skills/docs/confirmation-rules.md +1 -1
  16. package/.claude/skills/docs/copilot-loop-operations.md +4 -2
  17. package/.claude/skills/docs/issue-intake-procedure.md +12 -3
  18. package/.claude/skills/docs/merge-preconditions.md +5 -3
  19. package/.claude/skills/docs/pr-lifecycle-contract.md +1 -1
  20. package/.claude/skills/docs/public-dev-loop-contract.md +2 -1
  21. package/.claude/skills/docs/retrospective-checkpoint-contract.md +9 -6
  22. package/.claude/skills/docs/validation-policy.md +1 -1
  23. package/.claude/skills/local-implementation/SKILL.md +3 -3
  24. package/.claude/skills/loop-grill/SKILL.md +34 -14
  25. package/AGENTS.md +1 -1
  26. package/CHANGELOG.md +30 -0
  27. package/README.md +95 -189
  28. package/agents/refiner.agent.md +2 -2
  29. package/agents/review.agent.md +9 -12
  30. package/extension/README.md +5 -4
  31. package/package.json +5 -4
  32. package/scripts/docs/validate-state-machine-conformance.mjs +78 -1
  33. package/scripts/github/_gate-names.mjs +5 -0
  34. package/scripts/github/capture-review-threads.mjs +2 -2
  35. package/scripts/github/detect-checkpoint-evidence.mjs +7 -7
  36. package/scripts/github/edit-issue.mjs +259 -0
  37. package/scripts/github/probe-ci-status.mjs +18 -0
  38. package/scripts/github/probe-copilot-review.mjs +24 -3
  39. package/scripts/github/reconcile-draft-gate.mjs +13 -13
  40. package/scripts/github/request-copilot-review.mjs +17 -16
  41. package/scripts/github/upsert-checkpoint-verdict.mjs +25 -23
  42. package/scripts/github/verify-briefing-prefixes.mjs +224 -33
  43. package/scripts/github/write-gate-context.mjs +8 -4
  44. package/scripts/loop/_post-convergence-change.mjs +2 -2
  45. package/scripts/loop/_pr-runner-coordination.mjs +112 -13
  46. package/scripts/loop/check-retro-tooling.mjs +14 -9
  47. package/scripts/loop/copilot-pr-handoff.mjs +18 -14
  48. package/scripts/loop/detect-copilot-loop-state.mjs +11 -11
  49. package/scripts/loop/detect-internal-only-pr.mjs +6 -6
  50. package/scripts/loop/detect-pr-gate-coordination-state.mjs +117 -15
  51. package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
  52. package/scripts/loop/run-watch-cycle.mjs +42 -7
  53. package/scripts/loop/sanctioned-commands.mjs +1 -0
  54. package/scripts/pages/build-state-atlas.mjs +15 -0
  55. package/scripts/projects/add-queue-item.mjs +87 -4
  56. package/skills/copilot-pr-followup/SKILL.md +5 -5
  57. package/skills/dev-loop/SKILL.md +2 -2
  58. package/skills/docs/acceptance-criteria-verification.md +12 -4
  59. package/skills/docs/anti-patterns.md +3 -3
  60. package/skills/docs/artifact-authority-contract.md +6 -4
  61. package/skills/docs/confirmation-rules.md +1 -1
  62. package/skills/docs/copilot-loop-operations.md +4 -2
  63. package/skills/docs/issue-intake-procedure.md +12 -3
  64. package/skills/docs/merge-preconditions.md +5 -3
  65. package/skills/docs/pr-lifecycle-contract.md +1 -1
  66. package/skills/docs/public-dev-loop-contract.md +2 -1
  67. package/skills/docs/required-rules.json +17 -1
  68. package/skills/docs/retrospective-checkpoint-contract.md +9 -6
  69. package/skills/docs/validation-policy.md +1 -1
  70. package/skills/local-implementation/SKILL.md +3 -3
  71. 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 this gate-review round's per-scope reviewer sentinels (written by
10
- verify-fresh-review-context.mjs --prefix-hash/--prefix-file) do not all record the SAME
11
- invariant-briefing prefix hash. Deterministic and offline: reads only the sentinel files
12
- already on disk under tmp/, keyed by the given head SHA.
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), or every
27
- sentinel records a hash and all hashes are identical (a single hashed
28
- sentinel verifies nothing to mismatch)
29
- 1 Fail closed: two or more sentinels record DIFFERENT prefix hashes, or ANY
30
- sentinel for the round (even a lone one) records no prefix hash — a missing
31
- hash is treated as a mismatch, never grandfathered
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
- Caveat: rounds are keyed by head SHA only. Two different gates reviewed at the
35
- SAME head share one sentinel namespace, so run this check per gate pass (the head
36
- advances between gate passes per GATE-EXEC-REGATE-MANDATORY; never manually clear sentinels); legitimately different per-gate prefixes at an identical
37
- head would otherwise flag as a mismatch (conservative fail-closed, never
38
- fail-open).`.trim();
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: given the round's sentinels, decide verified/reason/missing/mismatched.
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
- * @returns {{ verified: boolean, reason?: string, missing?: string[], mismatched?: Array<{scope:string, prefixHash:string}> }}
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 distinct = new Map();
133
- for (const { scope, prefixHash } of sentinels) {
134
- if (!distinct.has(prefixHash)) distinct.set(prefixHash, []);
135
- distinct.get(prefixHash).push(scope);
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
- if (distinct.size > 1) {
138
- const mismatched = sentinels.map(({ scope, prefixHash }) => ({ scope, prefixHash }));
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: `Reviewer sentinels for this round recorded ${distinct.size} DIFFERENT invariant-briefing prefix hashesthe seeded briefings were not byte-identical (GATE-EXEC-BRIEFING-PREFIX).`,
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
- return { verified: true, prefixHash: sentinels[0].prefixHash };
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 verdict = evaluateBriefingPrefixes(sentinels);
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(["draft_gate", "pre_approval_gate"]);
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 !== "draft_gate" && gate !== "pre_approval_gate") {
379
- throw new Error(`--gate segment ${JSON.stringify(gate)} is unsafe (expected draft_gate or pre_approval_gate)`);
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 <your-angle> --context-path ${contextPath} --prefix-file ${briefingPrefixPath}\`. Refuse to proceed on contamination or a missing artifact.`,
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
- return await saveSharedStateFile(filePath, state);
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
- return path.join(cwd, ".pi", "runner-coordination", owner, name, `pr-${normalizedPr}.json`);
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
- ok: true,
402
- status: "owner_confirmed",
403
- repo: normalizedRepo,
404
- pr: normalizedPr,
405
- runId: normalizedRunId,
406
- activeRun: state.activeRun,
407
- previousRun: state.previousRun,
408
- exitSignals: state.exitSignals,
409
- filePath: resolvedPath,
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`, `gh issue edit`. These are
28
- * recorded as `allowedWriteOps` rather than violations so the gate is not
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 them from the allowlist.
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 that currently have no internal dev-loops wrapper. Recorded
79
- * distinctly so the gate is not blocked forever on an unavoidable gap.
80
- * Keep this set SMALL and explicit; remove an entry once a wrapper exists.
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