dev-loops 0.6.0 → 0.7.1

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 (172) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +2 -1
  3. package/.claude/agents/review.md +2 -1
  4. package/.claude/commands/{auto.md → loop-auto.md} +1 -1
  5. package/.claude/commands/loop-continue.md +15 -0
  6. package/.claude/commands/loop-enqueue.md +22 -0
  7. package/.claude/commands/loop-grill.md +17 -0
  8. package/.claude/commands/{info.md → loop-info.md} +2 -2
  9. package/.claude/commands/loop-queue-status.md +24 -0
  10. package/.claude/commands/{start-spike.md → loop-start-spike.md} +1 -1
  11. package/.claude/commands/{start.md → loop-start.md} +1 -1
  12. package/.claude/commands/{status.md → loop-status.md} +1 -1
  13. package/.claude/hooks/_bash-command-classify.mjs +333 -29
  14. package/.claude/hooks/_hook-decisions.mjs +138 -15
  15. package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
  16. package/.claude/skills/copilot-pr-followup/SKILL.md +24 -12
  17. package/.claude/skills/dev-loop/SKILL.md +15 -11
  18. package/.claude/skills/docs/acceptance-criteria-verification.md +6 -2
  19. package/.claude/skills/docs/anti-patterns.md +2 -1
  20. package/.claude/skills/docs/artifact-authority-contract.md +22 -4
  21. package/.claude/skills/docs/copilot-loop-operations.md +1 -1
  22. package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
  23. package/.claude/skills/docs/issue-intake-procedure.md +1 -0
  24. package/.claude/skills/docs/local-planning-flow.md +1 -1
  25. package/.claude/skills/docs/local-planning-worked-example.md +1 -1
  26. package/.claude/skills/docs/merge-preconditions.md +17 -1
  27. package/.claude/skills/docs/plan-file-contract.md +1 -1
  28. package/.claude/skills/docs/public-dev-loop-contract.md +1 -1
  29. package/.claude/skills/docs/retrospective-checkpoint-contract.md +90 -76
  30. package/.claude/skills/docs/ui-e2e-scoping-step.md +32 -0
  31. package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
  32. package/.claude/skills/local-implementation/SKILL.md +25 -14
  33. package/.claude/skills/loop-grill/SKILL.md +163 -0
  34. package/AGENTS.md +1 -0
  35. package/CHANGELOG.md +95 -0
  36. package/README.md +9 -9
  37. package/agents/dev-loop.agent.md +3 -2
  38. package/agents/developer.agent.md +1 -1
  39. package/agents/docs.agent.md +1 -1
  40. package/agents/fixer.agent.md +1 -1
  41. package/agents/quality.agent.md +1 -1
  42. package/agents/refiner.agent.md +1 -1
  43. package/agents/review.agent.md +3 -2
  44. package/cli/index.mjs +35 -42
  45. package/extension/README.md +4 -4
  46. package/extension/checks.ts +1 -5
  47. package/extension/harness-types.ts +1 -0
  48. package/extension/index.ts +6 -0
  49. package/extension/pi-extension-adapter.ts +2 -0
  50. package/extension/presentation.ts +7 -19
  51. package/package.json +9 -10
  52. package/scripts/github/capture-review-threads.mjs +2 -9
  53. package/scripts/github/comment-issue.mjs +2 -9
  54. package/scripts/github/create-label.mjs +133 -0
  55. package/scripts/github/detect-checkpoint-evidence.mjs +170 -14
  56. package/scripts/github/detect-linked-issue-pr.mjs +22 -6
  57. package/scripts/github/edit-pr.mjs +259 -0
  58. package/scripts/github/fetch-ci-logs.mjs +2 -9
  59. package/scripts/github/list-issues.mjs +2 -9
  60. package/scripts/github/manage-sub-issues.mjs +46 -10
  61. package/scripts/github/offer-human-handoff.mjs +8 -5
  62. package/scripts/github/post-gate-findings.mjs +14 -2
  63. package/scripts/github/probe-ci-status.mjs +8 -2
  64. package/scripts/github/probe-copilot-review.mjs +21 -14
  65. package/scripts/github/ready-for-review.mjs +26 -8
  66. package/scripts/github/reconcile-draft-gate.mjs +7 -3
  67. package/scripts/github/reply-resolve-review-thread.mjs +16 -5
  68. package/scripts/github/reply-resolve-review-threads.mjs +69 -7
  69. package/scripts/github/request-copilot-review.mjs +8 -2
  70. package/scripts/github/resolve-handoff-candidates.mjs +7 -3
  71. package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
  72. package/scripts/github/stage-reviewer-draft.mjs +10 -2
  73. package/scripts/github/tick-verified-checkboxes.mjs +202 -0
  74. package/scripts/github/upsert-checkpoint-verdict.mjs +31 -10
  75. package/scripts/github/verify-fresh-review-context.mjs +150 -31
  76. package/scripts/github/view-pr.mjs +150 -0
  77. package/scripts/github/write-gate-context.mjs +248 -61
  78. package/scripts/github/write-gate-findings-log.mjs +75 -1
  79. package/scripts/lib/jq-output.mjs +18 -0
  80. package/scripts/loop/_post-convergence-change.mjs +211 -0
  81. package/scripts/loop/_pr-runner-coordination.mjs +70 -0
  82. package/scripts/loop/_repo-root-resolver.mjs +47 -0
  83. package/scripts/loop/build-handoff-envelope.mjs +14 -4
  84. package/scripts/loop/check-retro-tooling.mjs +14 -3
  85. package/scripts/loop/checkpoint-contract.mjs +7 -4
  86. package/scripts/loop/cleanup-worktree.mjs +12 -3
  87. package/scripts/loop/conductor-monitor.mjs +12 -18
  88. package/scripts/loop/copilot-pr-handoff.mjs +135 -14
  89. package/scripts/loop/debt-remediate.mjs +24 -12
  90. package/scripts/loop/detect-change-scope.mjs +36 -7
  91. package/scripts/loop/detect-copilot-loop-state.mjs +11 -5
  92. package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
  93. package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
  94. package/scripts/loop/detect-internal-only-pr.mjs +8 -2
  95. package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
  96. package/scripts/loop/detect-pr-gate-coordination-state.mjs +149 -66
  97. package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
  98. package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
  99. package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
  100. package/scripts/loop/ensure-worktree.mjs +8 -3
  101. package/scripts/loop/info.mjs +12 -4
  102. package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
  103. package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
  104. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
  105. package/scripts/loop/inspect-run-viewer.mjs +67 -4
  106. package/scripts/loop/inspect-run.mjs +8 -2
  107. package/scripts/loop/outer-loop.mjs +8 -4
  108. package/scripts/loop/pr-runner-coordination.mjs +2 -9
  109. package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
  110. package/scripts/loop/pre-flight-gate.mjs +9 -9
  111. package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
  112. package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
  113. package/scripts/loop/provision-worktree.mjs +74 -3
  114. package/scripts/loop/resolve-dev-loop-startup.mjs +76 -7
  115. package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
  116. package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
  117. package/scripts/loop/run-conductor-cycle.mjs +8 -2
  118. package/scripts/loop/run-queue.mjs +18 -9
  119. package/scripts/loop/run-refinement-audit.mjs +8 -9
  120. package/scripts/loop/run-watch-cycle.mjs +2 -9
  121. package/scripts/loop/sanctioned-commands.mjs +104 -0
  122. package/scripts/loop/steer-loop.mjs +29 -16
  123. package/scripts/loop/validate-pr-body-spec.mjs +207 -0
  124. package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
  125. package/scripts/pages/build-site.mjs +59 -8
  126. package/scripts/pages/build-state-atlas.mjs +441 -0
  127. package/scripts/projects/_resolve-project.mjs +148 -0
  128. package/scripts/projects/add-queue-item.mjs +60 -54
  129. package/scripts/projects/archive-done-items.mjs +49 -84
  130. package/scripts/projects/ensure-queue-board.mjs +10 -36
  131. package/scripts/projects/list-queue-items.mjs +116 -65
  132. package/scripts/projects/move-queue-item.mjs +28 -49
  133. package/scripts/projects/reconcile-queue.mjs +253 -0
  134. package/scripts/projects/reorder-queue-item.mjs +41 -47
  135. package/scripts/projects/resolve-active-board-item.mjs +108 -46
  136. package/scripts/projects/sync-item-status.mjs +15 -10
  137. package/scripts/refine/_refine-helpers.mjs +21 -5
  138. package/scripts/refine/exit-spike.mjs +18 -8
  139. package/scripts/refine/promote-plan.mjs +22 -16
  140. package/scripts/refine/prose-linkage-detector.mjs +1 -1
  141. package/scripts/refine/refine-plan-file.mjs +13 -4
  142. package/scripts/refine/refinement-completeness-checker.mjs +1 -1
  143. package/scripts/refine/scaffold-spike-file.mjs +4 -8
  144. package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
  145. package/scripts/refine/tree-integrity-validator.mjs +1 -1
  146. package/scripts/refine/validate-plan-file.mjs +1 -1
  147. package/scripts/refine/validate-spike-file.mjs +1 -1
  148. package/scripts/refine/verify.mjs +11 -6
  149. package/scripts/release/assert-core-dependency-version.mjs +123 -0
  150. package/scripts/release/extract-changelog-section.mjs +16 -2
  151. package/skills/copilot-pr-followup/SKILL.md +24 -12
  152. package/skills/dev-loop/SKILL.md +10 -6
  153. package/skills/docs/acceptance-criteria-verification.md +6 -2
  154. package/skills/docs/anti-patterns.md +2 -1
  155. package/skills/docs/artifact-authority-contract.md +22 -4
  156. package/skills/docs/copilot-loop-operations.md +1 -1
  157. package/skills/docs/cross-harness-regression-contract.md +60 -0
  158. package/skills/docs/issue-intake-procedure.md +1 -0
  159. package/skills/docs/local-planning-flow.md +1 -1
  160. package/skills/docs/local-planning-worked-example.md +1 -1
  161. package/skills/docs/merge-preconditions.md +17 -1
  162. package/skills/docs/plan-file-contract.md +1 -1
  163. package/skills/docs/public-dev-loop-contract.md +1 -1
  164. package/skills/docs/retrospective-checkpoint-contract.md +90 -76
  165. package/skills/docs/ui-e2e-scoping-step.md +32 -0
  166. package/skills/docs/workflow-handoff-contract.md +39 -0
  167. package/skills/local-implementation/SKILL.md +25 -14
  168. package/skills/loop-grill/SKILL.md +165 -0
  169. package/.claude/commands/continue.md +0 -15
  170. package/scripts/loop/conductor.mjs +0 -233
  171. package/scripts/loop/detect-stale-runner.mjs +0 -265
  172. package/scripts/loop/pre-push-main-guard.mjs +0 -117
@@ -8,6 +8,7 @@ import {
8
8
  detectIssueRefinementArtifact,
9
9
  REFINEMENT_SOURCE,
10
10
  } from "@dev-loops/core/loop/issue-refinement-artifact";
11
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
11
12
  const USAGE = `Usage:
12
13
  detect-issue-refinement-artifact.mjs --repo <owner/name> --issue <number>
13
14
  detect-issue-refinement-artifact.mjs --input <path>
@@ -31,7 +32,8 @@ Success output (stdout, JSON):
31
32
  "reason": "..."
32
33
  }
33
34
  Error output (stderr, JSON):
34
- { "ok": false, "error": "...", "usage": "..." }`.trim();
35
+ { "ok": false, "error": "...", "usage": "..." }
36
+ ${JQ_OUTPUT_USAGE}`.trim();
35
37
  const parseError = buildParseError(USAGE);
36
38
  export function parseDetectIssueRefinementArtifactCliArgs(argv) {
37
39
  const options = {
@@ -47,6 +49,7 @@ export function parseDetectIssueRefinementArtifactCliArgs(argv) {
47
49
  repo: { type: "string" },
48
50
  issue: { type: "string" },
49
51
  input: { type: "string" },
52
+ ...JQ_OUTPUT_PARSE_OPTIONS,
50
53
  },
51
54
  allowPositionals: true,
52
55
  strict: false,
@@ -79,6 +82,7 @@ export function parseDetectIssueRefinementArtifactCliArgs(argv) {
79
82
  options.input = requireTokenValue(token, parseError).trim();
80
83
  continue;
81
84
  }
85
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
82
86
  throw parseError(`Unknown argument: ${token.rawName}`);
83
87
  }
84
88
  const hasInput = typeof options.input === "string" && options.input.length > 0;
@@ -165,8 +169,7 @@ export async function runCli(
165
169
  }
166
170
  try {
167
171
  const result = await detectIssueRefinementArtifactFromOptions(options, { env, ghCommand });
168
- stdout.write(`${JSON.stringify(result)}\n`);
169
- return 0;
172
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
170
173
  } catch (error) {
171
174
  stderr.write(`${formatCliError(error)}\n`);
172
175
  return 1;
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import { readFile } from "node:fs/promises";
3
2
  import path from "node:path";
4
3
  import {
5
4
  buildParseError,
@@ -14,13 +13,16 @@ import {
14
13
  import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
15
14
  import { loadDevLoopConfig, resolveGateConfig, resolveRefinement, resolveRefinementConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
16
15
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
17
- import { buildSnapshotFromPrFacts, interpretLoopState, summarizeLoopInterpretation } from "@dev-loops/core/loop/copilot-loop-state";
16
+ import { buildSnapshotFromPrFacts, interpretLoopState, isCopilotRoundCapReached, summarizeLoopInterpretation } from "@dev-loops/core/loop/copilot-loop-state";
18
17
  import { evaluatePrGateCoordination, PR_CHECKPOINT, PR_CHECKPOINT_ACTION } from "@dev-loops/core/loop/pr-gate-coordination";
19
18
  import { shouldGuardCopilotReviewRequest } from "@dev-loops/core/loop/pr-gate-coordination";
20
19
  import { UI_E2E_CHECK_NAMES } from "@dev-loops/core/loop/ui-e2e-scoping";
21
20
  import { fetchGithubReviewThreadsPayload } from "../github/capture-review-threads.mjs";
21
+ import { detectPostConvergenceSignificantChange } from "./_post-convergence-change.mjs";
22
22
  import { detectCheckpointEvidence } from "../github/detect-checkpoint-evidence.mjs";
23
+ import { resolveRepoRoot } from "./_repo-root-resolver.mjs";
23
24
  import { parseArgs } from "node:util";
25
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
24
26
  const UNMERGED_GIT_STATUS_CODES = new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]);
25
27
  const USAGE = `Usage: detect-pr-gate-coordination-state.mjs --repo <owner/name> --pr <number>
26
28
  Determine which PR gate/transition is legal next for a pull request.
@@ -67,9 +69,11 @@ Output (stdout, JSON):
67
69
  Error output (stderr, JSON):
68
70
  { "ok": false, "error": "...", "usage": "..." }
69
71
  { "ok": false, "error": "..." }
72
+ ${JQ_OUTPUT_USAGE}
70
73
  Exit codes:
71
74
  0 Success
72
- 1 Argument error or gh/runtime failure`.trim();
75
+ 1 Argument error or gh/runtime failure
76
+ 2 Invalid --jq filter`.trim();
73
77
  const parseError = buildParseError(USAGE);
74
78
  export function parseDetectPrGateCoordinationCliArgs(argv) {
75
79
  const options = {
@@ -83,6 +87,7 @@ export function parseDetectPrGateCoordinationCliArgs(argv) {
83
87
  help: { type: "boolean", short: "h" },
84
88
  repo: { type: "string" },
85
89
  pr: { type: "string" },
90
+ ...JQ_OUTPUT_PARSE_OPTIONS,
86
91
  },
87
92
  allowPositionals: true,
88
93
  strict: false,
@@ -107,6 +112,7 @@ export function parseDetectPrGateCoordinationCliArgs(argv) {
107
112
  options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
108
113
  continue;
109
114
  }
115
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
110
116
  throw parseError(`Unknown argument: ${token.rawName}`);
111
117
  }
112
118
  if (options.repo === undefined || options.pr === undefined) {
@@ -221,30 +227,38 @@ export function deriveUiE2ePassed(prData, checkNames = UI_E2E_CHECK_NAMES) {
221
227
  return present.every((entry) => {
222
228
  const conclusion = String(entry?.conclusion ?? "").toUpperCase();
223
229
  const state = String(entry?.state ?? "").toUpperCase();
224
- return conclusion === "SUCCESS" || state === "SUCCESS";
230
+ // SKIPPED = "not applicable to this run" (e.g. viewer-smoke when no viewer files changed) — not a failure.
231
+ return conclusion === "SUCCESS" || conclusion === "SKIPPED" || state === "SUCCESS" || state === "SKIPPED";
225
232
  });
226
233
  }
227
234
 
228
- export function resolveLinkedIssueFromPr(prData) {
229
- if (!prData || typeof prData !== "object") return null;
235
+ // Ordered, de-duplicated list of ALL closing-referenced issue numbers for a PR.
236
+ // Umbrella PRs legitimately close multiple issues (#1052), so the refinement
237
+ // guard resolves against every one of them, not just a unique single ref.
238
+ export function resolveLinkedIssuesFromPr(prData) {
239
+ if (!prData || typeof prData !== "object") return [];
240
+ const dedupe = (nums) => {
241
+ const seen = new Set();
242
+ const out = [];
243
+ for (const n of nums) {
244
+ if (Number.isInteger(n) && n > 0 && !seen.has(n)) {
245
+ seen.add(n);
246
+ out.push(n);
247
+ }
248
+ }
249
+ return out;
250
+ };
230
251
  const closing = Array.isArray(prData.closingIssuesReferences) ? prData.closingIssuesReferences : [];
231
- const closingNumbers = closing
232
- .map((entry) => Number(entry?.number))
233
- .filter((n) => Number.isInteger(n) && n > 0);
234
- if (closingNumbers.length === 1) {
235
- return closingNumbers[0];
252
+ const closingNumbers = dedupe(closing.map((entry) => Number(entry?.number)));
253
+ if (closingNumbers.length > 0) {
254
+ return closingNumbers;
236
255
  }
237
256
  const body = typeof prData.body === "string" ? prData.body : "";
238
- if (body.length === 0) return null;
257
+ if (body.length === 0) return [];
239
258
  const matches = body.match(/(?:closes|fixes|resolves)\s+#(\d+)/gi) || [];
240
- const bodyNumbers = matches
241
- .map((m) => Number((/(\d+)/.exec(m) || [])[1]))
242
- .filter((n) => Number.isInteger(n) && n > 0);
243
- if (bodyNumbers.length === 1) {
244
- return bodyNumbers[0];
245
- }
246
- return null;
259
+ return dedupe(matches.map((m) => Number((/(\d+)/.exec(m) || [])[1])));
247
260
  }
261
+
248
262
  async function fetchIssueBody({ repo, issue }, { env = process.env, ghCommand = "gh" } = {}) {
249
263
  const result = await runChild(
250
264
  ghCommand,
@@ -261,58 +275,118 @@ async function fetchIssueBody({ repo, issue }, { env = process.env, ghCommand =
261
275
  return null;
262
276
  }
263
277
  }
264
- async function loadRefinementArtifact({ repo, prData, prDraft, prClosed, prMerged }, { env = process.env, ghCommand = "gh" } = {}) {
265
- const linkedIssue = resolveLinkedIssueFromPr(prData);
266
- if (linkedIssue === null) {
278
+ export async function loadRefinementArtifact({ repo, prData, prDraft, prClosed, prMerged }, { env = process.env, ghCommand = "gh" } = {}) {
279
+ const linkedIssues = resolveLinkedIssuesFromPr(prData);
280
+ if (linkedIssues.length === 0) {
267
281
  if (prDraft) {
268
282
  return {
269
283
  status: "missing",
270
284
  linkedIssue: null,
271
- reason: "Draft PR has no deterministically resolvable linked issue (no closingIssuesReferences, no unique Closes/Fixes/Resolves pattern in body); draft gate cannot verify a refinement artifact.",
285
+ linkedIssues: [],
286
+ reason: "Draft PR has no deterministically resolvable linked issue (no closingIssuesReferences and no Closes/Fixes/Resolves #n reference in body); draft gate cannot verify a refinement artifact.",
272
287
  finding: "missing_refinement_artifact",
273
288
  };
274
289
  }
275
290
  return {
276
291
  status: "unknown",
277
292
  linkedIssue: null,
278
- reason: "No deterministically resolvable linked issue (no closingIssuesReferences, no unique Closes/Fixes/Resolves pattern in body).",
293
+ linkedIssues: [],
294
+ reason: "No deterministically resolvable linked issue (no closingIssuesReferences and no Closes/Fixes/Resolves #n reference in body).",
279
295
  };
280
296
  }
297
+ const scopeLabel = linkedIssues.map((n) => `#${n}`).join(", ");
281
298
  if (!prDraft && !prClosed && !prMerged) {
282
299
  return {
283
300
  status: "unknown",
284
- linkedIssue,
285
- reason: `Linked issue #${linkedIssue} detected; refinement check is a draft-gate boundary and the PR is not draft, so the check is informational only and does not fetch the issue body.`,
301
+ linkedIssue: linkedIssues.length === 1 ? linkedIssues[0] : null,
302
+ linkedIssues,
303
+ reason: `Linked issue(s) ${scopeLabel} detected (${linkedIssues.length}); refinement check is a draft-gate boundary and the PR is not draft, so the check is informational only and does not fetch issue bodies.`,
304
+ };
305
+ }
306
+ const { detectIssueRefinementArtifact } = await import("@dev-loops/core/loop/issue-refinement-artifact");
307
+ // Fetch and evaluate every closing-referenced issue. An umbrella PR's scope
308
+ // is refined if AT LEAST ONE linked issue carries a refinement artifact.
309
+ const evaluated = [];
310
+ for (const issue of linkedIssues) {
311
+ const body = await fetchIssueBody({ repo, issue }, { env, ghCommand });
312
+ if (body === null) {
313
+ evaluated.push({ issue, artifact: null });
314
+ continue;
315
+ }
316
+ evaluated.push({ issue, artifact: detectIssueRefinementArtifact({ body, issueNumber: issue }) });
317
+ }
318
+ const refinedIssues = evaluated
319
+ .filter((e) => e.artifact && e.artifact.hasACs === true)
320
+ .map((e) => e.issue);
321
+ const firstPresent = evaluated.find((e) => e.artifact && e.artifact.hasACs === true);
322
+ const isUmbrella = linkedIssues.length > 1;
323
+
324
+ if (firstPresent) {
325
+ const a = firstPresent.artifact;
326
+ return {
327
+ status: "present",
328
+ linkedIssue: firstPresent.issue,
329
+ linkedIssues,
330
+ refinedIssues,
331
+ source: a.source,
332
+ acItems: a.acItems,
333
+ dodItems: a.dodItems,
334
+ sections: a.sections,
335
+ linkedDoc: a.linkedDoc,
336
+ reason: isUmbrella
337
+ ? `Refinement artifact present via linked issue #${firstPresent.issue} (umbrella PR closes ${scopeLabel}).`
338
+ : a.reason,
339
+ finding: a.finding,
340
+ _onlyEnforcedWhenDraft: prDraft === true,
286
341
  };
287
342
  }
288
- const body = await fetchIssueBody({ repo, issue: linkedIssue }, { env, ghCommand });
289
- if (body === null) {
343
+
344
+ // None of the linked issues carry a refinement artifact (or all bodies failed
345
+ // to fetch). Report against the first linked issue for single-value consumers.
346
+ // Note: `finding`/`missing` here is only enforced by the gate when the PR is
347
+ // draft (`_onlyEnforcedWhenDraft`); closed/merged PRs surface it informationally.
348
+ const firstEvaluated = evaluated[0];
349
+ const allFailed = evaluated.every((e) => e.artifact === null);
350
+ if (allFailed) {
351
+ // Preserve prior single-issue semantics: draft → missing, else unknown.
290
352
  if (prDraft) {
291
353
  return {
292
354
  status: "missing",
293
- linkedIssue,
294
- reason: `Failed to fetch body for linked issue #${linkedIssue}; draft gate cannot verify a refinement artifact, treating as missing.`,
355
+ linkedIssue: firstEvaluated.issue,
356
+ linkedIssues,
357
+ refinedIssues,
358
+ reason: `Failed to fetch body for linked issue(s) ${scopeLabel}; draft gate cannot verify a refinement artifact, treating as missing.`,
295
359
  finding: "missing_refinement_artifact",
296
360
  };
297
361
  }
298
362
  return {
299
363
  status: "unknown",
300
- linkedIssue,
301
- reason: `Failed to fetch body for linked issue #${linkedIssue}; refinement status is unknown.`,
364
+ linkedIssue: linkedIssues.length === 1 ? linkedIssues[0] : firstEvaluated.issue,
365
+ linkedIssues,
366
+ refinedIssues,
367
+ reason: `Failed to fetch body for linked issue(s) ${scopeLabel}; refinement status is unknown.`,
302
368
  };
303
369
  }
304
- const { detectIssueRefinementArtifact } = await import("@dev-loops/core/loop/issue-refinement-artifact");
305
- const artifact = detectIssueRefinementArtifact({ body, issueNumber: linkedIssue });
370
+ // Mixed branch: not allFailed, so at least one body fetched but none is
371
+ // refined. Report against the first successfully-fetched (non-null) issue —
372
+ // `evaluated[0]` may be a failed fetch: it still retains its `issue` field but
373
+ // has `artifact: null` (body fetch / artifact detection failed for that issue).
374
+ const firstFetched = evaluated.find((e) => e.artifact !== null);
375
+ const first = firstFetched.artifact;
306
376
  return {
307
- status: artifact.hasACs ? "present" : "missing",
308
- linkedIssue,
309
- source: artifact.source,
310
- acItems: artifact.acItems,
311
- dodItems: artifact.dodItems,
312
- sections: artifact.sections,
313
- linkedDoc: artifact.linkedDoc,
314
- reason: artifact.reason,
315
- finding: artifact.finding,
377
+ status: "missing",
378
+ linkedIssue: firstFetched.issue,
379
+ linkedIssues,
380
+ refinedIssues,
381
+ source: first.source,
382
+ acItems: first.acItems,
383
+ dodItems: first.dodItems,
384
+ sections: first.sections,
385
+ linkedDoc: first.linkedDoc,
386
+ reason: isUmbrella
387
+ ? `No linked issue (${scopeLabel}) carries a refinement artifact (ACs/DoD); draft gate cannot verify a refinement artifact.`
388
+ : first.reason,
389
+ finding: "missing_refinement_artifact",
316
390
  _onlyEnforcedWhenDraft: prDraft === true,
317
391
  };
318
392
  }
@@ -332,16 +406,6 @@ async function fetchLocalConflictFiles({ env = process.env, gitCommand = "git" }
332
406
  }
333
407
  return parseGitStatusConflictFiles(result.stdout);
334
408
  }
335
- async function loadRetrospectiveCheckpoint(repoRoot) {
336
- const checkpointPath = path.join(repoRoot, ".pi", "dev-loop-retrospective-checkpoint.json");
337
- try {
338
- const checkpointText = await readFile(checkpointPath, "utf8");
339
- const checkpoint = parseJsonText(checkpointText, { label: "retrospective checkpoint" });
340
- return checkpoint && typeof checkpoint === "object" ? checkpoint : null;
341
- } catch (error) {
342
- return null;
343
- }
344
- }
345
409
  export async function loadPrGateCoordinationContext(options, runtime = {}) {
346
410
  const prData = await fetchPrFactsWithSettledMergeable(options, runtime);
347
411
  const currentHeadSha = typeof prData?.headRefOid === "string" && prData.headRefOid.trim().length > 0
@@ -392,7 +456,7 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
392
456
  // READY_TO_REREQUEST_REVIEW, dead-ending the loop at the round cap (#896). This
393
457
  // keeps the gate-coordination interpretation consistent with the standalone
394
458
  // detect-copilot-loop-state path and with request-copilot-review's cap logic.
395
- const interpreterRepoRoot = runtime.repoRoot ?? process.cwd();
459
+ const interpreterRepoRoot = runtime.repoRoot ?? resolveRepoRoot(process.cwd());
396
460
  const interpreterConfigResult = await loadDevLoopConfig({ repoRoot: interpreterRepoRoot });
397
461
  const interpreterRefinementConfig = (Array.isArray(interpreterConfigResult.errors) && interpreterConfigResult.errors.length > 0)
398
462
  ? resolveRefinement({ version: 1 })
@@ -446,15 +510,32 @@ async function fetchCopilotEverFormallyRequested({ repo, pr }, { env = process.e
446
510
 
447
511
  export async function detectPrGateCoordinationState(options, runtime = {}) {
448
512
  const context = await loadPrGateCoordinationContext(options, runtime);
449
- const repoRoot = runtime.repoRoot ?? process.cwd();
513
+ const repoRoot = runtime.repoRoot ?? resolveRepoRoot(process.cwd());
450
514
  const configLoadResult = await loadDevLoopConfig({ repoRoot });
451
515
  const hasConfigErrors = Array.isArray(configLoadResult.errors) && configLoadResult.errors.length > 0;
452
516
  const config = hasConfigErrors ? {} : (configLoadResult.config ?? {});
453
517
  const draftGateConfig = resolveGateConfig(config, "draft");
454
518
  const maxCopilotRounds = resolveRefinementConfig(config, "maxCopilotRounds");
455
- const requireRetrospectiveGate = resolveWorkflowConfig(config, "requireRetrospectiveGate");
456
- const requireRetrospectiveInternalTooling = resolveWorkflowConfig(config, "requireRetrospectiveInternalTooling");
457
- const retrospectiveCheckpoint = await loadRetrospectiveCheckpoint(repoRoot);
519
+ // Shared with interpretLoopState (consumed by copilot-pr-handoff.mjs) and
520
+ // evaluatePrGateCoordination the single source of truth for "is the
521
+ // Copilot round cap reached" so this detector cannot disagree with the
522
+ // handoff at the cap boundary (#1126).
523
+ const roundCapReached = isCopilotRoundCapReached({
524
+ copilotReviewRoundCount: context.snapshot?.copilotReviewRoundCount,
525
+ maxCopilotRounds,
526
+ });
527
+ const postConvergenceSignificantChange = await detectPostConvergenceSignificantChange(
528
+ {
529
+ repo: context.repo,
530
+ pr: context.pr,
531
+ currentHeadSha: context.currentHeadSha,
532
+ reviews: context.prData?.reviews,
533
+ changedFiles: context.prData?.files,
534
+ roundCapReached: roundCapReached && context.interpretation?.roundCapCleanEligible === true,
535
+ regularCopilotRounds: (context.snapshot?.copilotReviewRoundCount ?? 0) > 0,
536
+ },
537
+ runtime,
538
+ );
458
539
  const result = evaluatePrGateCoordination({
459
540
  repo: context.repo,
460
541
  pr: context.pr,
@@ -476,14 +557,12 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
476
557
  maxCopilotRounds,
477
558
  sameHeadCleanConverged: context.interpretation.sameHeadCleanConverged,
478
559
  draftGateRequireCi: draftGateConfig.requireCi,
479
- requireRetrospectiveGate,
480
- requireRetrospectiveInternalTooling,
481
- retrospectiveCheckpoint,
482
560
  draftGate: context.gateEvidence.draftGate,
483
561
  draftGateMarker: context.gateEvidence.draftGateMarker,
484
562
  preApprovalGate: context.gateEvidence.preApprovalGate,
485
563
  preApprovalGateMarker: context.gateEvidence.preApprovalGateMarker,
486
564
  refinementArtifact: context.refinementArtifact,
565
+ postConvergenceSignificantChange,
487
566
  });
488
567
  // Copilot review request guard (#613): When Copilot has reviewed the PR
489
568
  // but no formal review request was made, block pre-approval gate entry.
@@ -495,9 +574,6 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
495
574
  PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
496
575
  PR_CHECKPOINT.FINAL_APPROVAL_READY,
497
576
  ]);
498
- const roundCapReached = maxCopilotRounds !== null
499
- && typeof (context.snapshot?.copilotReviewRoundCount) === "number"
500
- && context.snapshot?.copilotReviewRoundCount >= maxCopilotRounds;
501
577
  const sameHeadCleanConverged = context.interpretation?.sameHeadCleanConverged ?? false;
502
578
  // Round-cap clean fallback (#896): the interpreter resolved a clean post-cap head
503
579
  // (zero unresolved threads + green CI) that Copilot will not re-review. The formal
@@ -505,7 +581,13 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
505
581
  const roundCapCleanFallback = context.interpretation?.roundCapCleanEligible ?? false;
506
582
  const copilotReviewEverFormallyRequested = copilotReviewRequestStatus === "none"
507
583
  && guardBoundaries.has(result.gateBoundary)
508
- && !(roundCapReached && (sameHeadCleanConverged || roundCapCleanFallback))
584
+ // cap-0 disables the Copilot gate, so shouldGuardCopilotReviewRequest always
585
+ // returns false here — skip the timeline fetch it would never need (#1126).
586
+ // (Restores the suppression the roundCapReached predicate swap dropped.)
587
+ && maxCopilotRounds !== 0
588
+ && !(roundCapReached
589
+ && (sameHeadCleanConverged || roundCapCleanFallback)
590
+ && !postConvergenceSignificantChange)
509
591
  ? await fetchCopilotEverFormallyRequested(
510
592
  { repo: context.repo, pr: context.pr },
511
593
  runtime,
@@ -518,6 +600,7 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
518
600
  maxCopilotRounds,
519
601
  sameHeadCleanConverged,
520
602
  roundCapCleanFallback,
603
+ postConvergenceSignificantChange,
521
604
  gateBoundary: result.gateBoundary,
522
605
  })) {
523
606
  result.gateBoundary = PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW;
@@ -590,7 +673,7 @@ async function main() {
590
673
  }
591
674
  try {
592
675
  const result = await detectPrGateCoordinationState(options);
593
- process.stdout.write(`${JSON.stringify(result)}\n`);
676
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent });
594
677
  } catch (error) {
595
678
  process.stderr.write(`${formatCliError(error)}\n`);
596
679
  process.exitCode = 1;
@@ -8,6 +8,7 @@ import {
8
8
  interpretReviewerLoopState,
9
9
  normalizeReviewerSnapshot,
10
10
  } from "@dev-loops/core/loop/reviewer-loop-state";
11
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
11
12
  const HELP = `Usage: detect-reviewer-loop-state.mjs [--input <path> | --repo <owner/name> --pr <number>] [--review-requested <true|false>] [--local-state <path>]
12
13
  Detect reviewer loop state for a pull request.
13
14
  Modes:
@@ -17,9 +18,13 @@ Options (auto-detect mode only):
17
18
  --review-requested <bool> Override review-requested detection (true/false)
18
19
  --local-state <path> Path to local state file for snapshot merging
19
20
  Reviewer scope is auto-resolved from PR requested reviewers.
21
+
22
+ ${JQ_OUTPUT_USAGE}
23
+
20
24
  Exit codes:
21
25
  0 Success
22
26
  1 Error
27
+ 2 Invalid --jq filter
23
28
  `;
24
29
  function parseBool(value, flag) {
25
30
  if (value === "true") return true;
@@ -36,6 +41,7 @@ export function parseDetectReviewerCliArgs(argv) {
36
41
  pr: { type: "string" },
37
42
  "review-requested": { type: "string" },
38
43
  "local-state": { type: "string" },
44
+ ...JQ_OUTPUT_PARSE_OPTIONS,
39
45
  },
40
46
  allowPositionals: true,
41
47
  strict: false,
@@ -83,6 +89,7 @@ export function parseDetectReviewerCliArgs(argv) {
83
89
  options.localStatePath = requireTokenValue(token);
84
90
  continue;
85
91
  }
92
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t))) continue;
86
93
  throw new Error(`Unknown argument: ${token.rawName}`);
87
94
  }
88
95
  if (options.inputPath !== undefined) {
@@ -231,7 +238,7 @@ export async function autoDetectReviewerSnapshot(
231
238
  }
232
239
  export async function runCli(
233
240
  argv = process.argv.slice(2),
234
- { stdout = process.stdout, env = process.env, ghCommand = "gh" } = {},
241
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh" } = {},
235
242
  ) {
236
243
  const options = parseDetectReviewerCliArgs(argv);
237
244
  if (options.help) { stdout.write(HELP); return; }
@@ -243,7 +250,10 @@ export async function runCli(
243
250
  snapshot = await autoDetectReviewerSnapshot(options, { env, ghCommand });
244
251
  }
245
252
  const interpretation = interpretReviewerLoopState(snapshot);
246
- stdout.write(`${JSON.stringify({ ok: true, snapshot, state: interpretation.state, allowedTransitions: interpretation.allowedTransitions, nextAction: interpretation.nextAction })}\n`);
253
+ process.exitCode = emitResult(
254
+ { ok: true, snapshot, state: interpretation.state, allowedTransitions: interpretation.allowedTransitions, nextAction: interpretation.nextAction },
255
+ { jq: options.jq, silent: options.silent, stdout, stderr },
256
+ );
247
257
  }
248
258
  if (isDirectCliRun(import.meta.url)) {
249
259
  runCli().catch((error) => { process.stderr.write(`${formatCliError(error)}\n`); process.exitCode = 1; });
@@ -3,6 +3,7 @@ import process from "node:process";
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { parseArgs } from "node:util";
5
5
  import { interpretTrackerLoopState } from "@dev-loops/core/loop/tracker-first-loop-state";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
6
7
 
7
8
  function showHelp() {
8
9
  process.stdout.write(`Usage: detect-tracker-first-loop-state.mjs --repo <owner/name> --issue <number>
@@ -11,9 +12,13 @@ Options:
11
12
  --repo <owner/name> GitHub repository slug
12
13
  --issue <number> GitHub issue number
13
14
  --help, -h Show this help
15
+
16
+ ${JQ_OUTPUT_USAGE}
17
+
14
18
  Exit codes:
15
19
  0 Success
16
20
  1 Error
21
+ 2 Invalid --jq filter
17
22
  `);
18
23
  process.exit(0);
19
24
  }
@@ -25,6 +30,7 @@ function parseCliArgs(argv) {
25
30
  repo: { type: "string" },
26
31
  issue: { type: "string" },
27
32
  help: { type: "boolean", short: "h" },
33
+ ...JQ_OUTPUT_PARSE_OPTIONS,
28
34
  },
29
35
  allowPositionals: true,
30
36
  strict: false,
@@ -46,7 +52,9 @@ function parseCliArgs(argv) {
46
52
  }
47
53
  if (token.name === "issue") {
48
54
  opts.issue = token.value ?? null;
55
+ continue;
49
56
  }
57
+ if (matchJqOutputToken(token, opts)) continue;
50
58
  }
51
59
  }
52
60
  return opts;
@@ -90,7 +98,7 @@ async function main() {
90
98
  return;
91
99
  }
92
100
  const result = interpretTrackerLoopState({ trackerState: rawState, prContext });
93
- process.stdout.write(JSON.stringify(result) + "\n");
101
+ process.exitCode = emitResult(result, { jq: rawOpts.jq, silent: rawOpts.silent });
94
102
  }
95
103
  const isDirectRun =
96
104
  process.argv[1] && process.argv[1].includes("detect-tracker-first-loop-state.mjs");
@@ -9,6 +9,7 @@ import {
9
9
  interpretTrackerPrState,
10
10
  normalizeTrackerPrSnapshot,
11
11
  } from "@dev-loops/core/loop/tracker-pr-state";
12
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
12
13
  const USAGE = `Usage:
13
14
  detect-tracker-pr-state.mjs --input <path>
14
15
  Interpret a pre-built tracker-PR snapshot JSON and emit the current lifecycle
@@ -47,9 +48,11 @@ Output (stdout, JSON):
47
48
  Error output (stderr, JSON):
48
49
  Argument/usage errors: { "ok": false, "error": "...", "usage": "..." }
49
50
  Runtime failures: { "ok": false, "error": "..." }
51
+ ${JQ_OUTPUT_USAGE}
50
52
  Exit codes:
51
53
  0 Success
52
- 1 Argument error or runtime failure`.trim();
54
+ 1 Argument error or runtime failure
55
+ 2 Invalid --jq filter`.trim();
53
56
  const parseError = buildParseError(USAGE);
54
57
  export function parseDetectTrackerPrCliArgs(argv) {
55
58
  const options = {
@@ -61,6 +64,7 @@ export function parseDetectTrackerPrCliArgs(argv) {
61
64
  options: {
62
65
  help: { type: "boolean", short: "h" },
63
66
  input: { type: "string" },
67
+ ...JQ_OUTPUT_PARSE_OPTIONS,
64
68
  },
65
69
  allowPositionals: true,
66
70
  strict: false,
@@ -81,6 +85,7 @@ export function parseDetectTrackerPrCliArgs(argv) {
81
85
  options.inputPath = requireTokenValue(token, parseError);
82
86
  continue;
83
87
  }
88
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
84
89
  throw parseError(`Unknown argument: ${token.rawName}`);
85
90
  }
86
91
  if (options.inputPath === undefined) {
@@ -92,6 +97,7 @@ export async function runCli(
92
97
  argv = process.argv.slice(2),
93
98
  {
94
99
  stdout = process.stdout,
100
+ stderr = process.stderr,
95
101
  } = {},
96
102
  ) {
97
103
  const options = parseDetectTrackerPrCliArgs(argv);
@@ -103,8 +109,9 @@ export async function runCli(
103
109
  const raw = parseJsonText(text);
104
110
  const snapshot = normalizeTrackerPrSnapshot(raw);
105
111
  const { state, allowedTransitions, nextAction, reverseSyncAction } = interpretTrackerPrState(snapshot);
106
- stdout.write(
107
- `${JSON.stringify({ ok: true, snapshot, state, allowedTransitions, nextAction, reverseSyncAction })}\n`,
112
+ process.exitCode = emitResult(
113
+ { ok: true, snapshot, state, allowedTransitions, nextAction, reverseSyncAction },
114
+ { jq: options.jq, silent: options.silent, stdout, stderr },
108
115
  );
109
116
  }
110
117
  const isDirectRun =
@@ -27,6 +27,7 @@ import { parseArgs } from "node:util";
27
27
  import { resolveWorktreePath } from "@dev-loops/core/loop/handoff-envelope";
28
28
  import { provisionWorktree } from "./provision-worktree.mjs";
29
29
  import { canonicalize } from "./_worktree-path.mjs";
30
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
30
31
 
31
32
  const USAGE = `Usage:
32
33
  ensure-worktree.mjs --repo-root <p> (--issue <n> | --pr <n>) [--branch <name>] [--base <ref>]
@@ -43,7 +44,9 @@ Optional:
43
44
  -h, --help Show this help.
44
45
  Output (stdout, JSON):
45
46
  { "ok": true, "path": <p>, "created": bool, "reused": bool,
46
- "provision": { "actions": [...], "summary": {...} } }`.trim();
47
+ "provision": { "actions": [...], "summary": {...} } }
48
+
49
+ ${JQ_OUTPUT_USAGE}`.trim();
47
50
 
48
51
  const parseError = buildParseError(USAGE);
49
52
 
@@ -71,6 +74,7 @@ export function parseEnsureWorktreeCliArgs(argv) {
71
74
  pr: { type: "string" },
72
75
  branch: { type: "string" },
73
76
  base: { type: "string" },
77
+ ...JQ_OUTPUT_PARSE_OPTIONS,
74
78
  },
75
79
  allowPositionals: true,
76
80
  strict: false,
@@ -103,6 +107,7 @@ export function parseEnsureWorktreeCliArgs(argv) {
103
107
  options.base = requireTokenValue(token, parseError, { flagPattern: /^-/u });
104
108
  continue;
105
109
  }
110
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
106
111
  throw parseError(`Unknown argument: ${token.rawName}`);
107
112
  }
108
113
  if (options.help) return options;
@@ -201,14 +206,14 @@ export async function ensureWorktree(
201
206
  return { ok: true, path: target, created: true, reused: false, provision: summary };
202
207
  }
203
208
 
204
- export async function runCli(argv = process.argv.slice(2), { stdout = process.stdout } = {}) {
209
+ export async function runCli(argv = process.argv.slice(2), { stdout = process.stdout, stderr = process.stderr } = {}) {
205
210
  const options = parseEnsureWorktreeCliArgs(argv);
206
211
  if (options.help) {
207
212
  stdout.write(`${USAGE}\n`);
208
213
  return;
209
214
  }
210
215
  const result = await ensureWorktree(options);
211
- stdout.write(`${JSON.stringify(result)}\n`);
216
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
212
217
  }
213
218
 
214
219
  if (isDirectCliRun(import.meta.url)) {