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
@@ -12,6 +12,7 @@ import {
12
12
  replyAndMaybeResolve,
13
13
  validateResolutionMessage,
14
14
  } from "./_review-thread-mutations.mjs";
15
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
15
16
  const USAGE = `Usage: reply-resolve-review-threads.mjs --repo <owner/name> --pr <number> [--author <login>] [--message <text>] [--resolve]
16
17
  Reply to all matching unresolved review threads on one PR and optionally resolve them.
17
18
  Required:
@@ -30,9 +31,11 @@ Error output (stderr, JSON):
30
31
  { "ok": false, "error": "...", "usage": "..." }
31
32
  Runtime/gh failures:
32
33
  { "ok": false, "error": "...", "partialProgress"?: { ... } }
34
+ ${JQ_OUTPUT_USAGE}
33
35
  Exit codes:
34
36
  0 Success
35
- 1 Argument error or gh/runtime failure`.trim();
37
+ 1 Argument error or gh/runtime failure
38
+ 2 Invalid --jq filter`.trim();
36
39
  const parseError = buildParseError(USAGE);
37
40
  export function parseReplyResolveThreadsCliArgs(argv) {
38
41
  const { tokens } = parseArgs({
@@ -44,6 +47,7 @@ export function parseReplyResolveThreadsCliArgs(argv) {
44
47
  author: { type: "string" },
45
48
  message: { type: "string" },
46
49
  resolve: { type: "boolean" },
50
+ ...JQ_OUTPUT_PARSE_OPTIONS,
47
51
  },
48
52
  allowPositionals: true,
49
53
  strict: false,
@@ -88,6 +92,7 @@ export function parseReplyResolveThreadsCliArgs(argv) {
88
92
  options.resolve = true;
89
93
  continue;
90
94
  }
95
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
91
96
  throw parseError(`Unknown argument: ${token.rawName}`);
92
97
  }
93
98
  if (options.repo === undefined || options.pr === undefined) {
@@ -111,6 +116,62 @@ async function readStdinText(stdin) {
111
116
  }
112
117
  return text;
113
118
  }
119
+ // When --message is set, stdin is read only to detect a conflicting second
120
+ // message source. A detached/idle pipe never sends EOF, so an unbounded read
121
+ // hangs forever and the process never exits (issue #1012). Resolve as soon as
122
+ // any NON-WHITESPACE byte arrives (a conflicting body is detected the instant
123
+ // real content appears — no need to wait for EOF); keep buffering while only
124
+ // whitespace has arrived (a leading newline is not yet a conflict and more may
125
+ // follow); resolve on natural EOF; and time out on a silent/idle pipe. Either
126
+ // way the stdin handle is released so the event loop can drain and the tool
127
+ // always terminates.
128
+ const CONFLICT_STDIN_TIMEOUT_MS = 500;
129
+ function readStdinConflictProbe(stdin, timeoutMs) {
130
+ return new Promise((resolve) => {
131
+ let settled = false;
132
+ let timer;
133
+ const cleanup = () => {
134
+ clearTimeout(timer);
135
+ stdin.off?.("data", onData);
136
+ stdin.off?.("end", onEnd);
137
+ stdin.off?.("error", onEnd);
138
+ // Release the handle: an abandoned reader on a still-open pipe would
139
+ // otherwise keep the event loop alive and re-introduce the hang.
140
+ stdin.pause?.();
141
+ if (typeof stdin.unref === "function") {
142
+ stdin.unref();
143
+ } else {
144
+ stdin.destroy?.();
145
+ }
146
+ };
147
+ const finish = (value) => {
148
+ if (settled) {
149
+ return;
150
+ }
151
+ settled = true;
152
+ cleanup();
153
+ resolve(value);
154
+ };
155
+ // Resolve early only once non-whitespace content is seen (a real conflict);
156
+ // '' or whitespace-only on clean EOF is not a conflict; undefined only on
157
+ // timeout (idle pipe) so the caller proceeds with --message.
158
+ let text = "";
159
+ const onData = (chunk) => {
160
+ text += String(chunk);
161
+ if (text.trim().length > 0) {
162
+ finish(text);
163
+ }
164
+ };
165
+ const onEnd = () => finish(text);
166
+ timer = setTimeout(() => finish(undefined), timeoutMs);
167
+ timer.unref?.();
168
+ stdin.setEncoding?.("utf8");
169
+ stdin.on?.("data", onData);
170
+ stdin.on?.("end", onEnd);
171
+ stdin.on?.("error", onEnd);
172
+ });
173
+ }
174
+
114
175
  async function resolveMessageInput(options, { stdin = process.stdin } = {}) {
115
176
  if (typeof options.message === "string") {
116
177
  if (stdin.isTTY) {
@@ -119,8 +180,8 @@ async function resolveMessageInput(options, { stdin = process.stdin } = {}) {
119
180
  }
120
181
  return options.message;
121
182
  }
122
- const stdinText = await readStdinText(stdin);
123
- if (stdinText.trim().length > 0) {
183
+ const stdinText = await readStdinConflictProbe(stdin, CONFLICT_STDIN_TIMEOUT_MS);
184
+ if (typeof stdinText === "string" && stdinText.trim().length > 0) {
124
185
  throw parseError("Choose exactly one message source: --message <text> or stdin");
125
186
  }
126
187
  if (options.message.trim().length === 0) {
@@ -241,6 +302,7 @@ export async function runCli(
241
302
  {
242
303
  stdin = process.stdin,
243
304
  stdout = process.stdout,
305
+ stderr = process.stderr,
244
306
  env = process.env,
245
307
  ghCommand = "gh",
246
308
  } = {},
@@ -258,7 +320,7 @@ export async function runCli(
258
320
  );
259
321
  const { matchedTargets, skippedThreadCount } = planBatchReplyTargets(parsed, options.author);
260
322
  if (matchedTargets.length === 0) {
261
- stdout.write(`${JSON.stringify(createSuccessPayload({
323
+ process.exitCode = emitResult(createSuccessPayload({
262
324
  repo: options.repo,
263
325
  pr: options.pr,
264
326
  author: options.author,
@@ -268,7 +330,7 @@ export async function runCli(
268
330
  resolvedThreadCount: 0,
269
331
  skippedThreadCount,
270
332
  results: [],
271
- }))}\n`);
333
+ }), { jq: options.jq, silent: options.silent, stdout, stderr });
272
334
  return;
273
335
  }
274
336
  const results = [];
@@ -328,7 +390,7 @@ export async function runCli(
328
390
  }
329
391
  const repliedThreadCount = results.length;
330
392
  const resolvedThreadCount = results.filter((entry) => entry.resolved).length;
331
- stdout.write(`${JSON.stringify(createSuccessPayload({
393
+ process.exitCode = emitResult(createSuccessPayload({
332
394
  repo: options.repo,
333
395
  pr: options.pr,
334
396
  author: options.author,
@@ -338,7 +400,7 @@ export async function runCli(
338
400
  resolvedThreadCount,
339
401
  skippedThreadCount,
340
402
  results,
341
- }))}\n`);
403
+ }), { jq: options.jq, silent: options.silent, stdout, stderr });
342
404
  }
343
405
  if (isDirectCliRun(import.meta.url)) {
344
406
  runCli().catch((error) => {
@@ -15,6 +15,7 @@ import { fetchGithubReviewThreadsPayload } from "./capture-review-threads.mjs";
15
15
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
16
16
  import { buildSnapshotFromPrFacts, interpretLoopState } from "@dev-loops/core/loop/copilot-loop-state";
17
17
  import { loadDevLoopConfig, resolveRefinement } from "@dev-loops/core/config";
18
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
18
19
  const BLOCKED_BY_COPILOT_COMMENT_STATUS = "blocked_by_copilot_comment";
19
20
  const SUPPRESSED_SAME_HEAD_CLEAN_STATUS = "suppressed_same_head_clean";
20
21
  const ROUND_CAP_REACHED_STATUS = "round_cap_reached";
@@ -50,9 +51,11 @@ Error output (stderr, JSON):
50
51
  { "ok": false, "error": "...", "usage": "..." }
51
52
  gh/runtime failures:
52
53
  { "ok": false, "error": "..." }
54
+ ${JQ_OUTPUT_USAGE}
53
55
  Exit codes:
54
56
  0 Success (including unavailable)
55
- 1 Argument error or gh failure`.trim();
57
+ 1 Argument error or gh failure
58
+ 2 Invalid --jq filter`.trim();
56
59
  const parseError = buildParseError(USAGE);
57
60
  export function parseRequestCliArgs(argv) {
58
61
  const { tokens } = parseArgs({
@@ -62,6 +65,7 @@ export function parseRequestCliArgs(argv) {
62
65
  "force-rerequest-review": { type: "boolean" },
63
66
  repo: { type: "string" },
64
67
  pr: { type: "string" },
68
+ ...JQ_OUTPUT_PARSE_OPTIONS,
65
69
  },
66
70
  allowPositionals: true,
67
71
  strict: false,
@@ -96,6 +100,7 @@ export function parseRequestCliArgs(argv) {
96
100
  options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
97
101
  continue;
98
102
  }
103
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
99
104
  throw parseError(`Unknown argument: ${token.rawName}`);
100
105
  }
101
106
  if (options.repo === undefined || options.pr === undefined) {
@@ -609,6 +614,7 @@ export async function runCli(
609
614
  argv = process.argv.slice(2),
610
615
  {
611
616
  stdout = process.stdout,
617
+ stderr = process.stderr,
612
618
  env = process.env,
613
619
  ghCommand = "gh",
614
620
  } = {},
@@ -619,7 +625,7 @@ export async function runCli(
619
625
  return;
620
626
  }
621
627
  const result = await performCopilotReviewRequest(options, { env, ghCommand });
622
- stdout.write(`${JSON.stringify(result)}\n`);
628
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
623
629
  }
624
630
  if (isDirectCliRun(import.meta.url)) {
625
631
  runCli().catch((error) => {
@@ -9,6 +9,7 @@ import {
9
9
  loadDevLoopConfig,
10
10
  resolveHumanHandoffConfig,
11
11
  } from "@dev-loops/core/config";
12
+ import { JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
12
13
 
13
14
  const USAGE = `Usage: resolve-handoff-candidates.mjs --repo <owner/name> --pr <number> [--changed-files <a,b,c>] [--pr-author <login>]
14
15
  Resolve an ordered, deduped list of human-handoff reviewer/assignee candidates
@@ -39,9 +40,11 @@ Optional:
39
40
  Output (stdout, JSON):
40
41
  { "ok": true, "enabled": bool, "candidates": [{ "login", "source", "isTeam"?, "paths"? }],
41
42
  "changedFiles": [...], "sources": [...], "warnings": [...] }
43
+ ${JQ_OUTPUT_USAGE}
42
44
  Exit codes:
43
45
  0 Success (including disabled no-op and fail-soft per-source skips)
44
- 1 Argument error`.trim();
46
+ 1 Argument error
47
+ 2 Invalid --jq filter`.trim();
45
48
 
46
49
  const parseError = buildParseError(USAGE);
47
50
 
@@ -74,6 +77,8 @@ export function parseResolveCandidatesCliArgs(argv) {
74
77
  continue;
75
78
  }
76
79
  if (token === "--pr-author") { options.prAuthor = nextValue(args, ++i, "--pr-author").trim().replace(/^@/, ""); continue; }
80
+ if (token === "--jq") { options.jq = nextValue(args, ++i, "--jq"); continue; }
81
+ if (token === "--silent" || token === "-s") { options.silent = true; continue; }
77
82
  throw parseError(`Unknown argument: ${token}`);
78
83
  }
79
84
  if (options.repo === undefined || options.pr === undefined) {
@@ -403,8 +408,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
403
408
  { repo: options.repo, pr: options.pr, changedFiles: options.changedFiles, prAuthor: options.prAuthor ?? null },
404
409
  { ...deps, config, repoRoot },
405
410
  );
406
- process.stdout.write(`${JSON.stringify(result)}\n`);
407
- return 0;
411
+ return emitResult(result, { jq: options.jq, silent: options.silent });
408
412
  }
409
413
 
410
414
  if (isDirectCliRun(import.meta.url)) {
@@ -3,6 +3,7 @@ import { parseArgs } from "node:util";
3
3
  import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
4
4
  import { parseIssueNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
5
5
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
6
7
  const ISSUE_JSON_FIELDS = "number,title,body,url,state";
7
8
  const USAGE = `Usage: resolve-tracker-local-spec.mjs (--repo <owner/name> --issue <number> | --issue-url <github-issue-url>)
8
9
  Resolve the canonical tracker-backed local spec bundle from one GitHub issue reference.
@@ -30,7 +31,8 @@ Error output (stderr, JSON):
30
31
  Argument/usage errors:
31
32
  { "ok": false, "error": "...", "usage": "..." }
32
33
  gh/runtime failures:
33
- { "ok": false, "error": "..." }`.trim();
34
+ { "ok": false, "error": "..." }
35
+ ${JQ_OUTPUT_USAGE}`.trim();
34
36
  const parseError = buildParseError(USAGE);
35
37
  export function parseGitHubIssueUrl(value) {
36
38
  let parsedUrl;
@@ -68,6 +70,7 @@ export function parseResolveTrackerLocalSpecCliArgs(argv) {
68
70
  repo: { type: "string" },
69
71
  issue: { type: "string" },
70
72
  "issue-url": { type: "string" },
73
+ ...JQ_OUTPUT_PARSE_OPTIONS,
71
74
  },
72
75
  allowPositionals: true,
73
76
  strict: false,
@@ -102,6 +105,7 @@ export function parseResolveTrackerLocalSpecCliArgs(argv) {
102
105
  options.issueUrl = requireTokenValue(token, parseError).trim();
103
106
  continue;
104
107
  }
108
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
105
109
  throw parseError(`Unknown argument: ${token.rawName}`);
106
110
  }
107
111
  const usingIssueUrl = typeof options.issueUrl === "string";
@@ -119,6 +123,8 @@ export function parseResolveTrackerLocalSpecCliArgs(argv) {
119
123
  repo,
120
124
  issue,
121
125
  issueUrl: options.issueUrl,
126
+ ...(options.jq !== undefined ? { jq: options.jq } : {}),
127
+ ...(options.silent !== undefined ? { silent: options.silent } : {}),
122
128
  };
123
129
  }
124
130
  try {
@@ -201,7 +207,7 @@ export async function resolveTrackerLocalSpec(
201
207
  }
202
208
  export async function runCli(
203
209
  argv = process.argv.slice(2),
204
- { stdout = process.stdout, env = process.env, ghCommand = "gh" } = {},
210
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh" } = {},
205
211
  ) {
206
212
  const options = parseResolveTrackerLocalSpecCliArgs(argv);
207
213
  if (options.help) {
@@ -212,7 +218,7 @@ export async function runCli(
212
218
  { repo: options.repo, issue: options.issue },
213
219
  { env, ghCommand },
214
220
  );
215
- stdout.write(`${JSON.stringify(result)}\n`);
221
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
216
222
  }
217
223
  if (isDirectCliRun(import.meta.url)) {
218
224
  runCli().catch((error) => {
@@ -7,6 +7,7 @@ import { parseArgs } from "node:util";
7
7
  import { parsePositiveInteger, requireTokenValue } from "../_cli-primitives.mjs";
8
8
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
9
9
  import { buildDraftReviewPayload } from "@dev-loops/core/loop/reviewer-loop-state";
10
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
10
11
  const HELP = `Usage: stage-reviewer-draft.mjs --repo <owner/name> --pr <number> --review-file <path> [--local-state-output <path>]
11
12
  Stage a pending draft review on a GitHub pull request.
12
13
  Options:
@@ -15,9 +16,13 @@ Options:
15
16
  --review-file <path> Path to JSON file containing review payload (required)
16
17
  --local-state-output <path> Path to write local state snapshot (optional)
17
18
  --help, -h Show this help
19
+
20
+ ${JQ_OUTPUT_USAGE}
21
+
18
22
  Exit codes:
19
23
  0 Success
20
24
  1 Error
25
+ 2 Invalid --jq filter
21
26
  `;
22
27
  export function parseStageDraftCliArgs(argv) {
23
28
  const { tokens } = parseArgs({
@@ -28,6 +33,7 @@ export function parseStageDraftCliArgs(argv) {
28
33
  pr: { type: "string" },
29
34
  "review-file": { type: "string" },
30
35
  "local-state-output": { type: "string" },
36
+ ...JQ_OUTPUT_PARSE_OPTIONS,
31
37
  },
32
38
  allowPositionals: true,
33
39
  strict: false,
@@ -67,6 +73,7 @@ export function parseStageDraftCliArgs(argv) {
67
73
  options.localStateOutput = requireTokenValue(token);
68
74
  continue;
69
75
  }
76
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t))) continue;
70
77
  throw new Error(`Unknown argument: ${token.rawName}`);
71
78
  }
72
79
  if (!options.repo || !options.pr || !options.reviewFile) {
@@ -169,6 +176,7 @@ export async function runCli(
169
176
  argv = process.argv.slice(2),
170
177
  {
171
178
  stdout = process.stdout,
179
+ stderr = process.stderr,
172
180
  env = process.env,
173
181
  ghCommand = "gh",
174
182
  } = {},
@@ -190,7 +198,7 @@ export async function runCli(
190
198
  await postDraftReview({ repo: options.repo, pr: options.pr, reviewPayload }, { env, ghCommand }),
191
199
  );
192
200
  const localStatePath = await writeLocalState(options.localStateOutput, draftReview);
193
- stdout.write(`${JSON.stringify({
201
+ process.exitCode = emitResult({
194
202
  ok: true,
195
203
  repo: options.repo,
196
204
  pr: options.pr,
@@ -199,7 +207,7 @@ export async function runCli(
199
207
  reviewState: draftReview.state,
200
208
  commitSha: draftReview.commitSha,
201
209
  localStatePath,
202
- })}\n`);
210
+ }, { jq: options.jq, silent: options.silent, stdout, stderr });
203
211
  }
204
212
  if (isDirectCliRun(import.meta.url)) {
205
213
  runCli().catch((error) => {
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+ // Mirror the issue-body AC/DoD tick procedure onto the PR body: after a clean
3
+ // pre_approval_gate verification, flip each verified item's `- [ ]` to `- [x]`
4
+ // in the PR body via a single `gh pr edit --body-file` update. Matches labels by
5
+ // EXACT (trimmed) text and fails closed — never blanket-checks, never unchecks.
6
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { parseArgs } from "node:util";
10
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
11
+ import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
12
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
13
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
14
+ import { editPr } from "./edit-pr.mjs";
15
+ import { viewPr } from "./view-pr.mjs";
16
+
17
+ const USAGE = `Usage: tick-verified-checkboxes.mjs --repo <owner/name> --pr <number> --verified <label> [--verified <label>...] [--dry-run]
18
+ Tick verified acceptance-criteria checkboxes in a PR body. Mirrors the issue-body
19
+ tick from skills/docs/acceptance-criteria-verification.md onto the PR body so the
20
+ merged PR shows checked AC/DoD. Flips \`- [ ] <label>\` to \`- [x] <label>\` ONLY when
21
+ <label> exactly equals a --verified label (exact-match, fail-closed: never blanket-
22
+ checks, never unchecks). Applies a single \`gh pr edit --body-file\` update.
23
+ Required:
24
+ --repo <owner/name> Repository slug (e.g. owner/repo)
25
+ --pr <number> Pull request number
26
+ --verified <label> An exact (trimmed) checklist label that was verified
27
+ (repeatable; at least one is required)
28
+ Options:
29
+ --dry-run Compute + report the flips but never edit the PR
30
+ Output (stdout, JSON):
31
+ { "ok": true, "pr": 17, "flipped": [...], "unmatched": [...], "edited": true|false }
32
+ Error output (stderr, JSON):
33
+ { "ok": false, "error": "...", "usage"?: "..." }
34
+ ${JQ_OUTPUT_USAGE}
35
+ Exit codes:
36
+ 0 Success
37
+ 1 Argument error or gh failure
38
+ 2 Invalid --jq filter`.trim();
39
+ const parseError = buildParseError(USAGE);
40
+
41
+ // GFM checklist line: optional indentation, a `-`/`*`/`+` bullet, a `[ ]`/`[x]`
42
+ // box, then the label. Capture indent + bullet so we preserve them on flip, and
43
+ // an optional trailing `\r` so CRLF bodies (as returned by `gh pr view`) round-
44
+ // trip byte-for-byte instead of silently failing to match.
45
+ const CHECKBOX_RE = /^(\s*)([-*+])\s+\[([ xX])\]\s+(.*?)(\r?)$/;
46
+
47
+ // PURE: flip only unchecked checklist lines whose trimmed label EXACTLY equals a
48
+ // verified label. Never unchecks. Never touches non-checkbox text. Idempotent:
49
+ // an already-`[x]` verified label is neither flipped nor reported as unmatched.
50
+ // Returns { body, flipped, unmatched } where flipped = labels changed this call
51
+ // and unmatched = verified labels absent from the body's checklist.
52
+ export function tickVerifiedCheckboxes(body, verifiedLabels) {
53
+ const verified = new Set(
54
+ (verifiedLabels ?? []).map((l) => String(l).trim()).filter((l) => l.length > 0),
55
+ );
56
+ const flipped = [];
57
+ const found = new Set();
58
+ const lines = String(body).split("\n");
59
+ const nextLines = lines.map((line) => {
60
+ const m = line.match(CHECKBOX_RE);
61
+ if (!m) return line;
62
+ const [, indent, bullet, mark, rest, cr] = m;
63
+ const label = rest.trim();
64
+ if (!verified.has(label)) return line;
65
+ found.add(label);
66
+ if (mark !== " ") return line; // already checked — leave it, idempotent
67
+ if (!flipped.includes(label)) flipped.push(label); // duplicate lines: report once
68
+ return `${indent}${bullet} [x] ${rest}${cr}`;
69
+ });
70
+ const unmatched = [...verified].filter((label) => !found.has(label));
71
+ return { body: nextLines.join("\n"), flipped, unmatched };
72
+ }
73
+
74
+ export function parseTickVerifiedCliArgs(argv) {
75
+ const { tokens } = parseArgs({
76
+ args: [...argv],
77
+ options: {
78
+ help: { type: "boolean", short: "h" },
79
+ repo: { type: "string" },
80
+ pr: { type: "string" },
81
+ verified: { type: "string", multiple: true },
82
+ "dry-run": { type: "boolean" },
83
+ ...JQ_OUTPUT_PARSE_OPTIONS,
84
+ },
85
+ allowPositionals: true,
86
+ strict: false,
87
+ tokens: true,
88
+ });
89
+ const options = {
90
+ help: false,
91
+ repo: undefined,
92
+ pr: undefined,
93
+ verified: [],
94
+ dryRun: false,
95
+ jq: undefined,
96
+ silent: false,
97
+ };
98
+ for (const token of tokens) {
99
+ if (token.kind === "positional") {
100
+ throw parseError(`Unknown argument: ${token.value}`);
101
+ }
102
+ if (token.kind !== "option") continue;
103
+ if (token.name === "help") {
104
+ options.help = true;
105
+ return options;
106
+ }
107
+ if (token.name === "repo") {
108
+ options.repo = requireTokenValue(token, parseError).trim();
109
+ continue;
110
+ }
111
+ if (token.name === "pr") {
112
+ options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
113
+ continue;
114
+ }
115
+ if (token.name === "verified") {
116
+ const label = requireTokenValue(token, parseError).trim();
117
+ if (label.length === 0) throw parseError("--verified must be a non-empty label");
118
+ options.verified.push(label);
119
+ continue;
120
+ }
121
+ if (token.name === "dry-run") {
122
+ options.dryRun = true;
123
+ continue;
124
+ }
125
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
126
+ throw parseError(`Unknown argument: ${token.rawName}`);
127
+ }
128
+ if (options.repo === undefined || options.pr === undefined) {
129
+ throw parseError("Ticking PR checkboxes requires both --repo <owner/name> and --pr <number>");
130
+ }
131
+ if (options.verified.length === 0) {
132
+ throw parseError("at least one --verified is required");
133
+ }
134
+ try {
135
+ parseRepoSlug(options.repo);
136
+ } catch (error) {
137
+ throw parseError(error instanceof Error ? error.message : String(error));
138
+ }
139
+ return options;
140
+ }
141
+
142
+ export async function tickCheckboxes(
143
+ options,
144
+ { env = process.env, ghCommand = "gh", run = runChild, editPr: edit = editPr } = {},
145
+ ) {
146
+ // Reuse view-pr.mjs rather than re-implementing `gh pr view --json body`.
147
+ const { pr } = await viewPr(
148
+ { repo: options.repo, pr: options.pr, fields: "body" },
149
+ { env, ghCommand, run },
150
+ );
151
+ const currentBody = typeof pr.body === "string" ? pr.body : "";
152
+ const { body: nextBody, flipped, unmatched } = tickVerifiedCheckboxes(currentBody, options.verified);
153
+ if (flipped.length === 0 || options.dryRun) {
154
+ return { ok: true, pr: options.pr, flipped, unmatched, edited: false };
155
+ }
156
+ const dir = await mkdtemp(join(tmpdir(), "tick-verified-"));
157
+ try {
158
+ const bodyFile = join(dir, "body.md");
159
+ await writeFile(bodyFile, nextBody, "utf8");
160
+ await edit(
161
+ { repo: options.repo, pr: options.pr, bodyFile, addAssignees: [], removeAssignees: [] },
162
+ { env, ghCommand, run },
163
+ );
164
+ return { ok: true, pr: options.pr, flipped, unmatched, edited: true };
165
+ } finally {
166
+ // Clean up the temp dir on success or failure; never let cleanup mask the result/error.
167
+ try {
168
+ await rm(dir, { recursive: true, force: true });
169
+ } catch {
170
+ // ignore cleanup failures
171
+ }
172
+ }
173
+ }
174
+
175
+ export async function runCli(
176
+ argv = process.argv.slice(2),
177
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", run = runChild, editPr: edit = editPr } = {},
178
+ ) {
179
+ let options;
180
+ try {
181
+ options = parseTickVerifiedCliArgs(argv);
182
+ } catch (error) {
183
+ stderr.write(`${formatCliError(error)}\n`);
184
+ return 1;
185
+ }
186
+ if (options.help) {
187
+ stdout.write(`${USAGE}\n`);
188
+ return 0;
189
+ }
190
+ let result;
191
+ try {
192
+ result = await tickCheckboxes(options, { env, ghCommand, run, editPr: edit });
193
+ } catch (error) {
194
+ stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
195
+ return 1;
196
+ }
197
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
198
+ }
199
+
200
+ if (isDirectCliRun(import.meta.url)) {
201
+ runCli().then((code) => { process.exitCode = code; });
202
+ }
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
3
3
  import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
4
4
  import { loadDevLoopConfig, resolveGateConfig, resolveRefinementConfig } from "@dev-loops/core/config";
5
5
  import { parseArgs } from "node:util";
6
- import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
7
7
  import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
8
8
  import { truncateText } from "@dev-loops/core/bash-exit-one";
9
9
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
@@ -391,14 +391,7 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
391
391
  options.inlineReason = smartTruncate(reason, MAX_GATE_COMMENT_EXCERPT_LENGTH);
392
392
  continue;
393
393
  }
394
- if (token.name === "jq") {
395
- options.jq = requireTokenValue(token, parseError);
396
- continue;
397
- }
398
- if (token.name === "silent") {
399
- options.silent = true;
400
- continue;
401
- }
394
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
402
395
  throw parseError(`Unknown argument: ${token.rawName}`);
403
396
  }
404
397
  // Default execution mode to inline_single_agent when omitted. inlineReason is
@@ -897,7 +890,14 @@ async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRo
897
890
  try {
898
891
  // The PR is now a draft, so RUN_DRAFT_GATE is the legal action. Re-enter with
899
892
  // the caller's full options; prIsDraft is now true so this branch is skipped.
900
- result = await upsertCheckpointVerdict(options, { env, ghCommand, repoRoot });
893
+ // `_draftTransitionInProgress` guards against unbounded recursion: if GitHub's
894
+ // draft-state read still lags the conversion mutation on re-entry (isDraft reads
895
+ // false again), the reconcile branch must NOT fire a second time — it fails closed
896
+ // with a clear error instead of recursing indefinitely (exit 13). (#1020)
897
+ result = await upsertCheckpointVerdict(
898
+ { ...options, _draftTransitionInProgress: true },
899
+ { env, ghCommand, repoRoot },
900
+ );
901
901
  } catch (error) {
902
902
  if (conversion.alreadyDraft !== true) {
903
903
  try {
@@ -1060,11 +1060,32 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
1060
1060
  if (
1061
1061
  options.gate === "draft_gate"
1062
1062
  && !prIsDraft
1063
+ && !options._draftTransitionInProgress
1063
1064
  && !coordination.draftGateAlreadySatisfied
1064
1065
  && coordination.allowedNextActions.includes(PR_CHECKPOINT_ACTION.RECONCILE_DRAFT_GATE)
1065
1066
  ) {
1066
1067
  return await postDraftGateViaDraftTransition(options, { env, ghCommand, repoRoot });
1067
1068
  }
1069
+ // Fail closed on a lagged draft-state read: we are re-entering FROM
1070
+ // postDraftGateViaDraftTransition (which just converted the PR to draft) yet the
1071
+ // coordination context still reports the PR as non-draft. Recursing would loop
1072
+ // indefinitely (the original #1020 hang → exit 13, error swallowed). Surface a
1073
+ // clear, actionable error instead so the operator knows the draft conversion did
1074
+ // not take (or GitHub's read lags the mutation) and can retry. (#1020)
1075
+ if (
1076
+ options.gate === "draft_gate"
1077
+ && !prIsDraft
1078
+ && options._draftTransitionInProgress
1079
+ ) {
1080
+ throw new Error(
1081
+ `draft_gate self-heal for ${options.repo}#${options.pr} failed: the PR was converted to draft ` +
1082
+ `to post the verdict, but GitHub still reports it as non-draft on re-entry (draft-state read lagged ` +
1083
+ `the conversion mutation, or the conversion did not take). Not recursing. Re-run the draft_gate post ` +
1084
+ `once the PR reflects the draft state, or reconcile manually with ` +
1085
+ `\`gh pr ready ${options.pr} --repo ${options.repo}\` / ` +
1086
+ `\`node scripts/github/reconcile-draft-gate.mjs --repo ${options.repo} --pr ${options.pr}\`.`,
1087
+ );
1088
+ }
1068
1089
  if (gateActionForbidden) {
1069
1090
  throw new Error(buildGateEntryRefusalError({ options, coordination }));
1070
1091
  }