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.
- package/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +7 -7
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/refiner.md +4 -4
- package/.claude/agents/review.md +10 -13
- package/.claude/commands/loop-continue.md +2 -1
- package/.claude/commands/loop-enqueue.md +9 -2
- package/.claude/commands/loop-info.md +1 -1
- package/.claude/commands/loop-start-spike.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 +42 -28
- package/.claude/skills/dev-loop/SKILL.md +8 -8
- package/.claude/skills/docs/acceptance-criteria-verification.md +13 -4
- package/.claude/skills/docs/anti-patterns.md +6 -5
- package/.claude/skills/docs/artifact-authority-contract.md +17 -14
- package/.claude/skills/docs/confirmation-rules.md +2 -1
- package/.claude/skills/docs/contract-style-guide.md +37 -0
- package/.claude/skills/docs/copilot-loop-operations.md +21 -15
- package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
- package/.claude/skills/docs/debt-remediation-contract.md +1 -1
- package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/.claude/skills/docs/issue-intake-procedure.md +37 -22
- package/.claude/skills/docs/merge-preconditions.md +17 -13
- package/.claude/skills/docs/pr-lifecycle-contract.md +45 -36
- package/.claude/skills/docs/public-dev-loop-contract.md +61 -44
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +25 -9
- package/.claude/skills/docs/spike-mode-contract.md +14 -7
- package/.claude/skills/docs/stop-conditions.md +30 -15
- package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
- package/.claude/skills/docs/validation-policy.md +4 -5
- package/.claude/skills/local-implementation/SKILL.md +49 -143
- package/.claude/skills/loop-grill/SKILL.md +34 -14
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +36 -0
- package/README.md +95 -189
- package/agents/dev-loop.agent.md +8 -8
- package/agents/fixer.agent.md +1 -0
- package/agents/refiner.agent.md +4 -4
- package/agents/review.agent.md +10 -13
- package/extension/README.md +5 -4
- package/package.json +7 -5
- package/scripts/_core-helpers.mjs +2 -0
- package/scripts/claude/headless-dev-loop.mjs +1 -1
- package/scripts/docs/validate-rule-ownership.mjs +442 -0
- package/scripts/docs/validate-state-machine-conformance.mjs +1169 -0
- package/scripts/github/_gate-names.mjs +5 -0
- package/scripts/github/_review-thread-mutations.mjs +5 -2
- package/scripts/github/capture-review-threads.mjs +2 -2
- package/scripts/github/create-pr.mjs +119 -10
- package/scripts/github/detect-checkpoint-evidence.mjs +109 -24
- package/scripts/github/edit-issue.mjs +259 -0
- package/scripts/github/list-review-threads.mjs +277 -0
- package/scripts/github/post-gate-findings.mjs +6 -3
- 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 +109 -42
- package/scripts/github/upsert-checkpoint-verdict.mjs +98 -27
- package/scripts/github/verify-briefing-prefixes.mjs +382 -0
- package/scripts/github/verify-fresh-review-context.mjs +78 -3
- package/scripts/github/wait-pr-checks.mjs +171 -0
- package/scripts/github/write-gate-context.mjs +220 -10
- package/scripts/github/write-gate-findings-log.mjs +54 -2
- 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 +47 -16
- package/scripts/loop/detect-change-scope.mjs +2 -2
- package/scripts/loop/detect-copilot-loop-state.mjs +34 -14
- package/scripts/loop/detect-internal-only-pr.mjs +6 -6
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +152 -18
- package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
- package/scripts/loop/run-watch-cycle.mjs +42 -7
- package/scripts/loop/sanctioned-commands.mjs +3 -0
- package/scripts/loop/validate-pr-body-spec.mjs +21 -5
- package/scripts/pages/build-state-atlas.mjs +65 -48
- package/scripts/projects/_resolve-project.mjs +1 -148
- package/scripts/projects/add-queue-item.mjs +87 -4
- package/scripts/projects/list-queue-items.mjs +3 -377
- package/scripts/projects/move-queue-item.mjs +3 -410
- package/scripts/projects/reorder-queue-item.mjs +3 -22
- package/skills/copilot-pr-followup/SKILL.md +42 -28
- package/skills/dev-loop/SKILL.md +3 -3
- package/skills/docs/acceptance-criteria-verification.md +13 -4
- package/skills/docs/anti-patterns.md +6 -5
- package/skills/docs/artifact-authority-contract.md +17 -14
- package/skills/docs/confirmation-rules.md +2 -1
- package/skills/docs/contract-style-guide.md +37 -0
- package/skills/docs/copilot-loop-operations.md +21 -15
- package/skills/docs/cross-harness-regression-contract.md +2 -2
- package/skills/docs/debt-remediation-contract.md +1 -1
- package/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/skills/docs/issue-intake-procedure.md +37 -22
- package/skills/docs/merge-preconditions.md +17 -13
- package/skills/docs/pr-lifecycle-contract.md +45 -36
- package/skills/docs/public-dev-loop-contract.md +61 -44
- package/skills/docs/required-rules.json +165 -0
- package/skills/docs/retrospective-checkpoint-contract.md +25 -9
- package/skills/docs/spike-mode-contract.md +14 -7
- package/skills/docs/stop-conditions.md +30 -15
- package/skills/docs/tracker-first-loop-state.md +13 -8
- package/skills/docs/validation-policy.md +4 -5
- package/skills/local-implementation/SKILL.md +49 -143
- package/skills/loop-grill/SKILL.md +38 -17
- 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
|
-
|
|
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)) {
|