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.
Files changed (203) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +8 -7
  3. package/.claude/agents/fixer.md +1 -0
  4. package/.claude/agents/refiner.md +2 -2
  5. package/.claude/agents/review.md +4 -3
  6. package/.claude/commands/{auto.md → loop-auto.md} +1 -1
  7. package/.claude/commands/loop-continue.md +15 -0
  8. package/.claude/commands/loop-enqueue.md +22 -0
  9. package/.claude/commands/loop-grill.md +17 -0
  10. package/.claude/commands/{info.md → loop-info.md} +2 -2
  11. package/.claude/commands/loop-queue-status.md +24 -0
  12. package/.claude/commands/{start-spike.md → loop-start-spike.md} +2 -2
  13. package/.claude/commands/{start.md → loop-start.md} +1 -1
  14. package/.claude/commands/{status.md → loop-status.md} +1 -1
  15. package/.claude/hooks/_bash-command-classify.mjs +333 -29
  16. package/.claude/hooks/_hook-decisions.mjs +138 -15
  17. package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
  18. package/.claude/skills/copilot-pr-followup/SKILL.md +60 -34
  19. package/.claude/skills/dev-loop/SKILL.md +16 -12
  20. package/.claude/skills/docs/acceptance-criteria-verification.md +8 -3
  21. package/.claude/skills/docs/anti-patterns.md +5 -3
  22. package/.claude/skills/docs/artifact-authority-contract.md +30 -11
  23. package/.claude/skills/docs/confirmation-rules.md +1 -0
  24. package/.claude/skills/docs/contract-style-guide.md +37 -0
  25. package/.claude/skills/docs/copilot-loop-operations.md +19 -15
  26. package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
  27. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  28. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  29. package/.claude/skills/docs/issue-intake-procedure.md +26 -19
  30. package/.claude/skills/docs/local-planning-flow.md +1 -1
  31. package/.claude/skills/docs/local-planning-worked-example.md +1 -1
  32. package/.claude/skills/docs/merge-preconditions.md +30 -12
  33. package/.claude/skills/docs/plan-file-contract.md +1 -1
  34. package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
  35. package/.claude/skills/docs/public-dev-loop-contract.md +60 -44
  36. package/.claude/skills/docs/retrospective-checkpoint-contract.md +106 -79
  37. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  38. package/.claude/skills/docs/stop-conditions.md +30 -15
  39. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  40. package/.claude/skills/docs/ui-e2e-scoping-step.md +32 -0
  41. package/.claude/skills/docs/validation-policy.md +4 -5
  42. package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
  43. package/.claude/skills/local-implementation/SKILL.md +62 -145
  44. package/.claude/skills/loop-grill/SKILL.md +163 -0
  45. package/AGENTS.md +1 -0
  46. package/CHANGELOG.md +101 -0
  47. package/README.md +9 -9
  48. package/agents/dev-loop.agent.md +11 -10
  49. package/agents/developer.agent.md +1 -1
  50. package/agents/docs.agent.md +1 -1
  51. package/agents/fixer.agent.md +2 -1
  52. package/agents/quality.agent.md +1 -1
  53. package/agents/refiner.agent.md +3 -3
  54. package/agents/review.agent.md +5 -4
  55. package/cli/index.mjs +35 -42
  56. package/extension/README.md +4 -4
  57. package/extension/checks.ts +1 -5
  58. package/extension/harness-types.ts +1 -0
  59. package/extension/index.ts +6 -0
  60. package/extension/pi-extension-adapter.ts +2 -0
  61. package/extension/presentation.ts +7 -19
  62. package/package.json +12 -12
  63. package/scripts/_core-helpers.mjs +2 -0
  64. package/scripts/claude/headless-dev-loop.mjs +1 -1
  65. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  66. package/scripts/docs/validate-state-machine-conformance.mjs +1092 -0
  67. package/scripts/github/_review-thread-mutations.mjs +5 -2
  68. package/scripts/github/capture-review-threads.mjs +2 -9
  69. package/scripts/github/comment-issue.mjs +2 -9
  70. package/scripts/github/create-label.mjs +133 -0
  71. package/scripts/github/create-pr.mjs +119 -10
  72. package/scripts/github/detect-checkpoint-evidence.mjs +259 -18
  73. package/scripts/github/detect-linked-issue-pr.mjs +22 -6
  74. package/scripts/github/edit-pr.mjs +259 -0
  75. package/scripts/github/fetch-ci-logs.mjs +2 -9
  76. package/scripts/github/list-issues.mjs +2 -9
  77. package/scripts/github/list-review-threads.mjs +277 -0
  78. package/scripts/github/manage-sub-issues.mjs +46 -10
  79. package/scripts/github/offer-human-handoff.mjs +8 -5
  80. package/scripts/github/post-gate-findings.mjs +20 -5
  81. package/scripts/github/probe-ci-status.mjs +8 -2
  82. package/scripts/github/probe-copilot-review.mjs +21 -14
  83. package/scripts/github/ready-for-review.mjs +26 -8
  84. package/scripts/github/reconcile-draft-gate.mjs +7 -3
  85. package/scripts/github/reply-resolve-review-thread.mjs +16 -5
  86. package/scripts/github/reply-resolve-review-threads.mjs +69 -7
  87. package/scripts/github/request-copilot-review.mjs +98 -26
  88. package/scripts/github/resolve-handoff-candidates.mjs +7 -3
  89. package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
  90. package/scripts/github/stage-reviewer-draft.mjs +10 -2
  91. package/scripts/github/tick-verified-checkboxes.mjs +202 -0
  92. package/scripts/github/upsert-checkpoint-verdict.mjs +105 -15
  93. package/scripts/github/verify-briefing-prefixes.mjs +191 -0
  94. package/scripts/github/verify-fresh-review-context.mjs +226 -32
  95. package/scripts/github/view-pr.mjs +150 -0
  96. package/scripts/github/wait-pr-checks.mjs +171 -0
  97. package/scripts/github/write-gate-context.mjs +458 -65
  98. package/scripts/github/write-gate-findings-log.mjs +128 -2
  99. package/scripts/lib/jq-output.mjs +18 -0
  100. package/scripts/loop/_post-convergence-change.mjs +211 -0
  101. package/scripts/loop/_pr-runner-coordination.mjs +70 -0
  102. package/scripts/loop/_repo-root-resolver.mjs +47 -0
  103. package/scripts/loop/build-handoff-envelope.mjs +14 -4
  104. package/scripts/loop/check-retro-tooling.mjs +14 -3
  105. package/scripts/loop/checkpoint-contract.mjs +7 -4
  106. package/scripts/loop/cleanup-worktree.mjs +12 -3
  107. package/scripts/loop/conductor-monitor.mjs +12 -18
  108. package/scripts/loop/copilot-pr-handoff.mjs +162 -14
  109. package/scripts/loop/debt-remediate.mjs +24 -12
  110. package/scripts/loop/detect-change-scope.mjs +38 -9
  111. package/scripts/loop/detect-copilot-loop-state.mjs +34 -8
  112. package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
  113. package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
  114. package/scripts/loop/detect-internal-only-pr.mjs +8 -2
  115. package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
  116. package/scripts/loop/detect-pr-gate-coordination-state.mjs +184 -69
  117. package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
  118. package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
  119. package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
  120. package/scripts/loop/ensure-worktree.mjs +8 -3
  121. package/scripts/loop/info.mjs +12 -4
  122. package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
  123. package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
  124. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
  125. package/scripts/loop/inspect-run-viewer.mjs +67 -4
  126. package/scripts/loop/inspect-run.mjs +8 -2
  127. package/scripts/loop/outer-loop.mjs +8 -4
  128. package/scripts/loop/pr-runner-coordination.mjs +2 -9
  129. package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
  130. package/scripts/loop/pre-flight-gate.mjs +9 -9
  131. package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
  132. package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
  133. package/scripts/loop/provision-worktree.mjs +74 -3
  134. package/scripts/loop/resolve-dev-loop-startup.mjs +216 -10
  135. package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
  136. package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
  137. package/scripts/loop/run-conductor-cycle.mjs +8 -2
  138. package/scripts/loop/run-queue.mjs +18 -9
  139. package/scripts/loop/run-refinement-audit.mjs +8 -9
  140. package/scripts/loop/run-watch-cycle.mjs +2 -9
  141. package/scripts/loop/sanctioned-commands.mjs +106 -0
  142. package/scripts/loop/steer-loop.mjs +29 -16
  143. package/scripts/loop/validate-pr-body-spec.mjs +223 -0
  144. package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
  145. package/scripts/pages/build-site.mjs +59 -8
  146. package/scripts/pages/build-state-atlas.mjs +443 -0
  147. package/scripts/projects/_resolve-project.mjs +1 -0
  148. package/scripts/projects/add-queue-item.mjs +60 -54
  149. package/scripts/projects/archive-done-items.mjs +49 -84
  150. package/scripts/projects/ensure-queue-board.mjs +10 -36
  151. package/scripts/projects/list-queue-items.mjs +59 -382
  152. package/scripts/projects/move-queue-item.mjs +21 -449
  153. package/scripts/projects/reconcile-queue.mjs +253 -0
  154. package/scripts/projects/reorder-queue-item.mjs +43 -68
  155. package/scripts/projects/resolve-active-board-item.mjs +108 -46
  156. package/scripts/projects/sync-item-status.mjs +15 -10
  157. package/scripts/refine/_refine-helpers.mjs +21 -5
  158. package/scripts/refine/exit-spike.mjs +18 -8
  159. package/scripts/refine/promote-plan.mjs +22 -16
  160. package/scripts/refine/prose-linkage-detector.mjs +1 -1
  161. package/scripts/refine/refine-plan-file.mjs +13 -4
  162. package/scripts/refine/refinement-completeness-checker.mjs +1 -1
  163. package/scripts/refine/scaffold-spike-file.mjs +4 -8
  164. package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
  165. package/scripts/refine/tree-integrity-validator.mjs +1 -1
  166. package/scripts/refine/validate-plan-file.mjs +1 -1
  167. package/scripts/refine/validate-spike-file.mjs +1 -1
  168. package/scripts/refine/verify.mjs +11 -6
  169. package/scripts/release/assert-core-dependency-version.mjs +123 -0
  170. package/scripts/release/extract-changelog-section.mjs +16 -2
  171. package/skills/copilot-pr-followup/SKILL.md +60 -34
  172. package/skills/dev-loop/SKILL.md +11 -7
  173. package/skills/docs/acceptance-criteria-verification.md +8 -3
  174. package/skills/docs/anti-patterns.md +5 -3
  175. package/skills/docs/artifact-authority-contract.md +30 -11
  176. package/skills/docs/confirmation-rules.md +1 -0
  177. package/skills/docs/contract-style-guide.md +37 -0
  178. package/skills/docs/copilot-loop-operations.md +19 -15
  179. package/skills/docs/cross-harness-regression-contract.md +60 -0
  180. package/skills/docs/debt-remediation-contract.md +1 -1
  181. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  182. package/skills/docs/issue-intake-procedure.md +26 -19
  183. package/skills/docs/local-planning-flow.md +1 -1
  184. package/skills/docs/local-planning-worked-example.md +1 -1
  185. package/skills/docs/merge-preconditions.md +30 -12
  186. package/skills/docs/plan-file-contract.md +1 -1
  187. package/skills/docs/pr-lifecycle-contract.md +44 -35
  188. package/skills/docs/public-dev-loop-contract.md +60 -44
  189. package/skills/docs/required-rules.json +149 -0
  190. package/skills/docs/retrospective-checkpoint-contract.md +106 -79
  191. package/skills/docs/spike-mode-contract.md +14 -7
  192. package/skills/docs/stop-conditions.md +30 -15
  193. package/skills/docs/tracker-first-loop-state.md +13 -8
  194. package/skills/docs/ui-e2e-scoping-step.md +32 -0
  195. package/skills/docs/validation-policy.md +4 -5
  196. package/skills/docs/workflow-handoff-contract.md +39 -0
  197. package/skills/local-implementation/SKILL.md +62 -145
  198. package/skills/loop-grill/SKILL.md +165 -0
  199. package/.claude/commands/continue.md +0 -15
  200. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
  201. package/scripts/loop/conductor.mjs +0 -233
  202. package/scripts/loop/detect-stale-runner.mjs +0 -265
  203. 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.name === "jq") {
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
- 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"));
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
- result = await upsertCheckpointVerdict(options, { env, ghCommand, repoRoot });
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
- 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,
@@ -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
+ }