dev-loops 0.6.0 → 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.
- package/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +8 -7
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/refiner.md +2 -2
- package/.claude/agents/review.md +4 -3
- package/.claude/commands/{auto.md → loop-auto.md} +1 -1
- package/.claude/commands/loop-continue.md +15 -0
- package/.claude/commands/loop-enqueue.md +22 -0
- package/.claude/commands/loop-grill.md +17 -0
- package/.claude/commands/{info.md → loop-info.md} +2 -2
- package/.claude/commands/loop-queue-status.md +24 -0
- package/.claude/commands/{start-spike.md → loop-start-spike.md} +2 -2
- package/.claude/commands/{start.md → loop-start.md} +1 -1
- package/.claude/commands/{status.md → loop-status.md} +1 -1
- package/.claude/hooks/_bash-command-classify.mjs +333 -29
- package/.claude/hooks/_hook-decisions.mjs +138 -15
- package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
- package/.claude/skills/copilot-pr-followup/SKILL.md +60 -34
- package/.claude/skills/dev-loop/SKILL.md +16 -12
- package/.claude/skills/docs/acceptance-criteria-verification.md +8 -3
- package/.claude/skills/docs/anti-patterns.md +5 -3
- package/.claude/skills/docs/artifact-authority-contract.md +30 -11
- package/.claude/skills/docs/confirmation-rules.md +1 -0
- package/.claude/skills/docs/contract-style-guide.md +37 -0
- package/.claude/skills/docs/copilot-loop-operations.md +19 -15
- package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
- 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 +26 -19
- package/.claude/skills/docs/local-planning-flow.md +1 -1
- package/.claude/skills/docs/local-planning-worked-example.md +1 -1
- package/.claude/skills/docs/merge-preconditions.md +30 -12
- package/.claude/skills/docs/plan-file-contract.md +1 -1
- package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
- package/.claude/skills/docs/public-dev-loop-contract.md +60 -44
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +106 -79
- 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/ui-e2e-scoping-step.md +32 -0
- package/.claude/skills/docs/validation-policy.md +4 -5
- package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
- package/.claude/skills/local-implementation/SKILL.md +62 -145
- package/.claude/skills/loop-grill/SKILL.md +163 -0
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +101 -0
- package/README.md +9 -9
- package/agents/dev-loop.agent.md +11 -10
- package/agents/developer.agent.md +1 -1
- package/agents/docs.agent.md +1 -1
- package/agents/fixer.agent.md +2 -1
- package/agents/quality.agent.md +1 -1
- package/agents/refiner.agent.md +3 -3
- package/agents/review.agent.md +5 -4
- package/cli/index.mjs +35 -42
- package/extension/README.md +4 -4
- package/extension/checks.ts +1 -5
- package/extension/harness-types.ts +1 -0
- package/extension/index.ts +6 -0
- package/extension/pi-extension-adapter.ts +2 -0
- package/extension/presentation.ts +7 -19
- package/package.json +12 -12
- 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 +1092 -0
- package/scripts/github/_review-thread-mutations.mjs +5 -2
- package/scripts/github/capture-review-threads.mjs +2 -9
- package/scripts/github/comment-issue.mjs +2 -9
- package/scripts/github/create-label.mjs +133 -0
- package/scripts/github/create-pr.mjs +119 -10
- package/scripts/github/detect-checkpoint-evidence.mjs +259 -18
- package/scripts/github/detect-linked-issue-pr.mjs +22 -6
- package/scripts/github/edit-pr.mjs +259 -0
- package/scripts/github/fetch-ci-logs.mjs +2 -9
- package/scripts/github/list-issues.mjs +2 -9
- package/scripts/github/list-review-threads.mjs +277 -0
- package/scripts/github/manage-sub-issues.mjs +46 -10
- package/scripts/github/offer-human-handoff.mjs +8 -5
- package/scripts/github/post-gate-findings.mjs +20 -5
- package/scripts/github/probe-ci-status.mjs +8 -2
- package/scripts/github/probe-copilot-review.mjs +21 -14
- package/scripts/github/ready-for-review.mjs +26 -8
- package/scripts/github/reconcile-draft-gate.mjs +7 -3
- package/scripts/github/reply-resolve-review-thread.mjs +16 -5
- package/scripts/github/reply-resolve-review-threads.mjs +69 -7
- package/scripts/github/request-copilot-review.mjs +98 -26
- package/scripts/github/resolve-handoff-candidates.mjs +7 -3
- package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
- package/scripts/github/stage-reviewer-draft.mjs +10 -2
- package/scripts/github/tick-verified-checkboxes.mjs +202 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +105 -15
- package/scripts/github/verify-briefing-prefixes.mjs +191 -0
- package/scripts/github/verify-fresh-review-context.mjs +226 -32
- package/scripts/github/view-pr.mjs +150 -0
- package/scripts/github/wait-pr-checks.mjs +171 -0
- package/scripts/github/write-gate-context.mjs +458 -65
- package/scripts/github/write-gate-findings-log.mjs +128 -2
- package/scripts/lib/jq-output.mjs +18 -0
- package/scripts/loop/_post-convergence-change.mjs +211 -0
- package/scripts/loop/_pr-runner-coordination.mjs +70 -0
- package/scripts/loop/_repo-root-resolver.mjs +47 -0
- package/scripts/loop/build-handoff-envelope.mjs +14 -4
- package/scripts/loop/check-retro-tooling.mjs +14 -3
- package/scripts/loop/checkpoint-contract.mjs +7 -4
- package/scripts/loop/cleanup-worktree.mjs +12 -3
- package/scripts/loop/conductor-monitor.mjs +12 -18
- package/scripts/loop/copilot-pr-handoff.mjs +162 -14
- package/scripts/loop/debt-remediate.mjs +24 -12
- package/scripts/loop/detect-change-scope.mjs +38 -9
- package/scripts/loop/detect-copilot-loop-state.mjs +34 -8
- package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
- package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
- package/scripts/loop/detect-internal-only-pr.mjs +8 -2
- package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +184 -69
- package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
- package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
- package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
- package/scripts/loop/ensure-worktree.mjs +8 -3
- package/scripts/loop/info.mjs +12 -4
- package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
- package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
- package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
- package/scripts/loop/inspect-run-viewer.mjs +67 -4
- package/scripts/loop/inspect-run.mjs +8 -2
- package/scripts/loop/outer-loop.mjs +8 -4
- package/scripts/loop/pr-runner-coordination.mjs +2 -9
- package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
- package/scripts/loop/pre-flight-gate.mjs +9 -9
- package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
- package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
- package/scripts/loop/provision-worktree.mjs +74 -3
- package/scripts/loop/resolve-dev-loop-startup.mjs +216 -10
- package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
- package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
- package/scripts/loop/run-conductor-cycle.mjs +8 -2
- package/scripts/loop/run-queue.mjs +18 -9
- package/scripts/loop/run-refinement-audit.mjs +8 -9
- package/scripts/loop/run-watch-cycle.mjs +2 -9
- package/scripts/loop/sanctioned-commands.mjs +106 -0
- package/scripts/loop/steer-loop.mjs +29 -16
- package/scripts/loop/validate-pr-body-spec.mjs +223 -0
- package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
- package/scripts/pages/build-site.mjs +59 -8
- package/scripts/pages/build-state-atlas.mjs +443 -0
- package/scripts/projects/_resolve-project.mjs +1 -0
- package/scripts/projects/add-queue-item.mjs +60 -54
- package/scripts/projects/archive-done-items.mjs +49 -84
- package/scripts/projects/ensure-queue-board.mjs +10 -36
- package/scripts/projects/list-queue-items.mjs +59 -382
- package/scripts/projects/move-queue-item.mjs +21 -449
- package/scripts/projects/reconcile-queue.mjs +253 -0
- package/scripts/projects/reorder-queue-item.mjs +43 -68
- package/scripts/projects/resolve-active-board-item.mjs +108 -46
- package/scripts/projects/sync-item-status.mjs +15 -10
- package/scripts/refine/_refine-helpers.mjs +21 -5
- package/scripts/refine/exit-spike.mjs +18 -8
- package/scripts/refine/promote-plan.mjs +22 -16
- package/scripts/refine/prose-linkage-detector.mjs +1 -1
- package/scripts/refine/refine-plan-file.mjs +13 -4
- package/scripts/refine/refinement-completeness-checker.mjs +1 -1
- package/scripts/refine/scaffold-spike-file.mjs +4 -8
- package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
- package/scripts/refine/tree-integrity-validator.mjs +1 -1
- package/scripts/refine/validate-plan-file.mjs +1 -1
- package/scripts/refine/validate-spike-file.mjs +1 -1
- package/scripts/refine/verify.mjs +11 -6
- package/scripts/release/assert-core-dependency-version.mjs +123 -0
- package/scripts/release/extract-changelog-section.mjs +16 -2
- package/skills/copilot-pr-followup/SKILL.md +60 -34
- package/skills/dev-loop/SKILL.md +11 -7
- package/skills/docs/acceptance-criteria-verification.md +8 -3
- package/skills/docs/anti-patterns.md +5 -3
- package/skills/docs/artifact-authority-contract.md +30 -11
- package/skills/docs/confirmation-rules.md +1 -0
- package/skills/docs/contract-style-guide.md +37 -0
- package/skills/docs/copilot-loop-operations.md +19 -15
- package/skills/docs/cross-harness-regression-contract.md +60 -0
- 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 +26 -19
- package/skills/docs/local-planning-flow.md +1 -1
- package/skills/docs/local-planning-worked-example.md +1 -1
- package/skills/docs/merge-preconditions.md +30 -12
- package/skills/docs/plan-file-contract.md +1 -1
- package/skills/docs/pr-lifecycle-contract.md +44 -35
- package/skills/docs/public-dev-loop-contract.md +60 -44
- package/skills/docs/required-rules.json +149 -0
- package/skills/docs/retrospective-checkpoint-contract.md +106 -79
- 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/ui-e2e-scoping-step.md +32 -0
- package/skills/docs/validation-policy.md +4 -5
- package/skills/docs/workflow-handoff-contract.md +39 -0
- package/skills/local-implementation/SKILL.md +62 -145
- package/skills/loop-grill/SKILL.md +165 -0
- package/.claude/commands/continue.md +0 -15
- package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
- package/scripts/loop/conductor.mjs +0 -233
- package/scripts/loop/detect-stale-runner.mjs +0 -265
- package/scripts/loop/pre-push-main-guard.mjs +0 -117
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Mirror the issue-body AC/DoD tick procedure onto the PR body: after a clean
|
|
3
|
+
// pre_approval_gate verification, flip each verified item's `- [ ]` to `- [x]`
|
|
4
|
+
// in the PR body via a single `gh pr edit --body-file` update. Matches labels by
|
|
5
|
+
// EXACT (trimmed) text and fails closed — never blanket-checks, never unchecks.
|
|
6
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { parseArgs } from "node:util";
|
|
10
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
11
|
+
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
12
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
13
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
14
|
+
import { editPr } from "./edit-pr.mjs";
|
|
15
|
+
import { viewPr } from "./view-pr.mjs";
|
|
16
|
+
|
|
17
|
+
const USAGE = `Usage: tick-verified-checkboxes.mjs --repo <owner/name> --pr <number> --verified <label> [--verified <label>...] [--dry-run]
|
|
18
|
+
Tick verified acceptance-criteria checkboxes in a PR body. Mirrors the issue-body
|
|
19
|
+
tick from skills/docs/acceptance-criteria-verification.md onto the PR body so the
|
|
20
|
+
merged PR shows checked AC/DoD. Flips \`- [ ] <label>\` to \`- [x] <label>\` ONLY when
|
|
21
|
+
<label> exactly equals a --verified label (exact-match, fail-closed: never blanket-
|
|
22
|
+
checks, never unchecks). Applies a single \`gh pr edit --body-file\` update.
|
|
23
|
+
Required:
|
|
24
|
+
--repo <owner/name> Repository slug (e.g. owner/repo)
|
|
25
|
+
--pr <number> Pull request number
|
|
26
|
+
--verified <label> An exact (trimmed) checklist label that was verified
|
|
27
|
+
(repeatable; at least one is required)
|
|
28
|
+
Options:
|
|
29
|
+
--dry-run Compute + report the flips but never edit the PR
|
|
30
|
+
Output (stdout, JSON):
|
|
31
|
+
{ "ok": true, "pr": 17, "flipped": [...], "unmatched": [...], "edited": true|false }
|
|
32
|
+
Error output (stderr, JSON):
|
|
33
|
+
{ "ok": false, "error": "...", "usage"?: "..." }
|
|
34
|
+
${JQ_OUTPUT_USAGE}
|
|
35
|
+
Exit codes:
|
|
36
|
+
0 Success
|
|
37
|
+
1 Argument error or gh failure
|
|
38
|
+
2 Invalid --jq filter`.trim();
|
|
39
|
+
const parseError = buildParseError(USAGE);
|
|
40
|
+
|
|
41
|
+
// GFM checklist line: optional indentation, a `-`/`*`/`+` bullet, a `[ ]`/`[x]`
|
|
42
|
+
// box, then the label. Capture indent + bullet so we preserve them on flip, and
|
|
43
|
+
// an optional trailing `\r` so CRLF bodies (as returned by `gh pr view`) round-
|
|
44
|
+
// trip byte-for-byte instead of silently failing to match.
|
|
45
|
+
const CHECKBOX_RE = /^(\s*)([-*+])\s+\[([ xX])\]\s+(.*?)(\r?)$/;
|
|
46
|
+
|
|
47
|
+
// PURE: flip only unchecked checklist lines whose trimmed label EXACTLY equals a
|
|
48
|
+
// verified label. Never unchecks. Never touches non-checkbox text. Idempotent:
|
|
49
|
+
// an already-`[x]` verified label is neither flipped nor reported as unmatched.
|
|
50
|
+
// Returns { body, flipped, unmatched } where flipped = labels changed this call
|
|
51
|
+
// and unmatched = verified labels absent from the body's checklist.
|
|
52
|
+
export function tickVerifiedCheckboxes(body, verifiedLabels) {
|
|
53
|
+
const verified = new Set(
|
|
54
|
+
(verifiedLabels ?? []).map((l) => String(l).trim()).filter((l) => l.length > 0),
|
|
55
|
+
);
|
|
56
|
+
const flipped = [];
|
|
57
|
+
const found = new Set();
|
|
58
|
+
const lines = String(body).split("\n");
|
|
59
|
+
const nextLines = lines.map((line) => {
|
|
60
|
+
const m = line.match(CHECKBOX_RE);
|
|
61
|
+
if (!m) return line;
|
|
62
|
+
const [, indent, bullet, mark, rest, cr] = m;
|
|
63
|
+
const label = rest.trim();
|
|
64
|
+
if (!verified.has(label)) return line;
|
|
65
|
+
found.add(label);
|
|
66
|
+
if (mark !== " ") return line; // already checked — leave it, idempotent
|
|
67
|
+
if (!flipped.includes(label)) flipped.push(label); // duplicate lines: report once
|
|
68
|
+
return `${indent}${bullet} [x] ${rest}${cr}`;
|
|
69
|
+
});
|
|
70
|
+
const unmatched = [...verified].filter((label) => !found.has(label));
|
|
71
|
+
return { body: nextLines.join("\n"), flipped, unmatched };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function parseTickVerifiedCliArgs(argv) {
|
|
75
|
+
const { tokens } = parseArgs({
|
|
76
|
+
args: [...argv],
|
|
77
|
+
options: {
|
|
78
|
+
help: { type: "boolean", short: "h" },
|
|
79
|
+
repo: { type: "string" },
|
|
80
|
+
pr: { type: "string" },
|
|
81
|
+
verified: { type: "string", multiple: true },
|
|
82
|
+
"dry-run": { type: "boolean" },
|
|
83
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
84
|
+
},
|
|
85
|
+
allowPositionals: true,
|
|
86
|
+
strict: false,
|
|
87
|
+
tokens: true,
|
|
88
|
+
});
|
|
89
|
+
const options = {
|
|
90
|
+
help: false,
|
|
91
|
+
repo: undefined,
|
|
92
|
+
pr: undefined,
|
|
93
|
+
verified: [],
|
|
94
|
+
dryRun: false,
|
|
95
|
+
jq: undefined,
|
|
96
|
+
silent: false,
|
|
97
|
+
};
|
|
98
|
+
for (const token of tokens) {
|
|
99
|
+
if (token.kind === "positional") {
|
|
100
|
+
throw parseError(`Unknown argument: ${token.value}`);
|
|
101
|
+
}
|
|
102
|
+
if (token.kind !== "option") continue;
|
|
103
|
+
if (token.name === "help") {
|
|
104
|
+
options.help = true;
|
|
105
|
+
return options;
|
|
106
|
+
}
|
|
107
|
+
if (token.name === "repo") {
|
|
108
|
+
options.repo = requireTokenValue(token, parseError).trim();
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (token.name === "pr") {
|
|
112
|
+
options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (token.name === "verified") {
|
|
116
|
+
const label = requireTokenValue(token, parseError).trim();
|
|
117
|
+
if (label.length === 0) throw parseError("--verified must be a non-empty label");
|
|
118
|
+
options.verified.push(label);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (token.name === "dry-run") {
|
|
122
|
+
options.dryRun = true;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
126
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
127
|
+
}
|
|
128
|
+
if (options.repo === undefined || options.pr === undefined) {
|
|
129
|
+
throw parseError("Ticking PR checkboxes requires both --repo <owner/name> and --pr <number>");
|
|
130
|
+
}
|
|
131
|
+
if (options.verified.length === 0) {
|
|
132
|
+
throw parseError("at least one --verified is required");
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
parseRepoSlug(options.repo);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
138
|
+
}
|
|
139
|
+
return options;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function tickCheckboxes(
|
|
143
|
+
options,
|
|
144
|
+
{ env = process.env, ghCommand = "gh", run = runChild, editPr: edit = editPr } = {},
|
|
145
|
+
) {
|
|
146
|
+
// Reuse view-pr.mjs rather than re-implementing `gh pr view --json body`.
|
|
147
|
+
const { pr } = await viewPr(
|
|
148
|
+
{ repo: options.repo, pr: options.pr, fields: "body" },
|
|
149
|
+
{ env, ghCommand, run },
|
|
150
|
+
);
|
|
151
|
+
const currentBody = typeof pr.body === "string" ? pr.body : "";
|
|
152
|
+
const { body: nextBody, flipped, unmatched } = tickVerifiedCheckboxes(currentBody, options.verified);
|
|
153
|
+
if (flipped.length === 0 || options.dryRun) {
|
|
154
|
+
return { ok: true, pr: options.pr, flipped, unmatched, edited: false };
|
|
155
|
+
}
|
|
156
|
+
const dir = await mkdtemp(join(tmpdir(), "tick-verified-"));
|
|
157
|
+
try {
|
|
158
|
+
const bodyFile = join(dir, "body.md");
|
|
159
|
+
await writeFile(bodyFile, nextBody, "utf8");
|
|
160
|
+
await edit(
|
|
161
|
+
{ repo: options.repo, pr: options.pr, bodyFile, addAssignees: [], removeAssignees: [] },
|
|
162
|
+
{ env, ghCommand, run },
|
|
163
|
+
);
|
|
164
|
+
return { ok: true, pr: options.pr, flipped, unmatched, edited: true };
|
|
165
|
+
} finally {
|
|
166
|
+
// Clean up the temp dir on success or failure; never let cleanup mask the result/error.
|
|
167
|
+
try {
|
|
168
|
+
await rm(dir, { recursive: true, force: true });
|
|
169
|
+
} catch {
|
|
170
|
+
// ignore cleanup failures
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function runCli(
|
|
176
|
+
argv = process.argv.slice(2),
|
|
177
|
+
{ stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", run = runChild, editPr: edit = editPr } = {},
|
|
178
|
+
) {
|
|
179
|
+
let options;
|
|
180
|
+
try {
|
|
181
|
+
options = parseTickVerifiedCliArgs(argv);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
stderr.write(`${formatCliError(error)}\n`);
|
|
184
|
+
return 1;
|
|
185
|
+
}
|
|
186
|
+
if (options.help) {
|
|
187
|
+
stdout.write(`${USAGE}\n`);
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
let result;
|
|
191
|
+
try {
|
|
192
|
+
result = await tickCheckboxes(options, { env, ghCommand, run, editPr: edit });
|
|
193
|
+
} catch (error) {
|
|
194
|
+
stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
195
|
+
return 1;
|
|
196
|
+
}
|
|
197
|
+
return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
201
|
+
runCli().then((code) => { process.exitCode = code; });
|
|
202
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
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
|
-
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
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";
|
|
8
9
|
import { truncateText } from "@dev-loops/core/bash-exit-one";
|
|
9
10
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
@@ -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) {
|
|
@@ -391,14 +403,7 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
|
|
|
391
403
|
options.inlineReason = smartTruncate(reason, MAX_GATE_COMMENT_EXCERPT_LENGTH);
|
|
392
404
|
continue;
|
|
393
405
|
}
|
|
394
|
-
if (token
|
|
395
|
-
options.jq = requireTokenValue(token, parseError);
|
|
396
|
-
continue;
|
|
397
|
-
}
|
|
398
|
-
if (token.name === "silent") {
|
|
399
|
-
options.silent = true;
|
|
400
|
-
continue;
|
|
401
|
-
}
|
|
406
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
402
407
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
403
408
|
}
|
|
404
409
|
// Default execution mode to inline_single_agent when omitted. inlineReason is
|
|
@@ -749,7 +754,11 @@ export function renderGateReviewCommentBody({ gate, headSha, verdict, findingsSu
|
|
|
749
754
|
"",
|
|
750
755
|
`**Next action:** ${nextAction}`,
|
|
751
756
|
);
|
|
752
|
-
|
|
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"));
|
|
753
762
|
}
|
|
754
763
|
function resolveRequestedHeadSha(requestedHeadSha, currentHeadSha) {
|
|
755
764
|
if (requestedHeadSha === currentHeadSha) {
|
|
@@ -897,7 +906,14 @@ async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRo
|
|
|
897
906
|
try {
|
|
898
907
|
// The PR is now a draft, so RUN_DRAFT_GATE is the legal action. Re-enter with
|
|
899
908
|
// the caller's full options; prIsDraft is now true so this branch is skipped.
|
|
900
|
-
|
|
909
|
+
// `_draftTransitionInProgress` guards against unbounded recursion: if GitHub's
|
|
910
|
+
// draft-state read still lags the conversion mutation on re-entry (isDraft reads
|
|
911
|
+
// false again), the reconcile branch must NOT fire a second time — it fails closed
|
|
912
|
+
// with a clear error instead of recursing indefinitely (exit 13). (#1020)
|
|
913
|
+
result = await upsertCheckpointVerdict(
|
|
914
|
+
{ ...options, _draftTransitionInProgress: true },
|
|
915
|
+
{ env, ghCommand, repoRoot },
|
|
916
|
+
);
|
|
901
917
|
} catch (error) {
|
|
902
918
|
if (conversion.alreadyDraft !== true) {
|
|
903
919
|
try {
|
|
@@ -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
|
-
|
|
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 =
|
|
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,
|
|
@@ -1060,11 +1085,32 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
1060
1085
|
if (
|
|
1061
1086
|
options.gate === "draft_gate"
|
|
1062
1087
|
&& !prIsDraft
|
|
1088
|
+
&& !options._draftTransitionInProgress
|
|
1063
1089
|
&& !coordination.draftGateAlreadySatisfied
|
|
1064
1090
|
&& coordination.allowedNextActions.includes(PR_CHECKPOINT_ACTION.RECONCILE_DRAFT_GATE)
|
|
1065
1091
|
) {
|
|
1066
1092
|
return await postDraftGateViaDraftTransition(options, { env, ghCommand, repoRoot });
|
|
1067
1093
|
}
|
|
1094
|
+
// Fail closed on a lagged draft-state read: we are re-entering FROM
|
|
1095
|
+
// postDraftGateViaDraftTransition (which just converted the PR to draft) yet the
|
|
1096
|
+
// coordination context still reports the PR as non-draft. Recursing would loop
|
|
1097
|
+
// indefinitely (the original #1020 hang → exit 13, error swallowed). Surface a
|
|
1098
|
+
// clear, actionable error instead so the operator knows the draft conversion did
|
|
1099
|
+
// not take (or GitHub's read lags the mutation) and can retry. (#1020)
|
|
1100
|
+
if (
|
|
1101
|
+
options.gate === "draft_gate"
|
|
1102
|
+
&& !prIsDraft
|
|
1103
|
+
&& options._draftTransitionInProgress
|
|
1104
|
+
) {
|
|
1105
|
+
throw new Error(
|
|
1106
|
+
`draft_gate self-heal for ${options.repo}#${options.pr} failed: the PR was converted to draft ` +
|
|
1107
|
+
`to post the verdict, but GitHub still reports it as non-draft on re-entry (draft-state read lagged ` +
|
|
1108
|
+
`the conversion mutation, or the conversion did not take). Not recursing. Re-run the draft_gate post ` +
|
|
1109
|
+
`once the PR reflects the draft state, or reconcile manually with ` +
|
|
1110
|
+
`\`gh pr ready ${options.pr} --repo ${options.repo}\` / ` +
|
|
1111
|
+
`\`node scripts/github/reconcile-draft-gate.mjs --repo ${options.repo} --pr ${options.pr}\`.`,
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1068
1114
|
if (gateActionForbidden) {
|
|
1069
1115
|
throw new Error(buildGateEntryRefusalError({ options, coordination }));
|
|
1070
1116
|
}
|
|
@@ -1101,6 +1147,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
1101
1147
|
// multi-line per-angle breakdown and the `**Findings summary:**` line carries a
|
|
1102
1148
|
// single-line digest (so the marker/parse contract still round-trips).
|
|
1103
1149
|
let structuredFindings = null;
|
|
1150
|
+
let rawFindingsInput = null;
|
|
1104
1151
|
if (options.findingsJson) {
|
|
1105
1152
|
let raw;
|
|
1106
1153
|
try {
|
|
@@ -1119,6 +1166,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
1119
1166
|
const candidate = Array.isArray(parsed)
|
|
1120
1167
|
? parsed
|
|
1121
1168
|
: (Array.isArray(parsed?.angles) ? parsed.angles : (Array.isArray(parsed?.findings) ? parsed.findings : null));
|
|
1169
|
+
rawFindingsInput = candidate;
|
|
1122
1170
|
try {
|
|
1123
1171
|
structuredFindings = normalizeStructuredFindings(candidate);
|
|
1124
1172
|
} catch (err) {
|
|
@@ -1128,6 +1176,48 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
1128
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)`);
|
|
1129
1177
|
}
|
|
1130
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
|
+
}
|
|
1131
1221
|
// --findings-json takes precedence; when structured findings are present, do not
|
|
1132
1222
|
// read --findings-file at all (avoids a spurious hard failure if a caller passes
|
|
1133
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
|
+
}
|