dev-loops 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/.pi/dev-loop/defaults.yaml +477 -0
  2. package/AGENTS.md +25 -0
  3. package/CHANGELOG.md +18 -0
  4. package/LICENSE +21 -0
  5. package/README.md +178 -0
  6. package/agents/dev-loop.agent.md +82 -0
  7. package/agents/developer.agent.md +37 -0
  8. package/agents/docs.agent.md +33 -0
  9. package/agents/fixer.agent.md +53 -0
  10. package/agents/quality.agent.md +28 -0
  11. package/agents/refiner.agent.md +87 -0
  12. package/agents/review.agent.md +64 -0
  13. package/cli/index.mjs +424 -0
  14. package/extension/README.md +233 -0
  15. package/extension/checks.ts +94 -0
  16. package/extension/index.ts +131 -0
  17. package/extension/post-merge-update.ts +512 -0
  18. package/extension/presentation.ts +107 -0
  19. package/lib/dev-loops-core.mjs +284 -0
  20. package/package.json +103 -0
  21. package/scripts/README.md +1007 -0
  22. package/scripts/_cli-primitives.mjs +10 -0
  23. package/scripts/_core-helpers.mjs +30 -0
  24. package/scripts/docs/validate-links.mjs +567 -0
  25. package/scripts/docs/validate-no-duplicate-rules.mjs +250 -0
  26. package/scripts/github/_review-thread-mutations.mjs +214 -0
  27. package/scripts/github/capture-review-threads.mjs +180 -0
  28. package/scripts/github/create-draft-pr.mjs +108 -0
  29. package/scripts/github/detect-checkpoint-evidence.mjs +393 -0
  30. package/scripts/github/detect-linked-issue-pr.mjs +331 -0
  31. package/scripts/github/manage-sub-issues.mjs +394 -0
  32. package/scripts/github/probe-copilot-review.mjs +323 -0
  33. package/scripts/github/ready-for-review.mjs +93 -0
  34. package/scripts/github/reconcile-draft-gate.mjs +328 -0
  35. package/scripts/github/reply-resolve-review-thread.mjs +42 -0
  36. package/scripts/github/reply-resolve-review-threads.mjs +329 -0
  37. package/scripts/github/request-copilot-review.mjs +551 -0
  38. package/scripts/github/resolve-tracker-local-spec.mjs +205 -0
  39. package/scripts/github/stage-reviewer-draft.mjs +191 -0
  40. package/scripts/github/upsert-checkpoint-verdict.mjs +694 -0
  41. package/scripts/github/verify-fresh-review-context.mjs +125 -0
  42. package/scripts/github/write-gate-findings-log.mjs +212 -0
  43. package/scripts/loop/_checkpoint-io.mjs +55 -0
  44. package/scripts/loop/_checkpoint-paths.mjs +28 -0
  45. package/scripts/loop/_handoff-contract.mjs +230 -0
  46. package/scripts/loop/_inspect-run-viewer-adapter.mjs +345 -0
  47. package/scripts/loop/_loop-evidence.mjs +32 -0
  48. package/scripts/loop/_pr-runner-coordination.mjs +611 -0
  49. package/scripts/loop/_stale-runner-detection.mjs +145 -0
  50. package/scripts/loop/_steering-state-file.mjs +134 -0
  51. package/scripts/loop/build-handoff-envelope.mjs +181 -0
  52. package/scripts/loop/checkpoint-contract.mjs +49 -0
  53. package/scripts/loop/conductor-monitor.mjs +1850 -0
  54. package/scripts/loop/conductor.mjs +214 -0
  55. package/scripts/loop/copilot-pr-handoff.mjs +493 -0
  56. package/scripts/loop/debt-remediate.mjs +304 -0
  57. package/scripts/loop/detect-change-scope.mjs +102 -0
  58. package/scripts/loop/detect-copilot-loop-state.mjs +454 -0
  59. package/scripts/loop/detect-copilot-session-activity.mjs +186 -0
  60. package/scripts/loop/detect-initial-copilot-pr-state.mjs +318 -0
  61. package/scripts/loop/detect-internal-only-pr.mjs +270 -0
  62. package/scripts/loop/detect-issue-refinement-artifact.mjs +163 -0
  63. package/scripts/loop/detect-pr-gate-coordination-state.mjs +509 -0
  64. package/scripts/loop/detect-reviewer-loop-state.mjs +231 -0
  65. package/scripts/loop/detect-stale-runner.mjs +250 -0
  66. package/scripts/loop/detect-tracker-first-loop-state.mjs +76 -0
  67. package/scripts/loop/detect-tracker-pr-state.mjs +102 -0
  68. package/scripts/loop/info.mjs +267 -0
  69. package/scripts/loop/inspect-run-viewer/cli.mjs +117 -0
  70. package/scripts/loop/inspect-run-viewer/constants.mjs +80 -0
  71. package/scripts/loop/inspect-run-viewer/graph.mjs +757 -0
  72. package/scripts/loop/inspect-run-viewer/handoff-envelope-renderer.mjs +398 -0
  73. package/scripts/loop/inspect-run-viewer/inbox.mjs +308 -0
  74. package/scripts/loop/inspect-run-viewer/managed-instance.mjs +750 -0
  75. package/scripts/loop/inspect-run-viewer/rendering.mjs +411 -0
  76. package/scripts/loop/inspect-run-viewer/server.mjs +638 -0
  77. package/scripts/loop/inspect-run-viewer/shared.mjs +103 -0
  78. package/scripts/loop/inspect-run-viewer/status.mjs +715 -0
  79. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +77 -0
  80. package/scripts/loop/inspect-run-viewer.mjs +82 -0
  81. package/scripts/loop/inspect-run.mjs +382 -0
  82. package/scripts/loop/outer-loop.mjs +419 -0
  83. package/scripts/loop/pr-runner-coordination.mjs +143 -0
  84. package/scripts/loop/pre-commit-branch-guard.mjs +68 -0
  85. package/scripts/loop/pre-flight-gate.mjs +236 -0
  86. package/scripts/loop/pre-pr-ready-gate.mjs +183 -0
  87. package/scripts/loop/pre-push-main-guard.mjs +103 -0
  88. package/scripts/loop/pre-write-remote-freshness-guard.mjs +32 -0
  89. package/scripts/loop/print-gates.mjs +42 -0
  90. package/scripts/loop/resolve-dev-loop-startup.mjs +533 -0
  91. package/scripts/loop/run-conductor-cycle.mjs +322 -0
  92. package/scripts/loop/run-queue.mjs +124 -0
  93. package/scripts/loop/run-refinement-audit.mjs +513 -0
  94. package/scripts/loop/run-watch-cycle.mjs +358 -0
  95. package/scripts/loop/steer-loop.mjs +841 -0
  96. package/scripts/loop/ui-designer-review-contract.mjs +76 -0
  97. package/scripts/loop/watch-initial-copilot-pr.mjs +253 -0
  98. package/scripts/projects/add-queue-item.mjs +528 -0
  99. package/scripts/projects/ensure-queue-board.mjs +837 -0
  100. package/scripts/projects/list-queue-items.mjs +489 -0
  101. package/scripts/projects/move-queue-item.mjs +549 -0
  102. package/scripts/projects/reorder-queue-item.mjs +518 -0
  103. package/scripts/refine/_refine-helpers.mjs +258 -0
  104. package/scripts/refine/prose-linkage-detector.mjs +92 -0
  105. package/scripts/refine/refinement-completeness-checker.mjs +88 -0
  106. package/scripts/refine/scope-boundary-cross-checker.mjs +163 -0
  107. package/scripts/refine/tree-integrity-validator.mjs +211 -0
  108. package/scripts/refine/verify.mjs +178 -0
  109. package/scripts/repo-wiki-local.mjs +156 -0
  110. package/scripts/repo-wiki.mjs +119 -0
  111. package/skills/copilot-pr-followup/SKILL.md +380 -0
  112. package/skills/dev-loop/SKILL.md +141 -0
  113. package/skills/dev-loop/scripts/dev-mode-context.mjs +152 -0
  114. package/skills/dev-loop/scripts/dev-mode-context.test.mjs +80 -0
  115. package/skills/dev-loop/scripts/init-phase.mjs +71 -0
  116. package/skills/dev-loop/scripts/log-bash-exit-1.mjs +25 -0
  117. package/skills/dev-loop/scripts/phase-files.mjs +29 -0
  118. package/skills/dev-loop/scripts/post-gate-verdict-fallback.mjs +480 -0
  119. package/skills/dev-loop/scripts/post-gate-verdict-fallback.test.mjs +732 -0
  120. package/skills/dev-loop/scripts/render-template.mjs +82 -0
  121. package/skills/dev-loop/scripts/render-template.test.mjs +63 -0
  122. package/skills/dev-loop/templates/bootstrap-agents.md +26 -0
  123. package/skills/dev-loop/templates/bootstrap-implementation-state.md +31 -0
  124. package/skills/dev-loop/templates/bootstrap-implementation-workflow.md +17 -0
  125. package/skills/dev-loop/templates/dev-mode-retrospective.md +15 -0
  126. package/skills/dev-loop/templates/dev-mode-review.md +17 -0
  127. package/skills/dev-loop/templates/dev-mode-skill-changes.md +11 -0
  128. package/skills/dev-loop/templates/merged-phase-plan.md +19 -0
  129. package/skills/dev-loop/templates/phase-doc.md +27 -0
  130. package/skills/dev-loop/templates/phase-summary.md +13 -0
  131. package/skills/dev-loop/templates/phase-variant.md +15 -0
  132. package/skills/dev-loop/templates/retrospective.md +11 -0
  133. package/skills/dev-loop/templates/review.md +32 -0
  134. package/skills/dev-loop/templates/ui-vision-review.md +55 -0
  135. package/skills/docs/acceptance-criteria-verification.md +21 -0
  136. package/skills/docs/anti-patterns.md +21 -0
  137. package/skills/docs/artifact-authority-contract.md +119 -0
  138. package/skills/docs/confirmation-rules.md +28 -0
  139. package/skills/docs/copilot-ci-status-contract.md +52 -0
  140. package/skills/docs/copilot-loop-operations.md +233 -0
  141. package/skills/docs/debt-remediation-contract.md +107 -0
  142. package/skills/docs/entrypoint-strategies.md +115 -0
  143. package/skills/docs/epic-tree-refinement-procedure.md +234 -0
  144. package/skills/docs/issue-intake-procedure.md +235 -0
  145. package/skills/docs/main-agent-contract.md +72 -0
  146. package/skills/docs/merge-preconditions.md +29 -0
  147. package/skills/docs/pr-lifecycle-contract.md +209 -0
  148. package/skills/docs/public-dev-loop-contract.md +497 -0
  149. package/skills/docs/retrospective-checkpoint-contract.md +159 -0
  150. package/skills/docs/stop-conditions.md +29 -0
  151. package/skills/docs/structural-quality.md +42 -0
  152. package/skills/docs/tracker-first-loop-state.md +281 -0
  153. package/skills/docs/validation-policy.md +27 -0
  154. package/skills/docs/workflow-handoff-contract.md +135 -0
  155. package/skills/final-approval/SKILL.md +19 -0
  156. package/skills/local-implementation/SKILL.md +640 -0
@@ -0,0 +1,694 @@
1
+ #!/usr/bin/env node
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";
5
+ import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
6
+ import { truncateText } from "@dev-loops/core/bash-exit-one";
7
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
8
+ import { loadPrGateCoordinationContext } from "../loop/detect-pr-gate-coordination-state.mjs";
9
+ import { evaluatePrGateCoordination, PR_CHECKPOINT_ACTION } from "@dev-loops/core/loop/pr-gate-coordination";
10
+ import { STATE } from "@dev-loops/core/loop/copilot-loop-state";
11
+ import { claimRunnerOwnership } from "../loop/_pr-runner-coordination.mjs";
12
+ import { detectStaleRunner } from "../loop/_stale-runner-detection.mjs";
13
+ import { detectInternalOnly } from "../loop/detect-internal-only-pr.mjs";
14
+ const GATE_NAMES = new Set(["draft_gate", "pre_approval_gate"]);
15
+ const GATE_VERDICTS = new Set(["clean", "findings_present", "blocked"]);
16
+ const MAX_GATE_COMMENT_TEXT_LENGTH = 2000;
17
+ const MAX_GATE_COMMENT_EXCERPT_LENGTH = 120;
18
+ const REMOVED_FLAGS = new Set([
19
+ "--force",
20
+ "--force-reason",
21
+ ]);
22
+ const USAGE = `Usage: upsert-checkpoint-verdict.mjs --repo <owner/name> --pr <number> --head-sha <sha> --verdict <clean|findings_present|blocked> (--findings-summary <text> | --findings-file <path>) --next-action <text> [--gate <draft_gate|pre_approval_gate>]
23
+ Create or update the visible checkpoint verdict comment for a gate/head pair.
24
+ Same-head reruns are idempotent: if a visible marker already exists for the same
25
+ \`gate + headSha\`, this helper updates it in place when correction is needed and
26
+ suppresses duplicate reposts when the existing visible comment already matches.
27
+ The gate (draft_gate or pre_approval_gate) is auto-resolved from the PR gate
28
+ coordination state when --gate is not provided. Explicit --gate is still accepted
29
+ but must match the coordination state's allowed next actions.
30
+ Required:
31
+ --repo <owner/name>
32
+ --pr <number>
33
+ --head-sha <sha> Full current head SHA or hexadecimal prefix of it
34
+ --verdict <clean|findings_present|blocked>
35
+ --findings-summary <text> Findings summary as a single argument
36
+ (use --findings-file for multi-line)
37
+ --findings-file <path> Read findings summary from file;
38
+ alternative to --findings-summary
39
+ (preserves newlines; takes precedence
40
+ when both are present)
41
+ --next-action <text>
42
+ Optional:
43
+ --gate <draft_gate|pre_approval_gate> Auto-resolved from coordination state
44
+ when omitted. Explicit gate is validated
45
+ against allowed coordination actions.
46
+ --findings-severity-counts <json> JSON object mapping severity to count
47
+ (e.g. '{"must-fix":0,"worth-fixing-now":0}').
48
+ Required for --verdict clean when
49
+ blockCleanOnFindingSeverities is configured.
50
+ Output (stdout, JSON):
51
+ {
52
+ "ok": true,
53
+ "action": "created"|"updated"|"noop",
54
+ "repo": "owner/repo",
55
+ "pr": 17,
56
+ "gate": "draft_gate",
57
+ "headSha": "abc1234",
58
+ "currentHeadSha": "abc1234",
59
+ "commentId": 101,
60
+ "commentUrl": "https://github.com/owner/repo/pull/17#issuecomment-101"
61
+ }
62
+ A \`warning\` field is included when a gate comment for the same gate already
63
+ exists on a different head SHA (the old comment is stale for the current head).
64
+ Error output (stderr, JSON):
65
+ { "ok": false, "error": "...", "usage": "..." }
66
+ { "ok": false, "error": "..." }
67
+ Exit codes:
68
+ 0 Success
69
+ 1 Argument error, gh failure, or contradictory gate evidence`.trim();
70
+ const parseError = buildParseError(USAGE);
71
+ function rejectRemovedFlag(token) {
72
+ throw parseError(
73
+ `${token} has been removed. Force bypass requires separate operator authorization. Omit the flag.`,
74
+ );
75
+ }
76
+ function normalizeGateName(value) {
77
+ const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
78
+ return GATE_NAMES.has(normalized) ? normalized : null;
79
+ }
80
+ function normalizeVerdict(value) {
81
+ const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
82
+ return GATE_VERDICTS.has(normalized) ? normalized : null;
83
+ }
84
+ function normalizeHeadSha(value) {
85
+ const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
86
+ return /^[0-9a-f]{7,64}$/i.test(normalized) ? normalized : null;
87
+ }
88
+ function normalizeRequiredText(value, flag) {
89
+ const normalized = typeof value === "string" ? value.trim() : "";
90
+ if (normalized.length === 0) {
91
+ throw parseError(`${flag} must be a non-empty string`);
92
+ }
93
+ if (flag === "--findings-summary") {
94
+ return summarizeCheckpointVerdictText(normalized);
95
+ }
96
+ return smartTruncate(collapseWhitespace(normalized), MAX_GATE_COMMENT_TEXT_LENGTH);
97
+ }
98
+ function collapseWhitespace(value) {
99
+ return String(value).replace(/\s+/gu, " ").trim();
100
+ }
101
+ function smartTruncate(value, limit) {
102
+ const text = String(value);
103
+ if (text.length <= limit) {
104
+ return text;
105
+ }
106
+ const truncated = text.slice(0, limit);
107
+ const lastSpace = truncated.lastIndexOf(" ");
108
+ const breakPoint = lastSpace > Math.floor(limit * 0.7) ? lastSpace : limit;
109
+ const retained = truncated.slice(0, breakPoint);
110
+ const omitted = text.length - retained.length;
111
+ return `${retained}…[truncated ${omitted} chars]`;
112
+ }
113
+ function pushUnique(values, value) {
114
+ if (value.length > 0 && !values.includes(value)) {
115
+ values.push(value);
116
+ }
117
+ }
118
+ function formatValidationCounts(counts) {
119
+ const orderedKeys = ["tests", "pass", "fail", "skipped", "todo", "cancelled", "suites"];
120
+ const parts = orderedKeys
121
+ .filter((key) => Number.isInteger(counts[key]))
122
+ .map((key) => `${key}: ${counts[key]}`);
123
+ return parts.length > 0 ? parts.join(", ") : null;
124
+ }
125
+ function buildVerboseValidationSummary(lines) {
126
+ const commands = [];
127
+ const counts = Object.create(null);
128
+ let ciLine = null;
129
+ let failureExcerpt = null;
130
+ let sawPassedSignal = false;
131
+ for (const rawLine of lines) {
132
+ const line = collapseWhitespace(rawLine.replace(/^[*-]\s*/u, ""));
133
+ if (line.length === 0) {
134
+ continue;
135
+ }
136
+ const commandMatch = line.match(/^(?:>|\$)\s*(.+)$/u);
137
+ if (commandMatch) {
138
+ pushUnique(commands, collapseWhitespace(commandMatch[1]));
139
+ continue;
140
+ }
141
+ const countMatch = line.match(/^(?:ℹ\s*)?(tests|suites|pass|fail|cancelled|skipped|todo)\s*:?\s*(\d+)$/iu);
142
+ if (countMatch) {
143
+ counts[countMatch[1].toLowerCase()] = Number.parseInt(countMatch[2], 10);
144
+ continue;
145
+ }
146
+ if (
147
+ ciLine === null
148
+ && /\b(?:github\s+ci|ci|checks?|workflow)\b/i.test(line)
149
+ && /\b(?:pass(?:ed)?|green|success(?:ful)?|fail(?:ed)?|red|pending|blocked)\b/i.test(line)
150
+ ) {
151
+ ciLine = truncateText(line, MAX_GATE_COMMENT_EXCERPT_LENGTH);
152
+ continue;
153
+ }
154
+ if (
155
+ failureExcerpt === null
156
+ && (/^✖\s*/u.test(line) || /^FAIL\b/u.test(line) || /\b(?:AssertionError|TypeError|ReferenceError|SyntaxError)\b/u.test(line) || /\bError:/u.test(line))
157
+ ) {
158
+ failureExcerpt = truncateText(line.replace(/^✖\s*/u, ""), MAX_GATE_COMMENT_EXCERPT_LENGTH);
159
+ continue;
160
+ }
161
+ if (/\bpass(?:ed)?\b/i.test(line)) {
162
+ sawPassedSignal = true;
163
+ }
164
+ }
165
+ const parts = [];
166
+ if (commands.length > 0) {
167
+ parts.push(`commands: ${commands.join(", ")}`);
168
+ }
169
+ const countLine = formatValidationCounts(counts);
170
+ if (countLine) {
171
+ parts.push(countLine);
172
+ }
173
+ if (ciLine) {
174
+ parts.push(`ci: ${ciLine}`);
175
+ }
176
+ const sawStructuredSignal = commands.length > 0 || countLine !== null || ciLine !== null || failureExcerpt !== null;
177
+ if (failureExcerpt) {
178
+ parts.push(`failure excerpt: ${failureExcerpt}`);
179
+ } else if (Number.isInteger(counts.fail) && counts.fail > 0) {
180
+ parts.push("validation: failed");
181
+ } else if (!countLine && sawPassedSignal && sawStructuredSignal) {
182
+ parts.push("validation: passed");
183
+ }
184
+ return parts.length > 0 ? parts.join("; ") : null;
185
+ }
186
+ export function summarizeCheckpointVerdictText(value, limit = MAX_GATE_COMMENT_TEXT_LENGTH) {
187
+ const normalized = typeof value === "string" ? value.trim() : "";
188
+ if (normalized.length === 0) {
189
+ return "";
190
+ }
191
+ const flat = collapseWhitespace(normalized);
192
+ if (!/[\r\n]/u.test(normalized)) {
193
+ return smartTruncate(flat, limit);
194
+ }
195
+ const lines = normalized.split(/\r?\n/u);
196
+ const verboseSummary = buildVerboseValidationSummary(lines);
197
+ return smartTruncate(verboseSummary ?? flat, limit);
198
+ }
199
+ export function parseUpsertCheckpointVerdictCliArgs(argv) {
200
+ const args = [...argv];
201
+ const options = {
202
+ help: false,
203
+ repo: undefined,
204
+ pr: undefined,
205
+ gate: undefined,
206
+ headSha: undefined,
207
+ verdict: undefined,
208
+ findingsSummary: undefined,
209
+ findingsFile: undefined,
210
+ nextAction: undefined,
211
+ findingsSeverityCounts: undefined,
212
+ };
213
+ while (args.length > 0) {
214
+ const token = args.shift();
215
+ if (token === "--help" || token === "-h") {
216
+ options.help = true;
217
+ return options;
218
+ }
219
+ if (REMOVED_FLAGS.has(token)) {
220
+ rejectRemovedFlag(token);
221
+ }
222
+ if (token === "--repo") {
223
+ options.repo = requireOptionValue(args, "--repo", parseError).trim();
224
+ continue;
225
+ }
226
+ if (token === "--pr") {
227
+ options.pr = parsePrNumber(requireOptionValue(args, "--pr", parseError), parseError);
228
+ continue;
229
+ }
230
+ if (token === "--gate") {
231
+ const gate = normalizeGateName(requireOptionValue(args, "--gate", parseError));
232
+ if (!gate) {
233
+ throw parseError("--gate must be one of: draft_gate, pre_approval_gate");
234
+ }
235
+ options.gate = gate;
236
+ continue;
237
+ }
238
+ if (token === "--head-sha") {
239
+ const headSha = normalizeHeadSha(requireOptionValue(args, "--head-sha", parseError));
240
+ if (!headSha) {
241
+ throw parseError("--head-sha must be a 7-64 character hexadecimal SHA");
242
+ }
243
+ options.headSha = headSha;
244
+ continue;
245
+ }
246
+ if (token === "--verdict") {
247
+ const verdict = normalizeVerdict(requireOptionValue(args, "--verdict", parseError));
248
+ if (!verdict) {
249
+ throw parseError("--verdict must be one of: clean, findings_present, blocked");
250
+ }
251
+ options.verdict = verdict;
252
+ continue;
253
+ }
254
+ if (token === "--findings-summary") {
255
+ options.findingsSummary = normalizeRequiredText(requireOptionValue(args, "--findings-summary", parseError), "--findings-summary");
256
+ continue;
257
+ }
258
+ if (token === "--findings-file") {
259
+ const rawPath = requireOptionValue(args, "--findings-file", parseError).trim();
260
+ if (rawPath.length === 0) {
261
+ throw parseError("--findings-file must be a non-empty path");
262
+ }
263
+ options.findingsFile = rawPath;
264
+ continue;
265
+ }
266
+ if (token === "--next-action") {
267
+ options.nextAction = normalizeRequiredText(requireOptionValue(args, "--next-action", parseError), "--next-action");
268
+ continue;
269
+ }
270
+ if (token === "--findings-severity-counts") {
271
+ const raw = requireOptionValue(args, "--findings-severity-counts", parseError);
272
+ let parsed;
273
+ try {
274
+ parsed = JSON.parse(raw);
275
+ } catch {
276
+ throw parseError("--findings-severity-counts must be valid JSON");
277
+ }
278
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
279
+ throw parseError("--findings-severity-counts must be a JSON object mapping severity to count");
280
+ }
281
+ const counts = Object.create(null);
282
+ for (const [key, value] of Object.entries(parsed)) {
283
+ if (!Number.isInteger(value) || value < 0) {
284
+ throw parseError(`--findings-severity-counts.${key} must be a non-negative integer`);
285
+ }
286
+ counts[key] = value;
287
+ }
288
+ options.findingsSeverityCounts = counts;
289
+ continue;
290
+ }
291
+ throw parseError(`Unknown argument: ${token}`);
292
+ }
293
+ const missing = ["repo", "pr", "headSha", "verdict", "findingsSummary", "nextAction"]
294
+ .filter((key) => options[key] === undefined);
295
+ if (options.findingsFile) {
296
+ const fsIdx = missing.indexOf("findingsSummary");
297
+ if (fsIdx !== -1) missing.splice(fsIdx, 1);
298
+ }
299
+ if (missing.length > 0) {
300
+ throw parseError("upsert-checkpoint-verdict requires --repo, --pr, --head-sha, --verdict, --findings-summary (or --findings-file), and --next-action");
301
+ }
302
+ try {
303
+ parseRepoSlug(options.repo);
304
+ } catch (error) {
305
+ throw parseError(error instanceof Error ? error.message : String(error));
306
+ }
307
+ return options;
308
+ }
309
+ function appendGateEvidenceNote(summary, note) {
310
+ const normalizedSummary = summarizeCheckpointVerdictText(summary);
311
+ const normalizedNote = typeof note === "string" ? collapseWhitespace(note) : "";
312
+ if (normalizedNote.length === 0) {
313
+ return normalizedSummary;
314
+ }
315
+ if (normalizedSummary.length === 0) {
316
+ return smartTruncate(normalizedNote, MAX_GATE_COMMENT_TEXT_LENGTH);
317
+ }
318
+ if (normalizedSummary.includes(normalizedNote)) {
319
+ return normalizedSummary;
320
+ }
321
+ return smartTruncate(`${normalizedSummary}; ${normalizedNote}`, MAX_GATE_COMMENT_TEXT_LENGTH);
322
+ }
323
+ export function renderGateReviewCommentBody({ gate, headSha, verdict, findingsSummary, nextAction, blockCleanOnFindingSeverities }) {
324
+ const lines = [
325
+ `### Gate review: \`${gate}\``,
326
+ "",
327
+ `**Reviewed head SHA:** \`${headSha}\``,
328
+ `**Verdict:** ${verdict}`,
329
+ ];
330
+ if ((verdict === "findings_present" || verdict === "blocked") && blockCleanOnFindingSeverities && blockCleanOnFindingSeverities.length > 0) {
331
+ const sevs = blockCleanOnFindingSeverities.join(", ");
332
+ lines.push(`**Blocking severities:** ${sevs} (clean requires no findings matching these severities)`);
333
+ }
334
+ lines.push(
335
+ "",
336
+ `**Findings summary:** ${findingsSummary}`,
337
+ "",
338
+ `**Next action:** ${nextAction}`,
339
+ );
340
+ return lines.join("\n");
341
+ }
342
+ function resolveRequestedHeadSha(requestedHeadSha, currentHeadSha) {
343
+ if (requestedHeadSha === currentHeadSha) {
344
+ return currentHeadSha;
345
+ }
346
+ if (currentHeadSha.startsWith(requestedHeadSha)) {
347
+ return currentHeadSha;
348
+ }
349
+ throw new Error(`Requested head SHA ${requestedHeadSha} does not match the current PR head SHA ${currentHeadSha}; refuse to mutate stale gate evidence.`);
350
+ }
351
+ function resolveGateAction(gate) {
352
+ return gate === "draft_gate"
353
+ ? PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE
354
+ : PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE;
355
+ }
356
+ function buildGateEntryRefusalError({ options, coordination }) {
357
+ return `Cannot enter ${options.gate} on ${options.repo}#${options.pr}: ${coordination.reason}`;
358
+ }
359
+ function selectGateEvidence(evidence, gate) {
360
+ if (gate === "draft_gate") {
361
+ return {
362
+ strict: evidence.draftGate,
363
+ marker: evidence.draftGateMarker,
364
+ };
365
+ }
366
+ return {
367
+ strict: evidence.preApprovalGate,
368
+ marker: evidence.preApprovalGateMarker,
369
+ };
370
+ }
371
+ function summarizeExistingComment({ strict, marker, headSha }) {
372
+ const strictSameHead = strict?.visible === true && strict.headSha === headSha ? strict : null;
373
+ const markerSameHead = marker?.visible === true && marker.headSha === headSha ? marker : null;
374
+ if (markerSameHead && (!strictSameHead || markerSameHead.commentId !== strictSameHead.commentId)) {
375
+ return {
376
+ kind: "marker",
377
+ commentId: markerSameHead.commentId,
378
+ commentUrl: markerSameHead.commentUrl,
379
+ verdict: markerSameHead.verdict,
380
+ findingsSummary: markerSameHead.findingsSummary ?? null,
381
+ nextAction: markerSameHead.nextAction ?? null,
382
+ contractComplete: markerSameHead.contractComplete === true,
383
+ };
384
+ }
385
+ if (strictSameHead) {
386
+ return {
387
+ kind: "strict",
388
+ commentId: strictSameHead.commentId,
389
+ commentUrl: strictSameHead.commentUrl,
390
+ verdict: strictSameHead.verdict,
391
+ findingsSummary: strictSameHead.findingsSummary,
392
+ nextAction: strictSameHead.nextAction,
393
+ contractComplete: true,
394
+ };
395
+ }
396
+ if (markerSameHead) {
397
+ return {
398
+ kind: "marker",
399
+ commentId: markerSameHead.commentId,
400
+ commentUrl: markerSameHead.commentUrl,
401
+ verdict: markerSameHead.verdict,
402
+ findingsSummary: markerSameHead.findingsSummary ?? null,
403
+ nextAction: markerSameHead.nextAction ?? null,
404
+ contractComplete: markerSameHead.contractComplete === true,
405
+ };
406
+ }
407
+ return null;
408
+ }
409
+ function detectStaleGateCommentWarning({ strict, headSha, gate }) {
410
+ if (!(strict?.visible === true && strict.headSha !== null && strict.headSha !== headSha)) {
411
+ return null;
412
+ }
413
+ return `A gate comment for \`${gate}\` already exists on a different head SHA \`${strict.headSha}\` (comment ${strict.commentId}). The old comment is stale for the current head.`;
414
+ }
415
+ async function runGhJson(args, { env, ghCommand }) {
416
+ const result = await runChild(ghCommand, args, env);
417
+ if (result.code !== 0) {
418
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
419
+ throw new Error(`gh command failed: ${detail}`);
420
+ }
421
+ return parseJsonText(result.stdout, { label: `gh ${args.slice(0, 3).join(" ")}` });
422
+ }
423
+ function parseCommentMutationResponse(payload) {
424
+ const commentId = Number.isInteger(payload?.id) ? payload.id : null;
425
+ const commentUrl = typeof payload?.html_url === "string" && payload.html_url.trim().length > 0
426
+ ? payload.html_url.trim()
427
+ : null;
428
+ if (commentId === null || commentUrl === null) {
429
+ throw new Error("Checkpoint verdict comment mutation did not return a comment id and html_url");
430
+ }
431
+ return { commentId, commentUrl };
432
+ }
433
+ async function createComment({ repo, pr, body }, { env, ghCommand }) {
434
+ const payload = await runGhJson(["api", "repos/" + repo + "/issues/" + pr + "/comments", "-f", `body=${body}`], { env, ghCommand });
435
+ return parseCommentMutationResponse(payload);
436
+ }
437
+ async function updateComment({ repo, commentId, body }, { env, ghCommand }) {
438
+ const payload = await runGhJson(["api", "-X", "PATCH", `repos/${repo}/issues/comments/${commentId}`, "-f", `body=${body}`], { env, ghCommand });
439
+ return parseCommentMutationResponse(payload);
440
+ }
441
+
442
+ async function verifyComment({ repo, commentId }, { env, ghCommand }) {
443
+ try {
444
+ const payload = await runGhJson(["api", `repos/${repo}/issues/comments/${commentId}`], { env, ghCommand });
445
+ return payload?.id != null;
446
+ } catch {
447
+ return false;
448
+ }
449
+ }
450
+
451
+ export async function upsertCheckpointVerdict(options, { env = process.env, ghCommand = "gh", repoRoot = process.cwd() } = {}) {
452
+ // Root cause 1: allow resurrected sessions to claim ownership when the previous
453
+ // run's coordination record is stale. Without this, a new run ID is rejected even
454
+ // though the old run is dead, forcing manual file deletion.
455
+ const envRunId = typeof env?.PI_SUBAGENT_RUN_ID === "string" ? env.PI_SUBAGENT_RUN_ID.trim() : "";
456
+ if (envRunId) {
457
+ try {
458
+ const staleCheck = await detectStaleRunner({ repo: options.repo, pr: options.pr, cwd: repoRoot });
459
+ if (staleCheck.status === "stale_runner") {
460
+ await claimRunnerOwnership({ repo: options.repo, pr: options.pr, runId: envRunId, cwd: repoRoot, mode: "takeover" });
461
+ }
462
+ } catch {
463
+ // Non-fatal: stale-runner takeover is best-effort. If it fails, the subsequent
464
+ // loadPrGateCoordinationContext call will surface the real error.
465
+ }
466
+ }
467
+ const coordinationContext = await loadPrGateCoordinationContext({ repo: options.repo, pr: options.pr }, { env, ghCommand });
468
+ const evidence = coordinationContext.gateEvidence;
469
+ const canonicalHeadSha = resolveRequestedHeadSha(options.headSha, evidence.currentHeadSha);
470
+ const { config } = await loadDevLoopConfig({ repoRoot });
471
+ const draftGateConfig = resolveGateConfig(config, "draft");
472
+ const preApprovalGateConfig = resolveGateConfig(config, "preApproval");
473
+ const maxCopilotRounds = resolveRefinementConfig(config, "maxCopilotRounds");
474
+ // Root cause 2: detect internal-only PRs so the Copilot convergence requirement
475
+ // is suppressed. Docs-only / tooling-only PRs should go straight to pre_approval_gate
476
+ // without requiring an external Copilot review cycle.
477
+ let reviewMode = null;
478
+ try {
479
+ const internalResult = await detectInternalOnly({ repo: options.repo, pr: options.pr }, { env, ghCommand });
480
+ if (internalResult?.ok && internalResult.internalOnly) {
481
+ reviewMode = "internal_only";
482
+ }
483
+ } catch {
484
+ // Non-fatal: internal-only detection failure is best-effort.
485
+ // Proceed with the default (external Copilot review) mode.
486
+ }
487
+ const coordination = evaluatePrGateCoordination({
488
+ repo: coordinationContext.repo,
489
+ pr: coordinationContext.pr,
490
+ currentHeadSha: coordinationContext.currentHeadSha,
491
+ prDraft: Boolean(coordinationContext.prData?.isDraft),
492
+ prClosed: String(coordinationContext.prData?.state || "").toUpperCase() === "CLOSED",
493
+ prMerged: String(coordinationContext.prData?.state || "").toUpperCase() === "MERGED",
494
+ lifecycleState: coordinationContext.interpretation.state,
495
+ loopDisposition: coordinationContext.disposition.loopDisposition,
496
+ ciStatus: coordinationContext.snapshot?.ciStatus ?? null,
497
+ copilotReviewRoundCount: coordinationContext.snapshot?.copilotReviewRoundCount ?? 0,
498
+ maxCopilotRounds,
499
+ sameHeadCleanConverged: coordinationContext.interpretation.sameHeadCleanConverged,
500
+ draftGateRequireCi: draftGateConfig.requireCi,
501
+ draftGate: coordinationContext.gateEvidence.draftGate,
502
+ draftGateMarker: coordinationContext.gateEvidence.draftGateMarker,
503
+ preApprovalGate: coordinationContext.gateEvidence.preApprovalGate,
504
+ preApprovalGateMarker: coordinationContext.gateEvidence.preApprovalGateMarker,
505
+ ...(reviewMode ? { reviewMode } : {}),
506
+ });
507
+ if (!options.gate) {
508
+ if (coordination.allowedNextActions.includes(PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE)) {
509
+ options.gate = "draft_gate";
510
+ } else if (coordination.allowedNextActions.includes(PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE)) {
511
+ options.gate = "pre_approval_gate";
512
+ } else if (coordination.allowedNextActions.includes(PR_CHECKPOINT_ACTION.RECONCILE_DRAFT_GATE)) {
513
+ options.gate = "draft_gate";
514
+ } else {
515
+ throw new Error(`Cannot auto-resolve gate for ${options.repo}#${options.pr}: no gate action is currently allowed (${coordination.reason})`);
516
+ }
517
+ }
518
+ const requestedGateAction = resolveGateAction(options.gate);
519
+ if (options.gate === "draft_gate" && coordination.draftGateAlreadySatisfied) {
520
+ throw new Error(
521
+ `Cannot enter draft_gate on ${options.repo}#${options.pr}: draft gate was already satisfied ` +
522
+ `(clean evidence exists, PR is no longer draft). ` +
523
+ `Do not re-post draft_gate. The draft→ready transition was already recorded.`,
524
+ );
525
+ }
526
+ const gateActionForbidden = coordination.forbiddenActions.includes(requestedGateAction);
527
+ if (gateActionForbidden) {
528
+ throw new Error(buildGateEntryRefusalError({ options, coordination }));
529
+ }
530
+ const activeGateConfig = options.gate === "draft_gate" ? draftGateConfig : preApprovalGateConfig;
531
+ if (
532
+ options.verdict === "clean"
533
+ && activeGateConfig.blockCleanOnFindingSeverities
534
+ && activeGateConfig.blockCleanOnFindingSeverities.length > 0
535
+ ) {
536
+ if (!options.findingsSeverityCounts) {
537
+ throw new Error(
538
+ `Cannot set verdict "clean" for ${options.gate}: --findings-severity-counts is required to verify that no unresolved blocking severities remain (example: --findings-severity-counts '{"must-fix":0,"worth-fixing-now":0,"defer":0}') (blocking: [${activeGateConfig.blockCleanOnFindingSeverities.join(", ")}]).`,
539
+ );
540
+ }
541
+ const missingBlockingKeys = activeGateConfig.blockCleanOnFindingSeverities.filter(
542
+ sev => !(sev in options.findingsSeverityCounts),
543
+ );
544
+ if (missingBlockingKeys.length > 0) {
545
+ throw new Error(
546
+ `Cannot set verdict "clean" for ${options.gate}: --findings-severity-counts must include explicit counts for all configured blocking severities. Missing: [${missingBlockingKeys.join(", ")}].`,
547
+ );
548
+ }
549
+ const blocking = activeGateConfig.blockCleanOnFindingSeverities.filter(
550
+ sev => (options.findingsSeverityCounts[sev] ?? 0) > 0,
551
+ );
552
+ if (blocking.length > 0) {
553
+ throw new Error(
554
+ `Cannot set verdict "clean" for ${options.gate}: unresolved findings remain at blocking severities [${blocking.join(", ")}]. Fix these findings and re-gate before declaring clean.`,
555
+ );
556
+ }
557
+ }
558
+ if (options.findingsFile) {
559
+ try {
560
+ const fileContent = await readFile(options.findingsFile, "utf8");
561
+ const trimmedEnd = fileContent.replace(/\n+$/, "");
562
+ if (trimmedEnd.length === 0) {
563
+ throw new Error(`--findings-file "${options.findingsFile}" is empty or contains only whitespace`);
564
+ }
565
+ const note = typeof coordination.gateEvidenceNote === "string" ? collapseWhitespace(coordination.gateEvidenceNote) : "";
566
+ const separator = trimmedEnd.includes("\n") ? "\n\n" : "; ";
567
+ const annotated = note.length > 0 ? `${trimmedEnd}${separator}${note}` : trimmedEnd;
568
+ options.findingsSummary = smartTruncate(annotated, MAX_GATE_COMMENT_TEXT_LENGTH);
569
+ } catch (err) {
570
+ throw new Error(`Cannot read --findings-file "${options.findingsFile}": ${err instanceof Error ? err.message : String(err)}`);
571
+ }
572
+ }
573
+ const effectiveFindingsSummary = options.findingsFile
574
+ ? options.findingsSummary
575
+ : appendGateEvidenceNote(options.findingsSummary, coordination.gateEvidenceNote ?? null);
576
+ const desiredBody = renderGateReviewCommentBody({
577
+ ...options,
578
+ headSha: canonicalHeadSha,
579
+ findingsSummary: effectiveFindingsSummary,
580
+ blockCleanOnFindingSeverities: activeGateConfig.blockCleanOnFindingSeverities,
581
+ });
582
+ const gateEvidence = selectGateEvidence(evidence, options.gate);
583
+ const existing = summarizeExistingComment({ ...gateEvidence, headSha: canonicalHeadSha });
584
+ const warning = detectStaleGateCommentWarning({ strict: gateEvidence.strict, headSha: canonicalHeadSha, gate: options.gate });
585
+ if (
586
+ existing
587
+ && existing.contractComplete
588
+ && existing.verdict === options.verdict
589
+ && existing.findingsSummary === effectiveFindingsSummary
590
+ && existing.nextAction === options.nextAction
591
+ ) {
592
+ return {
593
+ ok: true,
594
+ action: "noop",
595
+ repo: options.repo,
596
+ pr: options.pr,
597
+ gate: options.gate,
598
+ headSha: canonicalHeadSha,
599
+ currentHeadSha: evidence.currentHeadSha,
600
+ commentId: existing.commentId,
601
+ commentUrl: existing.commentUrl,
602
+ blockCleanOnFindingSeverities: activeGateConfig.blockCleanOnFindingSeverities,
603
+ ...(warning ? { warning } : {}),
604
+ };
605
+ }
606
+ if (existing) {
607
+ const updated = await updateComment({ repo: options.repo, commentId: existing.commentId, body: desiredBody }, { env, ghCommand });
608
+ // Post-update verification: verify the updated comment is visible via direct API fetch by comment ID.
609
+ // PI_SUBAGENT_RUN_ID is set (production context).
610
+ let updateVerificationWarning = null;
611
+ if (envRunId) {
612
+ let verified = await verifyComment({ repo: options.repo, commentId: updated.commentId }, { env, ghCommand });
613
+ if (!verified) {
614
+ await new Promise((resolve) => setTimeout(resolve, 2000));
615
+ verified = await verifyComment({ repo: options.repo, commentId: updated.commentId }, { env, ghCommand });
616
+ }
617
+ updateVerificationWarning = !verified
618
+ ? `Post-update verification failed: comment ${updated.commentId} not retrievable after retry.`
619
+ : null;
620
+ }
621
+ return {
622
+ ok: true,
623
+ action: "updated",
624
+ repo: options.repo,
625
+ pr: options.pr,
626
+ gate: options.gate,
627
+ headSha: canonicalHeadSha,
628
+ currentHeadSha: evidence.currentHeadSha,
629
+ commentId: updated.commentId,
630
+ commentUrl: updated.commentUrl,
631
+ blockCleanOnFindingSeverities: activeGateConfig.blockCleanOnFindingSeverities,
632
+ ...(warning ? { warning } : {}),
633
+ ...(updateVerificationWarning ? { verificationWarning: updateVerificationWarning } : {}),
634
+ };
635
+ }
636
+ const created = await createComment({ repo: options.repo, pr: options.pr, body: desiredBody }, { env, ghCommand });
637
+ // Post-creation verification: verify the comment is retrievable before returning.
638
+ // GitHub API can have brief eventual-consistency windows where a just-posted
639
+ // comment is not yet returned by paginated list endpoints. A direct fetch
640
+ // by comment ID confirms the comment is persisted, preventing the evidence
641
+ // checker from falsely reporting "missing" and triggering a duplicate post.
642
+ // Only active when PI_SUBAGENT_RUN_ID is set (production context).
643
+ let verified = true;
644
+ let verificationWarning = null;
645
+ if (envRunId) {
646
+ verified = await verifyComment({ repo: options.repo, commentId: created.commentId }, { env, ghCommand });
647
+ if (!verified) {
648
+ // Brief wait then retry — eventual consistency should resolve within ~2s.
649
+ await new Promise((resolve) => setTimeout(resolve, 2000));
650
+ verified = await verifyComment({ repo: options.repo, commentId: created.commentId }, { env, ghCommand });
651
+ }
652
+ verificationWarning = !verified
653
+ ? `Post-creation verification failed: comment ${created.commentId} not retrievable after retry. The comment was created (API confirmed) but may not appear in list endpoints immediately.`
654
+ : null;
655
+ }
656
+ return {
657
+ ok: true,
658
+ action: "created",
659
+ repo: options.repo,
660
+ pr: options.pr,
661
+ gate: options.gate,
662
+ headSha: canonicalHeadSha,
663
+ currentHeadSha: evidence.currentHeadSha,
664
+ commentId: created.commentId,
665
+ commentUrl: created.commentUrl,
666
+ blockCleanOnFindingSeverities: activeGateConfig.blockCleanOnFindingSeverities,
667
+ ...(warning ? { warning } : {}),
668
+ ...(verificationWarning ? { verificationWarning } : {}),
669
+ };
670
+ }
671
+ async function main() {
672
+ let options;
673
+ try {
674
+ options = parseUpsertCheckpointVerdictCliArgs(process.argv.slice(2));
675
+ } catch (error) {
676
+ process.stderr.write(`${formatCliError(error, { usage: USAGE })}\n`);
677
+ process.exitCode = 1;
678
+ return;
679
+ }
680
+ if (options.help) {
681
+ process.stdout.write(`${USAGE}\n`);
682
+ return;
683
+ }
684
+ try {
685
+ const result = await upsertCheckpointVerdict(options);
686
+ process.stdout.write(`${JSON.stringify(result)}\n`);
687
+ } catch (error) {
688
+ process.stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
689
+ process.exitCode = 1;
690
+ }
691
+ }
692
+ if (isDirectCliRun(import.meta.url)) {
693
+ await main();
694
+ }