dev-loops 0.7.1 → 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 (107) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +7 -7
  3. package/.claude/agents/fixer.md +1 -0
  4. package/.claude/agents/refiner.md +4 -4
  5. package/.claude/agents/review.md +10 -13
  6. package/.claude/commands/loop-continue.md +2 -1
  7. package/.claude/commands/loop-enqueue.md +9 -2
  8. package/.claude/commands/loop-info.md +1 -1
  9. package/.claude/commands/loop-start-spike.md +1 -1
  10. package/.claude/hooks/_bash-command-classify.mjs +7 -6
  11. package/.claude/hooks/_hook-decisions.mjs +5 -4
  12. package/.claude/skills/copilot-pr-followup/SKILL.md +42 -28
  13. package/.claude/skills/dev-loop/SKILL.md +8 -8
  14. package/.claude/skills/docs/acceptance-criteria-verification.md +13 -4
  15. package/.claude/skills/docs/anti-patterns.md +6 -5
  16. package/.claude/skills/docs/artifact-authority-contract.md +17 -14
  17. package/.claude/skills/docs/confirmation-rules.md +2 -1
  18. package/.claude/skills/docs/contract-style-guide.md +37 -0
  19. package/.claude/skills/docs/copilot-loop-operations.md +21 -15
  20. package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
  21. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  22. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  23. package/.claude/skills/docs/issue-intake-procedure.md +37 -22
  24. package/.claude/skills/docs/merge-preconditions.md +17 -13
  25. package/.claude/skills/docs/pr-lifecycle-contract.md +45 -36
  26. package/.claude/skills/docs/public-dev-loop-contract.md +61 -44
  27. package/.claude/skills/docs/retrospective-checkpoint-contract.md +25 -9
  28. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  29. package/.claude/skills/docs/stop-conditions.md +30 -15
  30. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  31. package/.claude/skills/docs/validation-policy.md +4 -5
  32. package/.claude/skills/local-implementation/SKILL.md +49 -143
  33. package/.claude/skills/loop-grill/SKILL.md +34 -14
  34. package/AGENTS.md +1 -1
  35. package/CHANGELOG.md +36 -0
  36. package/README.md +95 -189
  37. package/agents/dev-loop.agent.md +8 -8
  38. package/agents/fixer.agent.md +1 -0
  39. package/agents/refiner.agent.md +4 -4
  40. package/agents/review.agent.md +10 -13
  41. package/extension/README.md +5 -4
  42. package/package.json +7 -5
  43. package/scripts/_core-helpers.mjs +2 -0
  44. package/scripts/claude/headless-dev-loop.mjs +1 -1
  45. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  46. package/scripts/docs/validate-state-machine-conformance.mjs +1169 -0
  47. package/scripts/github/_gate-names.mjs +5 -0
  48. package/scripts/github/_review-thread-mutations.mjs +5 -2
  49. package/scripts/github/capture-review-threads.mjs +2 -2
  50. package/scripts/github/create-pr.mjs +119 -10
  51. package/scripts/github/detect-checkpoint-evidence.mjs +109 -24
  52. package/scripts/github/edit-issue.mjs +259 -0
  53. package/scripts/github/list-review-threads.mjs +277 -0
  54. package/scripts/github/post-gate-findings.mjs +6 -3
  55. package/scripts/github/probe-ci-status.mjs +18 -0
  56. package/scripts/github/probe-copilot-review.mjs +24 -3
  57. package/scripts/github/reconcile-draft-gate.mjs +13 -13
  58. package/scripts/github/request-copilot-review.mjs +109 -42
  59. package/scripts/github/upsert-checkpoint-verdict.mjs +98 -27
  60. package/scripts/github/verify-briefing-prefixes.mjs +382 -0
  61. package/scripts/github/verify-fresh-review-context.mjs +78 -3
  62. package/scripts/github/wait-pr-checks.mjs +171 -0
  63. package/scripts/github/write-gate-context.mjs +220 -10
  64. package/scripts/github/write-gate-findings-log.mjs +54 -2
  65. package/scripts/loop/_post-convergence-change.mjs +2 -2
  66. package/scripts/loop/_pr-runner-coordination.mjs +112 -13
  67. package/scripts/loop/check-retro-tooling.mjs +14 -9
  68. package/scripts/loop/copilot-pr-handoff.mjs +47 -16
  69. package/scripts/loop/detect-change-scope.mjs +2 -2
  70. package/scripts/loop/detect-copilot-loop-state.mjs +34 -14
  71. package/scripts/loop/detect-internal-only-pr.mjs +6 -6
  72. package/scripts/loop/detect-pr-gate-coordination-state.mjs +152 -18
  73. package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
  74. package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
  75. package/scripts/loop/run-watch-cycle.mjs +42 -7
  76. package/scripts/loop/sanctioned-commands.mjs +3 -0
  77. package/scripts/loop/validate-pr-body-spec.mjs +21 -5
  78. package/scripts/pages/build-state-atlas.mjs +65 -48
  79. package/scripts/projects/_resolve-project.mjs +1 -148
  80. package/scripts/projects/add-queue-item.mjs +87 -4
  81. package/scripts/projects/list-queue-items.mjs +3 -377
  82. package/scripts/projects/move-queue-item.mjs +3 -410
  83. package/scripts/projects/reorder-queue-item.mjs +3 -22
  84. package/skills/copilot-pr-followup/SKILL.md +42 -28
  85. package/skills/dev-loop/SKILL.md +3 -3
  86. package/skills/docs/acceptance-criteria-verification.md +13 -4
  87. package/skills/docs/anti-patterns.md +6 -5
  88. package/skills/docs/artifact-authority-contract.md +17 -14
  89. package/skills/docs/confirmation-rules.md +2 -1
  90. package/skills/docs/contract-style-guide.md +37 -0
  91. package/skills/docs/copilot-loop-operations.md +21 -15
  92. package/skills/docs/cross-harness-regression-contract.md +2 -2
  93. package/skills/docs/debt-remediation-contract.md +1 -1
  94. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  95. package/skills/docs/issue-intake-procedure.md +37 -22
  96. package/skills/docs/merge-preconditions.md +17 -13
  97. package/skills/docs/pr-lifecycle-contract.md +45 -36
  98. package/skills/docs/public-dev-loop-contract.md +61 -44
  99. package/skills/docs/required-rules.json +165 -0
  100. package/skills/docs/retrospective-checkpoint-contract.md +25 -9
  101. package/skills/docs/spike-mode-contract.md +14 -7
  102. package/skills/docs/stop-conditions.md +30 -15
  103. package/skills/docs/tracker-first-loop-state.md +13 -8
  104. package/skills/docs/validation-policy.md +4 -5
  105. package/skills/local-implementation/SKILL.md +49 -143
  106. package/skills/loop-grill/SKILL.md +38 -17
  107. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
@@ -0,0 +1,382 @@
1
+ #!/usr/bin/env node
2
+ import { readdir, readFile } from "node:fs/promises";
3
+ import { createHash } from "node:crypto";
4
+ import path from "node:path";
5
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
6
+ import { JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
7
+ import { GATE_NAMES } from "./_gate-names.mjs";
8
+
9
+ const USAGE = `Usage: verify-briefing-prefixes.mjs --head-sha <sha> [--help]
10
+ Fan-in enforcement for GATE-EXEC-BRIEFING-PREFIX (docs/gate-review-sub-loop-contract.md):
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.
18
+
19
+ Required:
20
+ --head-sha <sha> The FULL 40-char reviewed head SHA (git rev-parse HEAD); sentinels are read from
21
+ tmp/checkpoint-context-sentinel-<scope>-<headSha>.json.
22
+
23
+ Output (stdout, JSON):
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> }] }
27
+ { "ok": true, "verified": true, "headSha": "...", "reviewerCount": 0, "reason": "no sentinels found for this round" }
28
+ { "ok": true, "verified": false, "headSha": "...", "reviewerCount": <n>, "reason": "...", "missing": [...], "mismatched": [...] }
29
+ On error (stderr, JSON):
30
+ { "ok": false, "error": "...", "usage": "..." }
31
+ ${JQ_OUTPUT_USAGE}
32
+ Exit codes:
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
45
+ 2 Usage or internal error, or invalid --jq filter
46
+
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();
62
+
63
+ // Full 40-char SHA required: sentinel filenames embed the full `git rev-parse
64
+ // HEAD` value, so a short prefix would glob zero sentinels and read as a
65
+ // vacuous pass — fail closed on anything shorter instead.
66
+ const HEAD_SHA_RE = /^[0-9a-f]{40}$/i;
67
+ const SHA256_RE = /^[0-9a-f]{64}$/;
68
+ const SENTINEL_PREFIX = "checkpoint-context-sentinel-";
69
+ const parseError = buildParseError(USAGE);
70
+
71
+ function resolveFlagValue(argv, flag) {
72
+ const idx = argv.indexOf(flag);
73
+ if (idx === -1) return null;
74
+ const val = argv[idx + 1];
75
+ if (val === undefined || val === "" || (val.length > 0 && val[0] === "-")) {
76
+ return ""; // provided but missing/empty/flag-like
77
+ }
78
+ return val;
79
+ }
80
+
81
+ /**
82
+ * Read every sentinel for the given round (head SHA) from `<tmpRoot>/`, extracting
83
+ * the reviewer scope (from the filename) and recorded `prefixHash` (from the JSON
84
+ * body, when present). A malformed/unreadable sentinel is still COUNTED, with
85
+ * `prefixHash: null` — downstream that reads as a missing hash and FAILS CLOSED,
86
+ * deliberately: a corrupt sentinel means the invariant-prefix proof cannot be
87
+ * verified for that reviewer, and silently dropping it would fail open.
88
+ * @param {string} tmpRoot
89
+ * @param {string} headSha — lowercase hex, already validated
90
+ * @returns {Promise<Array<{ scope: string, prefixHash: string|null }>>}
91
+ */
92
+ async function readRoundSentinels(tmpRoot, headSha) {
93
+ const suffix = `-${headSha}.json`;
94
+ let entries;
95
+ try {
96
+ entries = await readdir(tmpRoot, { withFileTypes: true });
97
+ } catch (err) {
98
+ if (err.code === "ENOENT") return [];
99
+ throw err;
100
+ }
101
+ const matches = entries
102
+ .filter((e) => e.isFile() && e.name.startsWith(SENTINEL_PREFIX) && e.name.endsWith(suffix))
103
+ .map((e) => ({
104
+ file: e.name,
105
+ scope: e.name.slice(SENTINEL_PREFIX.length, -suffix.length),
106
+ }))
107
+ .filter((m) => m.scope.length > 0) // exclude the round-only (no-scope) sentinel name shape
108
+ // readdir order is filesystem-dependent; sort by scope so missing/mismatched
109
+ // output is deterministic across runs (this is a deterministic fan-in check).
110
+ .sort((a, b) => a.scope.localeCompare(b.scope));
111
+ const results = [];
112
+ for (const { file, scope } of matches) {
113
+ let prefixHash = null;
114
+ try {
115
+ const raw = await readFile(path.join(tmpRoot, file), "utf8");
116
+ const parsed = JSON.parse(raw);
117
+ // Only a well-formed sha256 counts as a recorded hash; anything else
118
+ // (corrupted/hand-edited value) is treated as missing so the round
119
+ // fails closed rather than comparing garbage.
120
+ if (parsed && typeof parsed.prefixHash === "string" && SHA256_RE.test(parsed.prefixHash.toLowerCase().trim())) {
121
+ prefixHash = parsed.prefixHash.toLowerCase().trim();
122
+ }
123
+ } catch {
124
+ // Malformed/unreadable sentinel: counted with prefixHash null so the
125
+ // evaluation fails closed on it (see the function doc comment).
126
+ }
127
+ results.push({ scope, prefixHash });
128
+ }
129
+ return results;
130
+ }
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
+
213
+ /**
214
+ * Pure comparison: decide verified/reason/missing/mismatched for the round.
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
+ *
235
+ * @param {Array<{ scope: string, prefixHash: string|null }>} sentinels
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}> }}
238
+ */
239
+ export function evaluateBriefingPrefixes(sentinels, gateRecords = null) {
240
+ if (sentinels.length === 0) {
241
+ return { verified: true, reason: "no sentinels found for this round" };
242
+ }
243
+ const missing = sentinels.filter((s) => s.prefixHash === null).map((s) => s.scope);
244
+ if (missing.length > 0) {
245
+ return {
246
+ verified: false,
247
+ reason: `${missing.length} of ${sentinels.length} reviewer sentinel(s) for this round have no recorded prefix hash — the invariant-briefing-prefix proof (GATE-EXEC-BRIEFING-PREFIX) was never established for them. Missing hashes are treated as a mismatch, not grandfathered in.`,
248
+ missing,
249
+ };
250
+ }
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 };
269
+ }
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) {
275
+ return {
276
+ verified: false,
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 })),
279
+ };
280
+ }
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 };
335
+ }
336
+
337
+ export async function main(argv = process.argv.slice(2), { tmpRoot = path.join(process.cwd(), "tmp") } = {}) {
338
+ if (argv.includes("--help") || argv.includes("-h")) {
339
+ process.stdout.write(`${USAGE}\n`);
340
+ return 0;
341
+ }
342
+ const headShaArg = resolveFlagValue(argv, "--head-sha");
343
+ if (headShaArg === null || headShaArg === "" || !HEAD_SHA_RE.test(headShaArg)) {
344
+ process.stderr.write(`${formatCliError(
345
+ parseError(`--head-sha is required and must be the FULL 40-character hex head SHA (short prefixes would match zero sentinels and pass vacuously)${headShaArg ? ` (got ${JSON.stringify(headShaArg)})` : ""}.`)
346
+ )}\n`);
347
+ return 2;
348
+ }
349
+ const headSha = headShaArg.toLowerCase();
350
+ const jqArg = resolveFlagValue(argv, "--jq");
351
+ if (jqArg === "") {
352
+ process.stderr.write(`${formatCliError(parseError("Invalid --jq value: must be non-empty."))}\n`);
353
+ return 2;
354
+ }
355
+ const jq = jqArg === null ? undefined : jqArg;
356
+ const silent = argv.includes("--silent") || argv.includes("-s");
357
+
358
+ const sentinels = await readRoundSentinels(tmpRoot, headSha);
359
+ const gateRecords = await readGateBriefingRecords(tmpRoot, headSha);
360
+ const verdict = evaluateBriefingPrefixes(sentinels, gateRecords);
361
+ const payload = {
362
+ ok: true,
363
+ verified: verdict.verified,
364
+ headSha,
365
+ reviewerCount: sentinels.length,
366
+ ...(verdict.reason ? { reason: verdict.reason } : {}),
367
+ ...(verdict.missing ? { missing: verdict.missing } : {}),
368
+ ...(verdict.mismatched ? { mismatched: verdict.mismatched } : {}),
369
+ ...(verdict.prefixHash ? { prefixHash: verdict.prefixHash } : {}),
370
+ ...(verdict.gates ? { gates: verdict.gates } : {}),
371
+ };
372
+ return emitResult(payload, { jq, silent, ok: verdict.verified });
373
+ }
374
+
375
+ if (isDirectCliRun(import.meta.url)) {
376
+ try {
377
+ process.exitCode = await main();
378
+ } catch (err) {
379
+ process.stderr.write(`${formatCliError(err)}\n`);
380
+ process.exitCode = 2;
381
+ }
382
+ }
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { mkdir, stat, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
3
  import { execFileSync } from "node:child_process";
4
+ import { createHash } from "node:crypto";
4
5
  import path from "node:path";
5
6
  import { buildParseError, isDirectCliRun, formatCliError } from "../_core-helpers.mjs";
6
7
  import { JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
7
8
  const USAGE = `Usage: verify-fresh-review-context.mjs [--help] [--scope <name>] [--context-path <path>]
9
+ [--prefix-hash <sha256>|--prefix-file <path>]
8
10
  Verify that the current scoped-reviewer session has fresh context.
9
11
 
10
12
  "Fresh" means the reviewer's context is the neutral gate-context builder
@@ -45,6 +47,19 @@ Options:
45
47
  means either a stale/isolated checkout or a skipped
46
48
  preamble, and the reviewer must refuse to proceed
47
49
  rather than silently reviewing without seeded context.
50
+ --prefix-hash <sha256> A 64-character hex SHA-256 digest (case-insensitive,
51
+ normalized to lowercase) of the
52
+ invariant briefing block this reviewer was seeded with
53
+ (GATE-EXEC-BRIEFING-PREFIX in
54
+ docs/gate-review-sub-loop-contract.md). Recorded on the
55
+ reviewer's sentinel so \`verify-briefing-prefixes.mjs\` can
56
+ fail closed when reviewers of the same gate pass were
57
+ seeded with different invariant blocks. Mutually exclusive
58
+ with --prefix-file.
59
+ --prefix-file <path> Path to the invariant briefing block text; the tool
60
+ hashes its raw bytes (sha256) and records the digest same
61
+ as --prefix-hash. Fails closed (exit 1) if the file is
62
+ missing. Mutually exclusive with --prefix-hash.
48
63
  Output (stdout, JSON):
49
64
  { "ok": true, "fresh": true, "sentinelCreated": true, "round": "<headSha|null>" }
50
65
  { "ok": true, "fresh": true, "sentinelCreated": true, "round": "...", "gateContextPath": "...", "gateContextPresent": true }
@@ -57,9 +72,12 @@ Exit codes:
57
72
  0 Clean (first run)
58
73
  1 Refuse to review: contaminated (prior session detected), OR (with
59
74
  --context-path) the seeded gate-context artifact is missing or resolves
60
- outside the reviewer's working directory
61
- 2 Usage or internal error, or invalid --jq filter`.trim();
75
+ outside the reviewer's working directory, OR (with --prefix-file) the
76
+ prefix file is missing
77
+ 2 Usage or internal error, invalid --jq filter, or invalid/conflicting
78
+ --prefix-hash/--prefix-file`.trim();
62
79
  const VALID_SCOPE_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
80
+ const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
63
81
  const parseError = buildParseError(USAGE);
64
82
  // Resolve a `--flag <value>` argument. Returns null when the flag is absent,
65
83
  // "" when it is present but the value is missing/empty/flag-like (a following
@@ -90,6 +108,12 @@ function resolveValidatedScope(argv) {
90
108
  function resolveContextPath(argv) {
91
109
  return resolveFlagValue(argv, "--context-path");
92
110
  }
111
+ function resolvePrefixHash(argv) {
112
+ return resolveFlagValue(argv, "--prefix-hash");
113
+ }
114
+ function resolvePrefixFile(argv) {
115
+ return resolveFlagValue(argv, "--prefix-file");
116
+ }
93
117
  // Round = the current head SHA, so a retry on a new head gets a fresh key while
94
118
  // a same-head re-entry collides and fails closed. `git rev-parse HEAD` yields the
95
119
  // same full SHA on every invocation for a given head, so the key is deterministic
@@ -145,6 +169,32 @@ async function main(argv = process.argv.slice(2)) {
145
169
  )}\n`);
146
170
  return 2;
147
171
  }
172
+ const prefixHashArg = resolvePrefixHash(argv);
173
+ if (prefixHashArg === "") {
174
+ process.stderr.write(`${formatCliError(
175
+ parseError("Invalid --prefix-hash value: must be non-empty.")
176
+ )}\n`);
177
+ return 2;
178
+ }
179
+ const prefixFileArg = resolvePrefixFile(argv);
180
+ if (prefixFileArg === "") {
181
+ process.stderr.write(`${formatCliError(
182
+ parseError("Invalid --prefix-file value: must be non-empty.")
183
+ )}\n`);
184
+ return 2;
185
+ }
186
+ if (prefixHashArg !== null && prefixFileArg !== null) {
187
+ process.stderr.write(`${formatCliError(
188
+ parseError("--prefix-hash and --prefix-file are mutually exclusive — pass at most one.")
189
+ )}\n`);
190
+ return 2;
191
+ }
192
+ if (prefixHashArg !== null && !SHA256_HEX_RE.test(prefixHashArg.trim().toLowerCase())) {
193
+ process.stderr.write(`${formatCliError(
194
+ parseError(`Invalid --prefix-hash value "${prefixHashArg}": must be a 64-character hex SHA-256 digest (case-insensitive).`)
195
+ )}\n`);
196
+ return 2;
197
+ }
148
198
  const jqArg = resolveFlagValue(argv, "--jq");
149
199
  if (jqArg === "") {
150
200
  process.stderr.write(`${formatCliError(
@@ -195,6 +245,29 @@ async function main(argv = process.argv.slice(2)) {
195
245
  }, false);
196
246
  }
197
247
  }
248
+ // Resolve the invariant-briefing prefix hash (GATE-EXEC-BRIEFING-PREFIX) before
249
+ // sentinel creation, same ordering rationale as --context-path above: a failure
250
+ // here must not burn the scope sentinel.
251
+ let prefixHash = prefixHashArg !== null ? prefixHashArg.trim().toLowerCase() : null;
252
+ if (prefixHash === null && prefixFileArg !== null) {
253
+ let prefixFileBytes;
254
+ try {
255
+ prefixFileBytes = await readFile(path.resolve(process.cwd(), prefixFileArg));
256
+ } catch (err) {
257
+ // ANY read failure (ENOENT, EACCES, EPERM, ...) refuses the review in the
258
+ // normal fail-closed shape — an unreadable prefix file means the
259
+ // invariant-briefing proof cannot be established, which is an enforcement
260
+ // refusal, not a tool crash.
261
+ return finish({
262
+ ok: true,
263
+ fresh: false,
264
+ sentinelCreated: false,
265
+ round: round ?? null,
266
+ reason: `--prefix-file "${prefixFileArg}" unreadable (${err.code ?? "error"}) — cannot compute the invariant-briefing prefix hash (GATE-EXEC-BRIEFING-PREFIX).`,
267
+ }, false);
268
+ }
269
+ prefixHash = createHash("sha256").update(prefixFileBytes).digest("hex");
270
+ }
198
271
  const sentinelPath = path.resolve(process.cwd(), sentinelRelative(scope, round));
199
272
  try {
200
273
  await mkdir(path.dirname(sentinelPath), { recursive: true });
@@ -217,6 +290,7 @@ async function main(argv = process.argv.slice(2)) {
217
290
  pid: process.pid,
218
291
  ...(scope ? { scope } : {}),
219
292
  ...(round ? { round } : {}),
293
+ ...(prefixHash ? { prefixHash } : {}),
220
294
  };
221
295
  try {
222
296
  await writeFile(sentinelPath, JSON.stringify(sentinel, null, 2) + "\n", {
@@ -242,6 +316,7 @@ async function main(argv = process.argv.slice(2)) {
242
316
  sentinelCreated: true,
243
317
  round: round ?? null,
244
318
  ...(contextPathArg !== null ? { gateContextPath: contextPathArg, gateContextPresent: true } : {}),
319
+ ...(prefixHash ? { prefixHash } : {}),
245
320
  }, true);
246
321
  }
247
322
  if (isDirectCliRun(import.meta.url)) {