dev-loops 0.7.1 → 0.7.2

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 (86) 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 +2 -2
  5. package/.claude/agents/review.md +2 -2
  6. package/.claude/commands/loop-enqueue.md +1 -1
  7. package/.claude/commands/loop-info.md +1 -1
  8. package/.claude/commands/loop-start-spike.md +1 -1
  9. package/.claude/skills/copilot-pr-followup/SKILL.md +37 -23
  10. package/.claude/skills/dev-loop/SKILL.md +6 -6
  11. package/.claude/skills/docs/acceptance-criteria-verification.md +3 -2
  12. package/.claude/skills/docs/anti-patterns.md +3 -2
  13. package/.claude/skills/docs/artifact-authority-contract.md +12 -11
  14. package/.claude/skills/docs/confirmation-rules.md +1 -0
  15. package/.claude/skills/docs/contract-style-guide.md +37 -0
  16. package/.claude/skills/docs/copilot-loop-operations.md +18 -14
  17. package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
  18. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  19. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  20. package/.claude/skills/docs/issue-intake-procedure.md +25 -19
  21. package/.claude/skills/docs/merge-preconditions.md +13 -11
  22. package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
  23. package/.claude/skills/docs/public-dev-loop-contract.md +59 -43
  24. package/.claude/skills/docs/retrospective-checkpoint-contract.md +18 -5
  25. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  26. package/.claude/skills/docs/stop-conditions.md +30 -15
  27. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  28. package/.claude/skills/docs/validation-policy.md +4 -5
  29. package/.claude/skills/local-implementation/SKILL.md +48 -142
  30. package/CHANGELOG.md +6 -0
  31. package/agents/dev-loop.agent.md +8 -8
  32. package/agents/fixer.agent.md +1 -0
  33. package/agents/refiner.agent.md +2 -2
  34. package/agents/review.agent.md +2 -2
  35. package/package.json +5 -4
  36. package/scripts/_core-helpers.mjs +2 -0
  37. package/scripts/claude/headless-dev-loop.mjs +1 -1
  38. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  39. package/scripts/docs/validate-state-machine-conformance.mjs +1092 -0
  40. package/scripts/github/_review-thread-mutations.mjs +5 -2
  41. package/scripts/github/create-pr.mjs +119 -10
  42. package/scripts/github/detect-checkpoint-evidence.mjs +102 -17
  43. package/scripts/github/list-review-threads.mjs +277 -0
  44. package/scripts/github/post-gate-findings.mjs +6 -3
  45. package/scripts/github/request-copilot-review.mjs +92 -26
  46. package/scripts/github/upsert-checkpoint-verdict.mjs +74 -5
  47. package/scripts/github/verify-briefing-prefixes.mjs +191 -0
  48. package/scripts/github/verify-fresh-review-context.mjs +78 -3
  49. package/scripts/github/wait-pr-checks.mjs +171 -0
  50. package/scripts/github/write-gate-context.mjs +213 -7
  51. package/scripts/github/write-gate-findings-log.mjs +54 -2
  52. package/scripts/loop/copilot-pr-handoff.mjs +29 -2
  53. package/scripts/loop/detect-change-scope.mjs +2 -2
  54. package/scripts/loop/detect-copilot-loop-state.mjs +23 -3
  55. package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -3
  56. package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
  57. package/scripts/loop/sanctioned-commands.mjs +2 -0
  58. package/scripts/loop/validate-pr-body-spec.mjs +21 -5
  59. package/scripts/pages/build-state-atlas.mjs +50 -48
  60. package/scripts/projects/_resolve-project.mjs +1 -148
  61. package/scripts/projects/list-queue-items.mjs +3 -377
  62. package/scripts/projects/move-queue-item.mjs +3 -410
  63. package/scripts/projects/reorder-queue-item.mjs +3 -22
  64. package/skills/copilot-pr-followup/SKILL.md +37 -23
  65. package/skills/dev-loop/SKILL.md +1 -1
  66. package/skills/docs/acceptance-criteria-verification.md +3 -2
  67. package/skills/docs/anti-patterns.md +3 -2
  68. package/skills/docs/artifact-authority-contract.md +12 -11
  69. package/skills/docs/confirmation-rules.md +1 -0
  70. package/skills/docs/contract-style-guide.md +37 -0
  71. package/skills/docs/copilot-loop-operations.md +18 -14
  72. package/skills/docs/cross-harness-regression-contract.md +2 -2
  73. package/skills/docs/debt-remediation-contract.md +1 -1
  74. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  75. package/skills/docs/issue-intake-procedure.md +25 -19
  76. package/skills/docs/merge-preconditions.md +13 -11
  77. package/skills/docs/pr-lifecycle-contract.md +44 -35
  78. package/skills/docs/public-dev-loop-contract.md +59 -43
  79. package/skills/docs/required-rules.json +149 -0
  80. package/skills/docs/retrospective-checkpoint-contract.md +18 -5
  81. package/skills/docs/spike-mode-contract.md +14 -7
  82. package/skills/docs/stop-conditions.md +30 -15
  83. package/skills/docs/tracker-first-loop-state.md +13 -8
  84. package/skills/docs/validation-policy.md +4 -5
  85. package/skills/local-implementation/SKILL.md +48 -142
  86. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile } from "node:fs/promises";
3
- import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
4
- import { loadDevLoopConfig, resolveGateConfig, resolveRefinementConfig } from "@dev-loops/core/config";
3
+ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText, sanitizeCopilotSummonTokens } from "../_core-helpers.mjs";
4
+ import { loadDevLoopConfig, resolveEffectiveCopilotRoundCap, resolveGateAngleContract, resolveGateConfig, resolveRefinementConfig, resolveRejectForeignAngles } from "@dev-loops/core/config";
5
+ import { checkFanoutAngleCoverage } from "@dev-loops/core/loop/gate-fanin";
5
6
  import { parseArgs } from "node:util";
6
7
  import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
7
8
  import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
@@ -72,6 +73,11 @@ Optional:
72
73
  --gate <draft_gate|pre_approval_gate> Auto-resolved from coordination state
73
74
  when omitted. Explicit gate is validated
74
75
  against allowed coordination actions.
76
+ --lightweight This PR is light-dispatched (#1210):
77
+ resolve the Copilot round cap as
78
+ min(lightMode.maxCopilotRounds ?? 1,
79
+ refinement.maxCopilotRounds) instead of
80
+ refinement.maxCopilotRounds alone.
75
81
  --findings-severity-counts <json> JSON object mapping severity to count
76
82
  (e.g. '{"must-fix":0,"worth-fixing-now":0}').
77
83
  Required for --verdict clean when
@@ -261,6 +267,7 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
261
267
  "findings-severity-counts": { type: "string" },
262
268
  "execution-mode": { type: "string" },
263
269
  "inline-reason": { type: "string" },
270
+ lightweight: { type: "boolean" },
264
271
  ...JQ_OUTPUT_PARSE_OPTIONS,
265
272
  },
266
273
  allowPositionals: true,
@@ -281,6 +288,7 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
281
288
  findingsSeverityCounts: undefined,
282
289
  executionMode: undefined,
283
290
  inlineReason: undefined,
291
+ lightweight: false,
284
292
  jq: undefined,
285
293
  silent: false,
286
294
  };
@@ -306,6 +314,10 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
306
314
  options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
307
315
  continue;
308
316
  }
317
+ if (token.name === "lightweight") {
318
+ options.lightweight = true;
319
+ continue;
320
+ }
309
321
  if (token.name === "gate") {
310
322
  const gate = normalizeGateName(requireTokenValue(token, parseError));
311
323
  if (!gate) {
@@ -742,7 +754,11 @@ export function renderGateReviewCommentBody({ gate, headSha, verdict, findingsSu
742
754
  "",
743
755
  `**Next action:** ${nextAction}`,
744
756
  );
745
- return lines.join("\n");
757
+ // Neutralize any bare @copilot/`/copilot`* tokens in the rendered body (gate
758
+ // evidence legitimately quotes the anti-summon rule, e.g. from a findings
759
+ // excerpt) so posting this comment can never arm request-copilot-review.mjs's
760
+ // anti-summon guard on a later request.
761
+ return sanitizeCopilotSummonTokens(lines.join("\n"));
746
762
  }
747
763
  function resolveRequestedHeadSha(requestedHeadSha, currentHeadSha) {
748
764
  if (requestedHeadSha === currentHeadSha) {
@@ -949,13 +965,18 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
949
965
  // loadPrGateCoordinationContext call will surface the real error.
950
966
  }
951
967
  }
952
- const coordinationContext = await loadPrGateCoordinationContext({ repo: options.repo, pr: options.pr }, { env, ghCommand });
968
+ // Thread the light-dispatch signal (#1210) so the context interpreter and the
969
+ // maxCopilotRounds resolution below both use the composed lightweight cap —
970
+ // the two must never disagree at the cap boundary (#1126).
971
+ const coordinationContext = await loadPrGateCoordinationContext({ repo: options.repo, pr: options.pr, lightweight: options.lightweight === true }, { env, ghCommand });
953
972
  const evidence = coordinationContext.gateEvidence;
954
973
  const canonicalHeadSha = resolveRequestedHeadSha(options.headSha, evidence.currentHeadSha);
955
974
  const { config } = await loadDevLoopConfig({ repoRoot });
956
975
  const draftGateConfig = resolveGateConfig(config, "draft");
957
976
  const preApprovalGateConfig = resolveGateConfig(config, "preApproval");
958
- const maxCopilotRounds = resolveRefinementConfig(config, "maxCopilotRounds");
977
+ const maxCopilotRounds = options.lightweight === true
978
+ ? resolveEffectiveCopilotRoundCap(config, { lightweight: true })
979
+ : resolveRefinementConfig(config, "maxCopilotRounds");
959
980
  // Root cause 2: detect internal-only PRs so the Copilot convergence requirement
960
981
  // is suppressed. Docs-only / tooling-only PRs should go straight to pre_approval_gate
961
982
  // without requiring an external Copilot review cycle.
@@ -982,6 +1003,10 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
982
1003
  copilotReviewRoundCount: coordinationContext.snapshot?.copilotReviewRoundCount ?? 0,
983
1004
  maxCopilotRounds,
984
1005
  sameHeadCleanConverged: coordinationContext.interpretation.sameHeadCleanConverged,
1006
+ // Independent gate-ENTRY re-check (#1190): fed alongside (not derived from)
1007
+ // sameHeadCleanConverged, so an outstanding request on the current head refuses
1008
+ // RUN_PRE_APPROVAL_GATE even if sameHeadCleanConverged were somehow stale/wrong.
1009
+ copilotReviewRequestStatus: coordinationContext.snapshot?.copilotReviewRequestStatus ?? "none",
985
1010
  draftGateRequireCi: draftGateConfig.requireCi,
986
1011
  draftGate: coordinationContext.gateEvidence.draftGate,
987
1012
  draftGateMarker: coordinationContext.gateEvidence.draftGateMarker,
@@ -1122,6 +1147,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
1122
1147
  // multi-line per-angle breakdown and the `**Findings summary:**` line carries a
1123
1148
  // single-line digest (so the marker/parse contract still round-trips).
1124
1149
  let structuredFindings = null;
1150
+ let rawFindingsInput = null;
1125
1151
  if (options.findingsJson) {
1126
1152
  let raw;
1127
1153
  try {
@@ -1140,6 +1166,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
1140
1166
  const candidate = Array.isArray(parsed)
1141
1167
  ? parsed
1142
1168
  : (Array.isArray(parsed?.angles) ? parsed.angles : (Array.isArray(parsed?.findings) ? parsed.findings : null));
1169
+ rawFindingsInput = candidate;
1143
1170
  try {
1144
1171
  structuredFindings = normalizeStructuredFindings(candidate);
1145
1172
  } catch (err) {
@@ -1149,6 +1176,48 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
1149
1176
  throw new Error(`--findings-json "${options.findingsJson}" did not contain any renderable findings (expected a non-empty per-angle array of { angle, findings } entries, or a flat per-finding array of { severity, summary, angle? } entries)`);
1150
1177
  }
1151
1178
  }
1179
+ // Fan-out angle-coverage enforcement (fail closed): a fanout_fanin verdict's
1180
+ // structured per-angle results must cover every configured mandatory angle,
1181
+ // and (default) must not name an angle outside the gate's configured pool.
1182
+ // Only applies when structured per-angle results were actually supplied —
1183
+ // a free-text --findings-summary fanout_fanin verdict carries no per-angle
1184
+ // data to validate.
1185
+ if (structuredFindings && (options.executionMode ?? DEFAULT_EXECUTION_MODE) === "fanout_fanin") {
1186
+ // Angle-less entries would be bucketed under the synthetic `general` label
1187
+ // by normalization and then surface as a CONFUSING foreign-angle error.
1188
+ // Fail first with a dedicated message naming the real problem instead.
1189
+ const angleless = (rawFindingsInput ?? []).filter(
1190
+ (e) => !e || typeof e !== "object" || typeof e.angle !== "string" || e.angle.trim().length === 0,
1191
+ ).length;
1192
+ if (angleless > 0) {
1193
+ throw new Error(
1194
+ `--findings-json for ${options.gate}: ${angleless} entr${angleless === 1 ? "y" : "ies"} lack a non-empty .angle — a fanout_fanin verdict must attribute every per-angle entry/finding to its review angle (use the nested [{ angle, verdict, findings }] shape, or add .angle to each flat finding)`,
1195
+ );
1196
+ }
1197
+ const gateKey = options.gate === "draft_gate" ? "draft" : "preApproval";
1198
+ const { mandatoryAngles, pool } = resolveGateAngleContract(config, gateKey);
1199
+ const { missingMandatory, foreignAngles } = checkFanoutAngleCoverage(structuredFindings, {
1200
+ mandatoryAngles,
1201
+ pool,
1202
+ });
1203
+ if (missingMandatory.length > 0) {
1204
+ throw new Error(
1205
+ `--findings-json for ${options.gate} is missing mandatory angle(s): ${missingMandatory.join(", ")} (configured in gates.${gateKey}.mandatoryAngles; add a per-angle entry for each before posting a fanout_fanin verdict)`,
1206
+ );
1207
+ }
1208
+ if (foreignAngles.length > 0) {
1209
+ const message = `--findings-json for ${options.gate} names angle(s) outside the configured pool: ${foreignAngles.join(", ")}`;
1210
+ if (resolveRejectForeignAngles(config)) {
1211
+ throw new Error(
1212
+ `${message} (add them to gates.${gateKey}.angles, or set gates.rejectForeignAngles: false to warn instead of fail)`,
1213
+ );
1214
+ }
1215
+ // rejectForeignAngles: false is WARNING mode, not silence — one line per call.
1216
+ if (!options.silent) {
1217
+ process.stderr.write(`WARNING: ${message} (gates.rejectForeignAngles is false; recorded as a warning)\n`);
1218
+ }
1219
+ }
1220
+ }
1152
1221
  // --findings-json takes precedence; when structured findings are present, do not
1153
1222
  // read --findings-file at all (avoids a spurious hard failure if a caller passes
1154
1223
  // both and the file is missing/invalid even though it would be ignored anyway).
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ import { readdir, readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
5
+ import { JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
6
+
7
+ const USAGE = `Usage: verify-briefing-prefixes.mjs --head-sha <sha> [--help]
8
+ 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.
13
+
14
+ Required:
15
+ --head-sha <sha> The FULL 40-char reviewed head SHA (git rev-parse HEAD); sentinels are read from
16
+ tmp/checkpoint-context-sentinel-<scope>-<headSha>.json.
17
+
18
+ Output (stdout, JSON):
19
+ { "ok": true, "verified": true, "headSha": "...", "reviewerCount": <n>, "prefixHash": "..." }
20
+ { "ok": true, "verified": true, "headSha": "...", "reviewerCount": 0, "reason": "no sentinels found for this round" }
21
+ { "ok": true, "verified": false, "headSha": "...", "reviewerCount": <n>, "reason": "...", "missing": [...], "mismatched": [...] }
22
+ On error (stderr, JSON):
23
+ { "ok": false, "error": "...", "usage": "..." }
24
+ ${JQ_OUTPUT_USAGE}
25
+ 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
32
+ 2 Usage or internal error, or invalid --jq filter
33
+
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();
39
+
40
+ // Full 40-char SHA required: sentinel filenames embed the full `git rev-parse
41
+ // HEAD` value, so a short prefix would glob zero sentinels and read as a
42
+ // vacuous pass — fail closed on anything shorter instead.
43
+ const HEAD_SHA_RE = /^[0-9a-f]{40}$/i;
44
+ const SHA256_RE = /^[0-9a-f]{64}$/;
45
+ const SENTINEL_PREFIX = "checkpoint-context-sentinel-";
46
+ const parseError = buildParseError(USAGE);
47
+
48
+ function resolveFlagValue(argv, flag) {
49
+ const idx = argv.indexOf(flag);
50
+ if (idx === -1) return null;
51
+ const val = argv[idx + 1];
52
+ if (val === undefined || val === "" || (val.length > 0 && val[0] === "-")) {
53
+ return ""; // provided but missing/empty/flag-like
54
+ }
55
+ return val;
56
+ }
57
+
58
+ /**
59
+ * Read every sentinel for the given round (head SHA) from `<tmpRoot>/`, extracting
60
+ * the reviewer scope (from the filename) and recorded `prefixHash` (from the JSON
61
+ * body, when present). A malformed/unreadable sentinel is still COUNTED, with
62
+ * `prefixHash: null` — downstream that reads as a missing hash and FAILS CLOSED,
63
+ * deliberately: a corrupt sentinel means the invariant-prefix proof cannot be
64
+ * verified for that reviewer, and silently dropping it would fail open.
65
+ * @param {string} tmpRoot
66
+ * @param {string} headSha — lowercase hex, already validated
67
+ * @returns {Promise<Array<{ scope: string, prefixHash: string|null }>>}
68
+ */
69
+ async function readRoundSentinels(tmpRoot, headSha) {
70
+ const suffix = `-${headSha}.json`;
71
+ let entries;
72
+ try {
73
+ entries = await readdir(tmpRoot, { withFileTypes: true });
74
+ } catch (err) {
75
+ if (err.code === "ENOENT") return [];
76
+ throw err;
77
+ }
78
+ const matches = entries
79
+ .filter((e) => e.isFile() && e.name.startsWith(SENTINEL_PREFIX) && e.name.endsWith(suffix))
80
+ .map((e) => ({
81
+ file: e.name,
82
+ scope: e.name.slice(SENTINEL_PREFIX.length, -suffix.length),
83
+ }))
84
+ .filter((m) => m.scope.length > 0) // exclude the round-only (no-scope) sentinel name shape
85
+ // readdir order is filesystem-dependent; sort by scope so missing/mismatched
86
+ // output is deterministic across runs (this is a deterministic fan-in check).
87
+ .sort((a, b) => a.scope.localeCompare(b.scope));
88
+ const results = [];
89
+ for (const { file, scope } of matches) {
90
+ let prefixHash = null;
91
+ try {
92
+ const raw = await readFile(path.join(tmpRoot, file), "utf8");
93
+ const parsed = JSON.parse(raw);
94
+ // Only a well-formed sha256 counts as a recorded hash; anything else
95
+ // (corrupted/hand-edited value) is treated as missing so the round
96
+ // fails closed rather than comparing garbage.
97
+ if (parsed && typeof parsed.prefixHash === "string" && SHA256_RE.test(parsed.prefixHash.toLowerCase().trim())) {
98
+ prefixHash = parsed.prefixHash.toLowerCase().trim();
99
+ }
100
+ } catch {
101
+ // Malformed/unreadable sentinel: counted with prefixHash null so the
102
+ // evaluation fails closed on it (see the function doc comment).
103
+ }
104
+ results.push({ scope, prefixHash });
105
+ }
106
+ return results;
107
+ }
108
+
109
+ /**
110
+ * Pure comparison: given the round's sentinels, decide verified/reason/missing/mismatched.
111
+ * Exported for direct unit testing without touching the filesystem.
112
+ * @param {Array<{ scope: string, prefixHash: string|null }>} sentinels
113
+ * @returns {{ verified: boolean, reason?: string, missing?: string[], mismatched?: Array<{scope:string, prefixHash:string}> }}
114
+ */
115
+ export function evaluateBriefingPrefixes(sentinels) {
116
+ if (sentinels.length === 0) {
117
+ return { verified: true, reason: "no sentinels found for this round" };
118
+ }
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
+ const missing = sentinels.filter((s) => s.prefixHash === null).map((s) => s.scope);
125
+ if (missing.length > 0) {
126
+ return {
127
+ verified: false,
128
+ 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.`,
129
+ missing,
130
+ };
131
+ }
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);
136
+ }
137
+ if (distinct.size > 1) {
138
+ const mismatched = sentinels.map(({ scope, prefixHash }) => ({ scope, prefixHash }));
139
+ return {
140
+ verified: false,
141
+ reason: `Reviewer sentinels for this round recorded ${distinct.size} DIFFERENT invariant-briefing prefix hashes — the seeded briefings were not byte-identical (GATE-EXEC-BRIEFING-PREFIX).`,
142
+ mismatched,
143
+ };
144
+ }
145
+ return { verified: true, prefixHash: sentinels[0].prefixHash };
146
+ }
147
+
148
+ export async function main(argv = process.argv.slice(2), { tmpRoot = path.join(process.cwd(), "tmp") } = {}) {
149
+ if (argv.includes("--help") || argv.includes("-h")) {
150
+ process.stdout.write(`${USAGE}\n`);
151
+ return 0;
152
+ }
153
+ const headShaArg = resolveFlagValue(argv, "--head-sha");
154
+ if (headShaArg === null || headShaArg === "" || !HEAD_SHA_RE.test(headShaArg)) {
155
+ process.stderr.write(`${formatCliError(
156
+ 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)})` : ""}.`)
157
+ )}\n`);
158
+ return 2;
159
+ }
160
+ const headSha = headShaArg.toLowerCase();
161
+ const jqArg = resolveFlagValue(argv, "--jq");
162
+ if (jqArg === "") {
163
+ process.stderr.write(`${formatCliError(parseError("Invalid --jq value: must be non-empty."))}\n`);
164
+ return 2;
165
+ }
166
+ const jq = jqArg === null ? undefined : jqArg;
167
+ const silent = argv.includes("--silent") || argv.includes("-s");
168
+
169
+ const sentinels = await readRoundSentinels(tmpRoot, headSha);
170
+ const verdict = evaluateBriefingPrefixes(sentinels);
171
+ const payload = {
172
+ ok: true,
173
+ verified: verdict.verified,
174
+ headSha,
175
+ reviewerCount: sentinels.length,
176
+ ...(verdict.reason ? { reason: verdict.reason } : {}),
177
+ ...(verdict.missing ? { missing: verdict.missing } : {}),
178
+ ...(verdict.mismatched ? { mismatched: verdict.mismatched } : {}),
179
+ ...(verdict.prefixHash ? { prefixHash: verdict.prefixHash } : {}),
180
+ };
181
+ return emitResult(payload, { jq, silent, ok: verdict.verified });
182
+ }
183
+
184
+ if (isDirectCliRun(import.meta.url)) {
185
+ try {
186
+ process.exitCode = await main();
187
+ } catch (err) {
188
+ process.stderr.write(`${formatCliError(err)}\n`);
189
+ process.exitCode = 2;
190
+ }
191
+ }
@@ -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)) {
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
4
+ import { parseNonNegativeInteger, parsePositiveInteger, parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
5
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
7
+ import {
8
+ DEFAULT_POLL_INTERVAL_MS,
9
+ COPILOT_REVIEW_WAIT_TIMEOUT_MS,
10
+ } from "@dev-loops/core/loop/policy-constants";
11
+ // Reuse probe-ci-status.mjs's watch engine verbatim (polling, heartbeats, the
12
+ // not-yet-registered-check grace/race guard, head-change detection) instead of
13
+ // re-implementing it. This wrapper only adds seconds-based flags and a direct
14
+ // process-exit-code contract for shell/scripted callers.
15
+ import { watchCiStatus } from "./probe-ci-status.mjs";
16
+
17
+ const DEFAULT_TIMEOUT_SECONDS = Math.floor(COPILOT_REVIEW_WAIT_TIMEOUT_MS / 1000);
18
+ const DEFAULT_POLL_SECONDS = Math.floor(DEFAULT_POLL_INTERVAL_MS / 1000);
19
+
20
+ const USAGE = `Usage: wait-pr-checks.mjs --repo <owner/name> --pr <number> [--timeout <seconds>] [--poll <seconds>]
21
+ Block until the current head SHA's CI checks/statuses settle, or the wait
22
+ budget elapses. Replaces hand-rolled \`until [ "$(gh pr checks | grep -c pending)" = 0 ]\`
23
+ shell loops with a single deterministic call.
24
+ Required:
25
+ --repo <owner/name> Repository slug (e.g. owner/repo)
26
+ --pr <number> Pull request number
27
+ Optional:
28
+ --timeout <seconds> Total wait budget (default ${DEFAULT_TIMEOUT_SECONDS}; 0 = single
29
+ immediate check, no wait)
30
+ --poll <seconds> Delay between polls (default ${DEFAULT_POLL_SECONDS})
31
+ Not-yet-registered-check race guard:
32
+ A freshly pushed head with zero registered checks does NOT settle green on
33
+ the first poll — a provider may post its first check a beat after the push.
34
+ The watcher requires either an observed check/status, or a stable empty
35
+ result across two consecutive polls, before treating the head as genuinely
36
+ check-less. A repo with no CI at all still settles after that grace instead
37
+ of hanging to timeout.
38
+ Output (stdout, JSON, the final per-check summary):
39
+ { "ok": true, "status": "success"|"failure"|"pending"|"timeout"|"changed",
40
+ "settled": bool, "ciStatus": "success"|"failure"|"pending"|"none",
41
+ "failedChecks": [{ "name": "...", "conclusion"?: "..." }], "headSha": "...", "attempts": N }
42
+ "changed" means the head SHA advanced during the wait; re-baseline and re-run.
43
+ Diagnostic output (stderr):
44
+ { "ok": true, "type": "watch_heartbeat", "elapsedMs": N, "totalBudgetMs": N, "poll": N, "maxPolls": N }
45
+ { "ok": false, "error": "...", "usage"?: "..." }
46
+ ${JQ_OUTPUT_USAGE}
47
+ Exit codes (default output, no --jq/--silent):
48
+ 0 Green (status "success")
49
+ 1 Red (status "failure"), or an argument/gh/runtime error
50
+ 2 Not settled (status "timeout"/"changed"/"pending")
51
+ With --jq/--silent, the standard jq-output contract above applies instead
52
+ (0/1 by result truthiness; 2 reserved for an invalid --jq filter).`.trim();
53
+
54
+ const parseError = buildParseError(USAGE);
55
+
56
+ export function parseWaitPrChecksCliArgs(argv) {
57
+ const { tokens } = parseArgs({
58
+ args: [...argv],
59
+ options: {
60
+ help: { type: "boolean", short: "h" },
61
+ repo: { type: "string" },
62
+ pr: { type: "string" },
63
+ timeout: { type: "string" },
64
+ poll: { type: "string" },
65
+ ...JQ_OUTPUT_PARSE_OPTIONS,
66
+ },
67
+ allowPositionals: true,
68
+ strict: false,
69
+ tokens: true,
70
+ });
71
+ const options = {
72
+ help: false,
73
+ repo: undefined,
74
+ pr: undefined,
75
+ timeoutMs: DEFAULT_TIMEOUT_SECONDS * 1000,
76
+ pollIntervalMs: DEFAULT_POLL_SECONDS * 1000,
77
+ };
78
+ for (const token of tokens) {
79
+ if (token.kind === "positional") {
80
+ throw parseError(`Unknown argument: ${token.value}`);
81
+ }
82
+ if (token.kind !== "option") {
83
+ continue;
84
+ }
85
+ if (token.name === "help") {
86
+ options.help = true;
87
+ return options;
88
+ }
89
+ if (token.name === "repo") {
90
+ options.repo = requireTokenValue(token, parseError).trim();
91
+ continue;
92
+ }
93
+ if (token.name === "pr") {
94
+ options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
95
+ continue;
96
+ }
97
+ if (token.name === "timeout") {
98
+ // Canonical-digits validation (shared /^\d+$/ primitives): rejects
99
+ // JS-coercible spellings like 1e3, 0x10, "1.0", or " 5 ".
100
+ options.timeoutMs = parseNonNegativeInteger(requireTokenValue(token, parseError), "--timeout", parseError) * 1000;
101
+ continue;
102
+ }
103
+ if (token.name === "poll") {
104
+ options.pollIntervalMs = parsePositiveInteger(requireTokenValue(token, parseError), "--poll", parseError) * 1000;
105
+ continue;
106
+ }
107
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
108
+ throw parseError(`Unknown argument: ${token.rawName}`);
109
+ }
110
+ if (options.repo === undefined || options.pr === undefined) {
111
+ throw parseError("Waiting on PR checks requires both --repo <owner/name> and --pr <number>");
112
+ }
113
+ try {
114
+ parseRepoSlug(options.repo);
115
+ } catch (error) {
116
+ throw parseError(error instanceof Error ? error.message : String(error));
117
+ }
118
+ return options;
119
+ }
120
+
121
+ // Direct process-exit-code contract for shell/scripted callers — this is the
122
+ // tool's reason to exist alongside `dev-loops loop watch-ci`, whose CLI always
123
+ // exits 0 and expects callers to branch on the JSON `status` field instead.
124
+ export function exitCodeForWaitResult(result) {
125
+ if (result.status === "success") return 0;
126
+ if (result.status === "failure") return 1;
127
+ return 2;
128
+ }
129
+
130
+ export async function runCli(
131
+ argv = process.argv.slice(2),
132
+ {
133
+ stdout = process.stdout,
134
+ stderr = process.stderr,
135
+ env = process.env,
136
+ ghCommand = "gh",
137
+ delayImpl = undefined,
138
+ now = undefined,
139
+ } = {},
140
+ ) {
141
+ const options = parseWaitPrChecksCliArgs(argv);
142
+ if (options.help) {
143
+ stdout.write(`${USAGE}\n`);
144
+ return 0;
145
+ }
146
+ const result = await watchCiStatus(
147
+ { repo: options.repo, pr: options.pr, timeoutMs: options.timeoutMs, pollIntervalMs: options.pollIntervalMs },
148
+ {
149
+ env,
150
+ ghCommand,
151
+ ...(delayImpl ? { delayImpl } : {}),
152
+ ...(now ? { now } : {}),
153
+ },
154
+ );
155
+ if (options.jq !== undefined || options.silent) {
156
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr, ok: result.status === "success" });
157
+ }
158
+ stdout.write(`${JSON.stringify(result)}\n`);
159
+ return exitCodeForWaitResult(result);
160
+ }
161
+
162
+ if (isDirectCliRun(import.meta.url)) {
163
+ runCli().then((code) => {
164
+ if (typeof code === "number") {
165
+ process.exitCode = code;
166
+ }
167
+ }).catch((error) => {
168
+ process.stderr.write(`${formatCliError(error)}\n`);
169
+ process.exitCode = 1;
170
+ });
171
+ }