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
@@ -17,6 +17,7 @@ import {
17
17
  writeLinkedPrNumber,
18
18
  PLAN_FILE_PROMOTE_ACTION,
19
19
  } from "@dev-loops/core/loop/plan-file-promote-contract";
20
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
20
21
 
21
22
  const CREATE_PR_PATH = fileURLToPath(new URL("../github/create-pr.mjs", import.meta.url));
22
23
 
@@ -42,9 +43,14 @@ Optional:
42
43
  --branch <name> Branch name to commit the plan on (default: derived from the plan path)
43
44
  --json Machine-readable JSON output
44
45
  --help Show this help
46
+
47
+ ${JQ_OUTPUT_USAGE}
48
+ (--jq/--silent only apply together with --json; the default text output is unaffected.)
49
+
45
50
  Exit codes:
46
51
  0 Promotion succeeded (or idempotent no-op for an already-linked plan)
47
- 1 Argument error, or fail-closed (plan not ready / git / PR-create failure); no issue ever created`.trim();
52
+ 1 Argument error, or fail-closed (plan not ready / git / PR-create failure); no issue ever created
53
+ 2 Invalid --jq filter`.trim();
48
54
 
49
55
  const parseError = buildParseError(USAGE);
50
56
 
@@ -57,6 +63,7 @@ export function parsePromotePlanCliArgs(argv) {
57
63
  base: { type: "string" },
58
64
  branch: { type: "string" },
59
65
  json: { type: "boolean" },
66
+ ...JQ_OUTPUT_PARSE_OPTIONS,
60
67
  },
61
68
  allowPositionals: true,
62
69
  strict: false,
@@ -88,6 +95,7 @@ export function parsePromotePlanCliArgs(argv) {
88
95
  options.json = true;
89
96
  continue;
90
97
  }
98
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
91
99
  throw parseError(`Unknown argument: ${token.rawName}`);
92
100
  }
93
101
  if (typeof options.planFile !== "string" || options.planFile.trim().length === 0) {
@@ -123,11 +131,14 @@ function parsePrNumberFromGhOutput(text) {
123
131
  return match ? Number.parseInt(match[1], 10) : null;
124
132
  }
125
133
 
126
- function emit(stdout, json, summary, humanLines) {
127
- if (json) {
128
- stdout.write(`${JSON.stringify(summary)}\n`);
134
+ // Sets process.exitCode from `summary.ok` (0/1), or the shared --jq/--silent
135
+ // contract's exit code (0/1/2) when --json + --jq/--silent are in play.
136
+ function emit(stdout, options, summary, humanLines) {
137
+ if (options.json) {
138
+ process.exitCode = emitResult(summary, { jq: options.jq, silent: options.silent, stdout });
129
139
  } else {
130
140
  stdout.write(`${humanLines.join("\n")}\n`);
141
+ process.exitCode = summary.ok ? 0 : 1;
131
142
  }
132
143
  }
133
144
 
@@ -161,8 +172,7 @@ export async function runCli(argv = process.argv.slice(2), {
161
172
  if (!decision.ok) {
162
173
  // Fail closed: no git, no gh, no issue — surface the reason and stop.
163
174
  const summary = { ok: false, reason: decision.reason, planFileIntakeState: decision.planFileIntakeState ?? null };
164
- emit(stdout, options.json, summary, [`promote-plan: FAIL (${decision.reason})`]);
165
- process.exitCode = 1;
175
+ emit(stdout, options, summary, [`promote-plan: FAIL (${decision.reason})`]);
166
176
  return summary;
167
177
  }
168
178
 
@@ -174,7 +184,7 @@ export async function runCli(argv = process.argv.slice(2), {
174
184
  planFile: planPath,
175
185
  prNumber: decision.existingPrNumber,
176
186
  };
177
- emit(stdout, options.json, summary, [
187
+ emit(stdout, options, summary, [
178
188
  "promote-plan: PASS (already promoted)",
179
189
  ` plan: ${planPath}`,
180
190
  ` pr: #${decision.existingPrNumber} (existing; opened nothing)`,
@@ -207,8 +217,7 @@ export async function runCli(argv = process.argv.slice(2), {
207
217
 
208
218
  const fail = (reason, detail) => {
209
219
  const summary = { ok: false, reason, detail: detail ?? null, planFile: planPath };
210
- emit(stdout, options.json, summary, [`promote-plan: FAIL (${reason})${detail ? `: ${detail}` : ""}`]);
211
- process.exitCode = 1;
220
+ emit(stdout, options, summary, [`promote-plan: FAIL (${reason})${detail ? `: ${detail}` : ""}`]);
212
221
  return summary;
213
222
  };
214
223
 
@@ -294,11 +303,10 @@ export async function runCli(argv = process.argv.slice(2), {
294
303
  branch,
295
304
  recovery: `gh pr create failed, so no PR was opened, but the plan is committed and branch ${branch} is pushed. Re-run promote-plan to recover: the commit step is idempotent (skips when nothing is staged) and the push is idempotent, so a clean re-run will retry opening the PR.`,
296
305
  };
297
- emit(stdout, options.json, summary, [
306
+ emit(stdout, options, summary, [
298
307
  `promote-plan: FAIL (pr_create_failed)${detail ? `: ${detail}` : ""}`,
299
308
  ` branch: ${branch} (committed and pushed; no PR opened — re-run promote-plan to recover, it is idempotent and will retry opening the PR)`,
300
309
  ]);
301
- process.exitCode = 1;
302
310
  return summary;
303
311
  }
304
312
  const prNumber = parsePrNumberFromGhOutput(prCreate.stdout);
@@ -315,11 +323,10 @@ export async function runCli(argv = process.argv.slice(2), {
315
323
  branch,
316
324
  recovery: `A draft PR was opened on branch ${branch} but its number could not be parsed from gh output. Find it with: gh pr list --head "${branch}" --json number,url. Then record the link with: node scripts/refine/promote-plan.mjs is idempotent only once the front-matter prNumber is set, so add it manually (prNumber: <n>) and commit ${planDocRelPath}.`,
317
325
  };
318
- emit(stdout, options.json, summary, [
326
+ emit(stdout, options, summary, [
319
327
  `promote-plan: FAIL (pr_number_unparseable)`,
320
328
  ` branch: ${branch} (a draft PR is open; its number could not be parsed — recover via 'gh pr list --head "${branch}"')`,
321
329
  ]);
322
- process.exitCode = 1;
323
330
  return summary;
324
331
  }
325
332
 
@@ -340,11 +347,10 @@ export async function runCli(argv = process.argv.slice(2), {
340
347
  prNumber,
341
348
  recovery: `PR #${prNumber} is open but the plan->PR link commit failed. ${planDocRelPath} now carries prNumber: ${prNumber} in its front-matter; record the link with: git add -- "${planDocRelPath}" && git commit -m "docs(plan): link to PR #${prNumber}" && git push.`,
342
349
  };
343
- emit(stdout, options.json, summary, [
350
+ emit(stdout, options, summary, [
344
351
  `promote-plan: FAIL (${reason})${detail ? `: ${detail}` : ""}`,
345
352
  ` pr: #${prNumber} (open; plan->PR link NOT committed — recover manually)`,
346
353
  ]);
347
- process.exitCode = 1;
348
354
  return summary;
349
355
  };
350
356
  const linkedMarkdown = writeLinkedPrNumber(markdownText, prNumber);
@@ -370,7 +376,7 @@ export async function runCli(argv = process.argv.slice(2), {
370
376
  branch,
371
377
  prNumber,
372
378
  };
373
- emit(stdout, options.json, summary, [
379
+ emit(stdout, options, summary, [
374
380
  "promote-plan: PASS",
375
381
  ` plan: ${planPath}`,
376
382
  ` branch: ${branch}`,
@@ -80,7 +80,7 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
80
80
  }
81
81
  const tree = await loadTreeFromInput(options.input);
82
82
  const result = runProseLinkageDetector(tree);
83
- writeCheckerOutput(result, { stdout, json: options.json });
83
+ process.exitCode = writeCheckerOutput(result, { stdout, json: options.json, jq: options.jq, silent: options.silent });
84
84
  return result;
85
85
  }
86
86
 
@@ -13,6 +13,7 @@ import {
13
13
  } from "@dev-loops/core/loop/plan-file-refine-contract";
14
14
  import { PLAN_FILE_REFINEMENT_SECTIONS } from "@dev-loops/core/loop/plan-file-intake-contract";
15
15
  import { classifyDocsGrillFinding } from "../loop/docs-grill-contract.mjs";
16
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
16
17
 
17
18
  const USAGE = `Usage:
18
19
  refine-plan-file.mjs --plan-file <path> --payload <path> [--json]
@@ -31,9 +32,14 @@ Required:
31
32
  Optional:
32
33
  --json Machine-readable JSON output
33
34
  --help Show this help
35
+
36
+ ${JQ_OUTPUT_USAGE}
37
+ (--jq/--silent only apply together with --json; the default text output is unaffected.)
38
+
34
39
  Exit codes:
35
40
  0 Refinement succeeded; plan written in place; stop for local human review
36
- 1 Argument error, or fail-closed refine/grill/ambiguous state (no write)`.trim();
41
+ 1 Argument error, or fail-closed refine/grill/ambiguous state (no write)
42
+ 2 Invalid --jq filter`.trim();
37
43
 
38
44
  const parseError = buildParseError(USAGE);
39
45
 
@@ -45,6 +51,7 @@ export function parseRefinePlanFileCliArgs(argv) {
45
51
  "plan-file": { type: "string" },
46
52
  payload: { type: "string" },
47
53
  json: { type: "boolean" },
54
+ ...JQ_OUTPUT_PARSE_OPTIONS,
48
55
  },
49
56
  allowPositionals: true,
50
57
  strict: false,
@@ -72,6 +79,7 @@ export function parseRefinePlanFileCliArgs(argv) {
72
79
  options.json = true;
73
80
  continue;
74
81
  }
82
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
75
83
  throw parseError(`Unknown argument: ${token.rawName}`);
76
84
  }
77
85
  if (typeof options.planFile !== "string" || options.planFile.trim().length === 0) {
@@ -123,11 +131,12 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
123
131
  if (!result.ok) {
124
132
  // Fail closed: surface the reason, do not write, do not advance, do not promote.
125
133
  if (options.json) {
126
- stdout.write(`${JSON.stringify({ ok: false, reason: result.reason, planFileIntakeState: result.planFileIntakeState ?? null })}\n`);
134
+ const failurePayload = { ok: false, reason: result.reason, planFileIntakeState: result.planFileIntakeState ?? null };
135
+ process.exitCode = emitResult(failurePayload, { jq: options.jq, silent: options.silent, stdout });
127
136
  } else {
128
137
  stdout.write(`refine-plan-file: FAIL (${result.reason})\n`);
138
+ process.exitCode = 1;
129
139
  }
130
- process.exitCode = 1;
131
140
  return result;
132
141
  }
133
142
 
@@ -142,7 +151,7 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
142
151
  grillDispositions: result.grillDispositions,
143
152
  };
144
153
  if (options.json) {
145
- stdout.write(`${JSON.stringify(summary)}\n`);
154
+ process.exitCode = emitResult(summary, { jq: options.jq, silent: options.silent, stdout });
146
155
  } else {
147
156
  stdout.write(
148
157
  [
@@ -76,7 +76,7 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
76
76
  }
77
77
  const tree = await loadTreeFromInput(options.input);
78
78
  const result = runRefinementCompletenessChecker(tree);
79
- writeCheckerOutput(result, { stdout, json: options.json });
79
+ process.exitCode = writeCheckerOutput(result, { stdout, json: options.json, jq: options.jq, silent: options.silent });
80
80
  return result;
81
81
  }
82
82
 
@@ -14,7 +14,7 @@ import path from "node:path";
14
14
  import { parseArgs } from "node:util";
15
15
 
16
16
  import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
17
- import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
17
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
18
18
  import { SPIKE_FILE_EXPLORATION_SECTIONS } from "./validate-spike-file.mjs";
19
19
 
20
20
  // Placeholder bodies the operator fills in during the spike. Non-empty so the
@@ -113,14 +113,10 @@ function parseCliArgs(argv) {
113
113
  case "out":
114
114
  args.out = requireValue(token, "--out requires a value (path)");
115
115
  break;
116
- case "jq":
117
- args.jq = requireValue(token, "--jq requires a filter");
118
- break;
119
- case "silent":
120
- args.silent = true;
121
- break;
122
- default:
116
+ default: {
117
+ if (matchJqOutputToken(token, args, (t) => requireValue(t, "--jq requires a filter"))) break;
123
118
  throw parseError(`Unknown flag: ${token.rawName}`);
119
+ }
124
120
  }
125
121
  }
126
122
  return args;
@@ -151,7 +151,7 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
151
151
  }
152
152
  const tree = await loadTreeFromInput(options.input);
153
153
  const result = runScopeBoundaryCrossChecker(tree);
154
- writeCheckerOutput(result, { stdout, json: options.json });
154
+ process.exitCode = writeCheckerOutput(result, { stdout, json: options.json, jq: options.jq, silent: options.silent });
155
155
  return result;
156
156
  }
157
157
 
@@ -199,7 +199,7 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
199
199
  }
200
200
  const tree = await loadTreeFromInput(options.input);
201
201
  const result = runTreeIntegrityValidator(tree);
202
- writeCheckerOutput(result, { stdout, json: options.json });
202
+ process.exitCode = writeCheckerOutput(result, { stdout, json: options.json, jq: options.jq, silent: options.silent });
203
203
  return result;
204
204
  }
205
205
 
@@ -52,7 +52,7 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
52
52
  }
53
53
  const markdownText = await readFile(options.input, "utf8");
54
54
  const result = validatePlanFile(markdownText);
55
- writeCheckerOutput(result, { stdout, json: options.json });
55
+ process.exitCode = writeCheckerOutput(result, { stdout, json: options.json, jq: options.jq, silent: options.silent });
56
56
  return result;
57
57
  }
58
58
 
@@ -75,7 +75,7 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
75
75
  }
76
76
  const markdownText = await readFile(options.input, "utf8");
77
77
  const result = validateSpikeFile(markdownText);
78
- writeCheckerOutput(result, { stdout, json: options.json });
78
+ process.exitCode = writeCheckerOutput(result, { stdout, json: options.json, jq: options.jq, silent: options.silent });
79
79
  return result;
80
80
  }
81
81
 
@@ -9,6 +9,7 @@ import { runProseLinkageDetector } from "./prose-linkage-detector.mjs";
9
9
  import { runScopeBoundaryCrossChecker } from "./scope-boundary-cross-checker.mjs";
10
10
  import { runRefinementCompletenessChecker } from "./refinement-completeness-checker.mjs";
11
11
  import { runTreeIntegrityValidator } from "./tree-integrity-validator.mjs";
12
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
12
13
 
13
14
  const USAGE = `Usage:
14
15
  dev-loops refine verify --issue <number> [--repo <owner/name>] [--json]
@@ -26,7 +27,10 @@ Required (exactly one mode):
26
27
  Optional:
27
28
  --repo <owner/name> Repository slug for online mode
28
29
  --json Machine-readable JSON output (default: human-readable summary)
29
- --help Show this help`.trim();
30
+ --help Show this help
31
+
32
+ ${JQ_OUTPUT_USAGE}
33
+ (--jq/--silent only apply together with --json; the default text output is unaffected.)`.trim();
30
34
 
31
35
  const parseError = buildParseError(USAGE);
32
36
 
@@ -39,6 +43,7 @@ export function parseRefineVerifyCliArgs(argv) {
39
43
  repo: { type: "string" },
40
44
  input: { type: "string" },
41
45
  json: { type: "boolean" },
46
+ ...JQ_OUTPUT_PARSE_OPTIONS,
42
47
  },
43
48
  allowPositionals: true,
44
49
  strict: false,
@@ -79,6 +84,7 @@ export function parseRefineVerifyCliArgs(argv) {
79
84
  options.json = true;
80
85
  continue;
81
86
  }
87
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
82
88
  throw parseError(`Unknown argument: ${token.rawName}`);
83
89
  }
84
90
 
@@ -177,13 +183,12 @@ export async function runCli(
177
183
  };
178
184
 
179
185
  if (options.json) {
180
- stdout.write(`${JSON.stringify(payload)}\n`);
186
+ process.exitCode = emitResult(payload, { jq: options.jq, silent: options.silent, stdout });
181
187
  } else {
182
188
  writeHumanOutput(result, tree, { stdout });
183
- }
184
-
185
- if (!result.ok) {
186
- process.exitCode = 1;
189
+ if (!result.ok) {
190
+ process.exitCode = 1;
191
+ }
187
192
  }
188
193
  return payload;
189
194
  }
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Release-time guard: fail closed when the published `dev-loops` package's
4
+ * `@dev-loops/core` dependency does not resolve to the same major.minor as the
5
+ * version being released. This is the check that would have caught #1033
6
+ * (`dev-loops@0.6.2` shipping `@dev-loops/core@^0.2.6`).
7
+ *
8
+ * Runs in release.yml BEFORE the GitHub Release is created, so a mismatched
9
+ * manifest never becomes a release (and never fires npm-publish). release.yml
10
+ * has no `npm ci`, so this script imports only `node:` builtins — a workspace
11
+ * import here would ERR_MODULE_NOT_FOUND and skip the guard entirely (same
12
+ * constraint as extract-changelog-section.mjs).
13
+ *
14
+ * Usage:
15
+ * node scripts/release/assert-core-dependency-version.mjs [--release-version <v>] [--manifest <path>]
16
+ *
17
+ * --release-version defaults to the manifest `version`. Pass the tag-derived
18
+ * version in release.yml so a manifest that forgot its own bump is also caught.
19
+ * Exits 0 on lockstep, 1 on mismatch, 2 on usage/parse errors.
20
+ */
21
+ import { realpathSync } from "node:fs";
22
+ import { readFile } from "node:fs/promises";
23
+ import { fileURLToPath } from "node:url";
24
+
25
+ const CORE_DEP = "@dev-loops/core";
26
+
27
+ function isDirectCliRun(importMetaUrl, argv1 = process.argv[1]) {
28
+ if (typeof argv1 !== "string" || argv1.length === 0) return false;
29
+ try {
30
+ return realpathSync(argv1) === realpathSync(fileURLToPath(importMetaUrl));
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Extract the `major.minor` token from a semver version or npm range.
38
+ * Handles leading range operators (^, ~, >=, >, <=, <, =, v) and compound
39
+ * ranges by taking the first `<digits>.<digits>` occurrence.
40
+ * @param {string} spec
41
+ * @returns {string} e.g. "0.6"
42
+ */
43
+ export function extractMajorMinor(spec) {
44
+ const match = String(spec).match(/(\d+)\.(\d+)/);
45
+ if (!match) {
46
+ throw new Error(`cannot parse a major.minor version from "${spec}"`);
47
+ }
48
+ return `${match[1]}.${match[2]}`;
49
+ }
50
+
51
+ /**
52
+ * Assert the `@dev-loops/core` dependency range is in lockstep (same
53
+ * major.minor) with the release version. Throws on mismatch (fail closed).
54
+ * @param {{releaseVersion: string, coreRange: string}} input
55
+ * @returns {{releaseVersion: string, coreRange: string, majorMinor: string}}
56
+ */
57
+ export function assertCoreDependencyInLockstep({ releaseVersion, coreRange } = {}) {
58
+ if (!releaseVersion) throw new Error("releaseVersion is required");
59
+ if (!coreRange) {
60
+ throw new Error(`root package must declare a "${CORE_DEP}" dependency`);
61
+ }
62
+ const releaseMajorMinor = extractMajorMinor(releaseVersion);
63
+ const coreMajorMinor = extractMajorMinor(coreRange);
64
+ if (releaseMajorMinor !== coreMajorMinor) {
65
+ throw new Error(
66
+ `${CORE_DEP} dependency "${coreRange}" (major.minor ${coreMajorMinor}) does not match ` +
67
+ `the release version ${releaseVersion} (major.minor ${releaseMajorMinor}). ` +
68
+ `Bump the ${CORE_DEP} range to ^${releaseMajorMinor}.0 before releasing (root cause of #1033).`,
69
+ );
70
+ }
71
+ return { releaseVersion, coreRange, majorMinor: releaseMajorMinor };
72
+ }
73
+
74
+ function usageError(message) {
75
+ const err = new Error(message);
76
+ err.usage = true;
77
+ return err;
78
+ }
79
+
80
+ function parseArgs(argv) {
81
+ const out = { manifest: "package.json", releaseVersion: null };
82
+ for (let i = 0; i < argv.length; i += 1) {
83
+ const arg = argv[i];
84
+ if (arg === "--release-version" || arg === "--manifest") {
85
+ const value = argv[++i];
86
+ // Fail closed like extract-changelog-section.mjs: a flag missing its value
87
+ // is a usage error (exit 2), not a silent fallback that defeats the guard.
88
+ if (value === undefined) throw usageError(`${arg} requires a value`);
89
+ if (arg === "--release-version") out.releaseVersion = value;
90
+ else out.manifest = value;
91
+ } else {
92
+ throw usageError(`unknown argument: ${arg}`);
93
+ }
94
+ }
95
+ return out;
96
+ }
97
+
98
+ async function main(argv) {
99
+ const { manifest, releaseVersion } = parseArgs(argv);
100
+ // An unreadable or invalid manifest is a usage/parse error (exit 2), not a
101
+ // lockstep mismatch (exit 1) — matches the header contract and extract-changelog-section.mjs.
102
+ let pkg;
103
+ try {
104
+ pkg = JSON.parse(await readFile(manifest, "utf8"));
105
+ } catch (err) {
106
+ throw usageError(`cannot read or parse manifest "${manifest}": ${err.message}`);
107
+ }
108
+ const coreRange = pkg.dependencies?.[CORE_DEP];
109
+ const result = assertCoreDependencyInLockstep({
110
+ releaseVersion: releaseVersion ?? pkg.version,
111
+ coreRange,
112
+ });
113
+ process.stdout.write(
114
+ `${CORE_DEP} ${result.coreRange} is in lockstep with release ${result.releaseVersion} (major.minor ${result.majorMinor}).\n`,
115
+ );
116
+ }
117
+
118
+ if (isDirectCliRun(import.meta.url)) {
119
+ main(process.argv.slice(2)).catch((err) => {
120
+ process.stderr.write(`::error::${err.message}\n`);
121
+ process.exit(err.usage ? 2 : 1);
122
+ });
123
+ }
@@ -1,6 +1,20 @@
1
+ import { realpathSync } from "node:fs";
1
2
  import { readFile } from "node:fs/promises";
2
-
3
- import { isDirectCliRun } from "../_core-helpers.mjs";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ // Inlined (was scripts/_core-helpers.mjs -> @dev-loops/core): this script runs
6
+ // in release.yml with no `npm ci`, so it must import only node: builtins.
7
+ // A workspace import here ERR_MODULE_NOT_FOUNDs and the Release never gets made.
8
+ function isDirectCliRun(importMetaUrl, argv1 = process.argv[1]) {
9
+ if (typeof argv1 !== "string" || argv1.length === 0) {
10
+ return false;
11
+ }
12
+ try {
13
+ return realpathSync(argv1) === realpathSync(fileURLToPath(importMetaUrl));
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
4
18
 
5
19
  const USAGE = `Usage: extract-changelog-section.mjs --version <v> [--changelog <path>]
6
20
 
@@ -243,7 +243,8 @@ When unresolved feedback exists, use a narrow follow-up loop:
243
243
  - resolve the review-round cap from config via `resolveRefinementConfig(config, "maxCopilotRounds")` from `@dev-loops/core/config`; default config ships `maxCopilotRounds: 5`
244
244
  - **Opt out entirely:** `maxCopilotRounds: 0` disables the external Copilot review gate for the repo — the loop runs `draft_gate → pre_approval_gate` with the local harness only, never requesting or waiting on Copilot. Use this when the repo has no Copilot reviewer configured or prefers local-harness-only review.
245
245
  - use the completed Copilot review-round count from `detect-copilot-loop-state.mjs` / `copilot-pr-handoff.mjs` as the current PR's review-round count
246
- - if completed review rounds have reached the maximum (default: 5), do **not** re-request Copilot review
246
+ - if completed review rounds have reached the maximum (default: 5), do **not** re-request Copilot review within that concluded cycle
247
+ - if the loop already converged and then significant post-convergence changes land on a newer head (new/changed product or test logic, not doc/message/comment-only edits), treat that as a NEW cycle and re-request Copilot review when regular rounds are already > 0 (the prior cycle's cap does not suppress this new-cycle request)
247
248
  - when the round limit is reached **and** the refreshed thread snapshot proves zero unresolved threads **and** current-head CI is green or credibly green, treat that clean state as eligible for `pre_approval_gate` fallback instead of deadlocking on another Copilot rerequest
248
249
  - when using that fallback, add a short round-exhaustion note to the visible `pre_approval_gate` gate evidence so the PR records why no further Copilot rerequest occurred
249
250
  - if the round cap is reached before the PR is thread-clean or before CI is green/credibly green, reply-resolve any remaining intentionally deferred threads with a short `deferred to follow-up` note, then stop and report that the Copilot round limit was reached
@@ -305,7 +306,7 @@ Do NOT use `gh pr comment`, `gh api`, or `gh pr review` for gate comments.
305
306
  Both gates run this same checkpoint review chain. It is an **agent-orchestrated skill procedure** — a node script cannot spawn the per-angle reviewers, so the conductor agent drives the fan-out and uses the pure `@dev-loops/core/loop/gate-fanin` helpers only for consolidation, batching, and ledger mapping. The cost model is **build once, seed many**: the context-builder script builds ONE deterministic, neutral context bundle (diff + adjacent code) and each independent reviewer is seeded with that identical bundle verbatim instead of re-deriving it (work-dedup; no fork primitive, no Workflow tool). See [Gate Review Sub-Loop Contract](../../docs/gate-review-sub-loop-contract.md).
306
307
 
307
308
  1. **Context (Phase 1) — build the neutral bundle ONCE:** build/read the gate-context artifact via `scripts/github/write-gate-context.mjs` (`buildGateContext` resolves the angle set through `resolveGateAnglesDynamic`; mandatory angles always survive). The artifact at `tmp/gate-context/<repo-slug>/pr-<N>/<gate>-<headSha>.json` carries the resolved angles + change scope + acceptance-criteria pointer. When the resolver was handed a diff with `diffOutput`, `buildGateContext` also persists the FULL diff to `tmp/gate-context/<repo-slug>/pr-<N>/<gate>-<headSha>.diff` and records `scope.diffPath` (relative) plus `scope.changedFiles` (full repo-relative paths). It ADDITIONALLY builds a deterministic, neutral `adjacentCode` bundle — each changed file plus its 1-hop import in/out-edges (callers/callees/imports), with size guards (skip lockfiles/generated/binary/minified; cap per-file bytes; truncate the long tail) recorded in a `stripped`/`truncated`/`missing` manifest. This single bundle is the build-once seed; reviewers do not re-derive the diff + adjacent code.
308
- 2. **Fan-out (Phase 2) — seed each reviewer with the bundle verbatim:** plan batches with `planFanoutBatches(angles, resolveMaxFanoutReviewers(config))` (default cap 8). Spawn one scoped `review` agent per resolved angle (plain Agent tool), parallel up to the cap, batching any overflow sequentially. When `degraded` is true, record the sequential degradation in the gate evidence. Reviewers are independent: each is seeded with the IDENTICAL neutral bundle (the gate-context artifact's `scope.diffPath` + `adjacentCode`) verbatim plus its single angle, and never inherits the conductor agent's conversation or opinions. Each reviewer follows the [review agent scoped angle-review mode](../../agents/review.agent.md): fresh context, single angle, read-only, writing its per-angle findings artifact to `tmp/gate-reviews/<repo-slug>/pr-<N>/<gate>-<headSha>/<angle>.json`. Brief each reviewer to use the provided bundle as its base and to review like an external code reviewer hunting real bugs: read the FULL diff (from `scope.diffPath`, or `git diff` when null) and the bundled adjacent code (callers, callees, imports) rather than re-deriving them, then review adversarially for concrete defects (edge cases, input validation, numeric coercion incl. NaN/Infinity/floats/negatives, null/undefined, boundary conditions, mismatched caller/callee contracts, dedup/identity bugs) with `file:line` + the failing scenario — not process nits like "no test exists". Reviewers are read-only and MAY widen scope (open adjacent repo files beyond the bundle) only when their angle genuinely needs more; that widening is recorded in the optional `contextWidened` field on each per-angle findings artifact.
309
+ 2. **Fan-out (Phase 2) — seed each reviewer with the bundle verbatim:** plan batches with `planFanoutBatches(angles, cap)` using the `gates.maxFanoutReviewers` cap (default 8). Spawn one scoped `review` agent per resolved angle (plain Agent tool), parallel up to the cap, batching any overflow sequentially. When `degraded` is true, record the sequential degradation in the gate evidence. Reviewers are independent: each is seeded with the IDENTICAL neutral bundle (the gate-context artifact's `scope.diffPath` + `adjacentCode`) verbatim plus its single angle, and never inherits the conductor agent's conversation or opinions. Each reviewer follows the [review agent scoped angle-review mode](../../agents/review.agent.md): fresh context, single angle, read-only, writing its per-angle findings artifact to `tmp/gate-reviews/<repo-slug>/pr-<N>/<gate>-<headSha>/<angle>.json`. Brief each reviewer to use the provided bundle as its base and to review like an external code reviewer hunting real bugs: read the FULL diff (from `scope.diffPath`, or `git diff` when null) and the bundled adjacent code (callers, callees, imports) rather than re-deriving them, then review adversarially for concrete defects (edge cases, input validation, numeric coercion incl. NaN/Infinity/floats/negatives, null/undefined, boundary conditions, mismatched caller/callee contracts, dedup/identity bugs) with `file:line` + the failing scenario — not process nits like "no test exists". Reviewers are read-only and MAY widen scope (open adjacent repo files beyond the bundle) only when their angle genuinely needs more; that widening is recorded in the optional `contextWidened` field on each per-angle findings artifact.
309
310
  3. **Fan-in (Phase 3):** consolidate the per-angle artifacts via `consolidateFanin({ angleResults, blockCleanOnFindingSeverities })` (blocking severities from `resolveGateConfig(config, <configKey>).blockCleanOnFindingSeverities`, where `<configKey>` is the config key for the gate — `draft` for `draft_gate`, `preApproval` for `pre_approval_gate` — not the `draft_gate|pre_approval_gate` artifact name). It yields the gate `verdict` (`clean` when no blocking-severity finding remains, `findings_present` when one does, `blocked` when any per-angle artifact is malformed/missing). Map consolidated findings with `toFindingsLogShape(...)` and write the disposition ledger via `write-gate-findings-log.mjs` **before** the visible comment. The ledger is written regardless of any comment opt-out. Then, when `resolveGatePostFindingsComments(config)` is true (default; opt-out via `gates.postFindingsComments: false`), post the consolidated findings as a visible, marker-tagged PR comment via `node <resolved-skill-scripts>/github/post-gate-findings.mjs --repo <owner/name> --pr <number> --gate <gate> --head-sha <current_head_sha> --findings '<toFindingsLogShape JSON>'` so the findings are auditable and Copilot/humans are aware of them. The helper is idempotent per gate (exactly one comment per gate, updated in place on each run; the reviewed head is shown in the body) and posts a brief "no findings" note when the consolidated set is empty. When `postFindingsComments` is false the helper no-ops with a `skipped` result and you skip this post step; the ledger still records the findings.
310
311
  4. **Verdict (Phase 4):** post the verdict with the [Gate comment command](#mandatory-gate-comment-command-contract) using `--execution-mode fanout_fanin` and `--findings-json <path>`. Write that JSON from the collected per-angle results (the same per-angle `{angle, verdict, findings}` artifacts at `tmp/gate-reviews/<repo-slug>/pr-<N>/<gate>-<headSha>/<angle>.json` that fed `consolidateFanin` in Phase 3; the flat `toFindingsLogShape` output is also accepted and grouped by `.angle`) so the verdict comment renders the structured per-angle breakdown instead of a wall-of-text summary. Do NOT use `--findings-summary` for a fan-out gate — that is the inline_single_agent fallback.
311
312
  5. **Retry (Phase 5):** on blocking findings, drive each internal fan-out finding through the SAME fix → reply-with-resolving-commit → resolve loop used for external Copilot review comments (Step 7 above): fix on the branch, push the resolving commit, then re-run only the `findings_present` angles from the previous pass (the context-builder and fan-in always re-run); repeat until the consolidated verdict is `clean` for the current head SHA. Internal fan-out findings and external review comments are handled identically by that loop.
@@ -322,11 +323,7 @@ The canonical checkpoint verdict comment contract is [Gate Review Comment Contra
322
323
  - **CI prerequisite:** resolve the draft gate config first (`resolveGateConfig(config, "draft")`). When `requireCi=true` (default), wait for green current-head CI before entering `draft_gate`. When `requireCi=false`, the draft gate may proceed without green CI. This draft-only override does **not** relax `pre_approval_gate`; final approval and merge readiness still require green current-head CI.
323
324
  - **Pass criteria:** all configured draft gate angles pass; all findings at severities in `blockCleanOnFindingSeverities` are addressed; validation passes; no unrelated files are included.
324
325
  - **Next step after passing:** mark the PR ready for review.
325
- - **Board status sync (best-effort, after ready-for-review):** once the PR is created/marked ready for review, sync the linked issue's board item to "In Progress" so the queue board reflects active work without a manual `dev-loops queue sync-status --to-column <col>`:
326
- ```sh
327
- node <resolved-skill-scripts>/projects/sync-item-status.mjs --repo <owner/name> --item <linked-issue> --to-column "In Progress"
328
- ```
329
- Best-effort and NON-FATAL: it uses local `gh` auth (no CI/PAT), exits 0 when the board is not configured / the item is not on the board / the API fails, and never blocks marking the PR ready.
326
+ - **Board status sync (built-in, after ready-for-review):** the In-Progress board move is now performed automatically as a deterministic tail of `ready-for-review.mjs` marking the PR ready couples the board move to the ready transition (#1069), so no separate `sync-item-status` step is needed. It stays best-effort and NON-FATAL: it uses local `gh` auth (no CI/PAT), exits 0 when the board is not configured / the item is not on the board / the API fails, and never blocks marking the PR ready.
330
327
  - **Non-substitution rule:** a clean `draft_gate` comment only authorizes the draft → ready-for-review transition for that head SHA. It does **not** satisfy `pre_approval_gate`, final-approval readiness, or merge-ready requirements.
331
328
  - **Required PR comment:** post a visible checkpoint verdict comment using the mandatory [Gate comment command](#mandatory-gate-comment-command-contract). Keep validation reporting concise: include command names with pass/fail status. Do **not** paste raw passing test output into the visible gate comment. If you include a failing validation excerpt, keep it focused and truncate it to a deterministic retained-prefix length before posting the comment. See [Gate Review Comment Contract](../../docs/gate-review-comment-contract.md). Do not run `gh pr ready` unless a visible `clean` `draft_gate` checkpoint verdict comment exists for the current head SHA. A checkpoint verdict comment for an older head SHA does not satisfy this requirement for the current head. If findings exist, the PR stays draft and needs fixes before retry. If fixes advance the head SHA while still draft, post a new checkpoint verdict comment for the new head. If the checkpoint verdict comment cannot be posted, fail closed and do not run `gh pr ready`.
332
329
 
@@ -340,7 +337,7 @@ This is the default pre-approval gate for this workflow boundary. The canonical
340
337
  - **Review angles:** resolved at runtime from config via `resolveGateAngles(config, "preApproval")` from `@dev-loops/core/config`. Default config enables all configured pre-approval gate angle families; consumer repos may opt out individual angles via `excludeAngles`.
341
338
  - **Persona mapping:** each angle resolves to a reviewer persona via `resolveReviewerRole(config, angle)` from `@dev-loops/core/config`. Include this prompt in each reviewer's briefing so the reviewer knows exactly what to look for.
342
339
  - **Pass criteria:** the sub-loop completes with verdict `clean`; all configured angles pass; if parallel execution is impractical, still run all configured lenses and explicitly record the limitation.
343
- - **Acceptance criteria verification:** follow the canonical procedure in [Acceptance Criteria Verification](../docs/acceptance-criteria-verification.md) before posting the `pre_approval_gate` comment.
340
+ - **Acceptance criteria verification:** follow the canonical procedure in [Acceptance Criteria Verification](../docs/acceptance-criteria-verification.md) before posting the `pre_approval_gate` comment. After a clean verification this also ticks the verified PR-body checkboxes via `scripts/github/tick-verified-checkboxes.mjs`, so the merged PR shows checked AC.
344
341
  - **Next step after passing:** continue the Step 7 flow and then proceed to the human approval checkpoint below.
345
342
  - **Non-substitution rule:** a clean `pre_approval_gate` comment is separate from `draft_gate` evidence. It governs final-approval readiness for that head SHA; it does **not** replace the required `draft_gate` evidence for leaving draft.
346
343
  - **Required PR comment:** post a visible checkpoint verdict comment using the mandatory [Gate comment command](#mandatory-gate-comment-command-contract). Keep validation reporting concise: include command names with pass/fail status. Do **not** paste raw passing test output into the visible gate comment. If you include a failing validation excerpt, keep it focused and truncate it to a deterministic retained-prefix length before posting the comment. Do not declare final-approval readiness unless a visible `clean` `pre_approval_gate` checkpoint verdict comment exists for the current head SHA. Final-approval readiness must not rely only on local or hidden artifacts; the visible PR comment is the required auditable evidence. If the checkpoint verdict comment cannot be posted, fail closed and do not declare final-approval readiness. A checkpoint verdict comment for an older head SHA does not satisfy this requirement for the current head. If findings exist, follow-up fixes are required before final approval. The `pre_approval_gate` procedure must be entered and completed (visible comment posted) before any merge-ready or approval-ready declaration. Skipping the gate is not recoverable by asserting convergence. If fixes advance the head SHA, post a new checkpoint verdict comment for the new head.
@@ -350,14 +347,14 @@ This is the default pre-approval gate for this workflow boundary. The canonical
350
347
  Before any merge-ready or final-approval claim, run `detect-pr-gate-coordination-state.mjs` for the current PR. If it reports `gateBoundary=conflict_resolution` or `mergeStateStatus` is conflicted, stop the normal gate path immediately and use this recovery flow:
351
348
 
352
349
  1. fetch fresh `origin/main`, confirm the current PR head SHA, and summarize the conflict scope from `mergeStateStatus` plus any reported `conflictFiles`
353
- 2. ask for explicit authorization before any rebase or other branch-state-changing reconciliation command
354
- 3. after authorization, reconcile locally on the PR branch; default to rebase onto latest `origin/main`, unless the operator explicitly chooses another conflict-resolution command
350
+ 2. ask for explicit authorization before any merge commit or other branch-state-changing reconciliation command
351
+ 3. after authorization, reconcile locally on the PR branch; default to a merge commit (`git merge origin/main`) per the behind-branch integration policy in [Local Implementation Skill](../local-implementation/SKILL.md#branch--review--merge-policy), unless the operator explicitly chooses another conflict-resolution command
355
352
  4. auto-resolve simple conflicts when the correct fix is mechanical and clearly in scope; report complex conflicts explicitly and fix them manually only for in-scope files
356
353
  5. rerun the smallest honest local validation for the touched conflict slice
357
354
  6. rerun `detect-pr-gate-coordination-state.mjs` for the new head
358
355
  7. because the head changed, rerun `pre_approval_gate` for the new head before any approval-ready or merge-ready claim
359
356
  8. wait for current-head CI again before retrying merge evaluation
360
- 9. if the chosen reconciliation rewrote branch history (for example rebase), ask for explicit authorization before `git push --force-with-lease`, then continue the loop on the updated head
357
+ 9. if the chosen reconciliation rewrote branch history (rebase only — merge commits push as a normal fast-forward), ask for explicit authorization before `git push --force-with-lease` (`--force-with-lease` only, never bare `--force`), then continue the loop on the updated head
361
358
 
362
359
  `mergeStateStatus: CLEAN` alone is not enough to resume approval or merge claims. The existing merge-ready preconditions still apply: zero unresolved review threads, a clean current-head `pre_approval_gate`, and green current-head CI.
363
360
 
@@ -384,6 +381,21 @@ node <resolved-skill-scripts>/github/detect-checkpoint-evidence.mjs \
384
381
 
385
382
  This helper is always-on: it uses `gh api` to fetch visible PR issue comments and fails closed unless both required gate comments exist: a clean `draft_gate` comment for the one-time draft boundary and a clean current-head `pre_approval_gate` comment. Do not run `gh pr merge` if this command exits non-zero. There is no opt-out flag. Resolved threads, green CI, clean Copilot rereview, or local notes do not substitute for this successful helper output. If a final approval or merge boundary sees `gh pr merge` without a same-boundary successful check, treat that as a workflow violation and stop.
386
383
 
384
+ ### Stale runner-coordination lock held by a completed run
385
+
386
+ The pre-merge gate evidence check fails closed on the PR's runner-coordination claim (`.pi/runner-coordination/<owner>/<name>/pr-<n>.json`): a fresh merge re-dispatch (new run id) is refused with `ownership_lost`, or `stale_runner` once the claim ages past the max-age window.
387
+
388
+ The auto-loop now releases its claim best-effort when a run reaches a terminal stop (clean-converged, blocked, or done — including the stop at the human approval checkpoint), so a merge-authorized re-dispatch normally inherits a cleared claim and proceeds. The release is non-fatal: it never blocks the stop, and it never clears a claim owned by a genuinely active competing run.
389
+
390
+ If a stale claim still blocks the merge because the completing run could not release (crash, killed process, or a pre-#1109 run), the sanctioned recovery for a lock held by a COMPLETED run is an explicit takeover by the merge run:
391
+
392
+ ```sh
393
+ node <resolved-skill-scripts>/loop/pr-runner-coordination.mjs takeover \
394
+ --repo <owner/name> --pr <number>
395
+ ```
396
+
397
+ `takeover` seizes ownership for the current run id and records the displaced run under `previousRun`. Only take over when the prior owner is genuinely completed/dead. A genuinely active (non-stale) run must still be allowed to block — do not take over to race a live run.
398
+
387
399
  ### Mandatory post-merge retrospective checkpoint write
388
400
 
389
401
  After a merge succeeds (or an explicit retrospective skip is authorized), write the durable retrospective checkpoint before exiting the subagent session:
@@ -410,7 +422,7 @@ node <resolved-skill-scripts>/projects/archive-done-items.mjs --repo <owner/name
410
422
 
411
423
  Board and threshold resolve from `.devloops` (`queue.projectNumber`/`queue.boardTitle`, `queue.archiveOlderThanDays`, default 7d), using local `gh` auth — no CI, cron, or PAT. This step is best-effort and NON-FATAL: ignore any failure and never let it block the merge or the retrospective.
412
424
 
413
- Alongside the archive step, sync the linked issue's board item to "Done" so the queue board reflects the merge without a manual `dev-loops queue sync-status --to-column <col>`:
425
+ The deterministic mechanism that converges merged→Done is `dev-loops queue reconcile` idempotent, run best-effort at loop startup — so the merge lands on the "Done" column without a remembered manual step. The manual sync below is now an optional fallback (e.g. to converge immediately without waiting for the next startup reconcile):
414
426
 
415
427
  ```sh
416
428
  node <resolved-skill-scripts>/projects/sync-item-status.mjs --repo <owner/name> --item <linked-issue> --to-column "Done"