dev-loops 0.6.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +8 -7
  3. package/.claude/agents/fixer.md +1 -0
  4. package/.claude/agents/refiner.md +2 -2
  5. package/.claude/agents/review.md +4 -3
  6. package/.claude/commands/{auto.md → loop-auto.md} +1 -1
  7. package/.claude/commands/loop-continue.md +15 -0
  8. package/.claude/commands/loop-enqueue.md +22 -0
  9. package/.claude/commands/loop-grill.md +17 -0
  10. package/.claude/commands/{info.md → loop-info.md} +2 -2
  11. package/.claude/commands/loop-queue-status.md +24 -0
  12. package/.claude/commands/{start-spike.md → loop-start-spike.md} +2 -2
  13. package/.claude/commands/{start.md → loop-start.md} +1 -1
  14. package/.claude/commands/{status.md → loop-status.md} +1 -1
  15. package/.claude/hooks/_bash-command-classify.mjs +333 -29
  16. package/.claude/hooks/_hook-decisions.mjs +138 -15
  17. package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
  18. package/.claude/skills/copilot-pr-followup/SKILL.md +60 -34
  19. package/.claude/skills/dev-loop/SKILL.md +16 -12
  20. package/.claude/skills/docs/acceptance-criteria-verification.md +8 -3
  21. package/.claude/skills/docs/anti-patterns.md +5 -3
  22. package/.claude/skills/docs/artifact-authority-contract.md +30 -11
  23. package/.claude/skills/docs/confirmation-rules.md +1 -0
  24. package/.claude/skills/docs/contract-style-guide.md +37 -0
  25. package/.claude/skills/docs/copilot-loop-operations.md +19 -15
  26. package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
  27. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  28. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  29. package/.claude/skills/docs/issue-intake-procedure.md +26 -19
  30. package/.claude/skills/docs/local-planning-flow.md +1 -1
  31. package/.claude/skills/docs/local-planning-worked-example.md +1 -1
  32. package/.claude/skills/docs/merge-preconditions.md +30 -12
  33. package/.claude/skills/docs/plan-file-contract.md +1 -1
  34. package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
  35. package/.claude/skills/docs/public-dev-loop-contract.md +60 -44
  36. package/.claude/skills/docs/retrospective-checkpoint-contract.md +106 -79
  37. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  38. package/.claude/skills/docs/stop-conditions.md +30 -15
  39. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  40. package/.claude/skills/docs/ui-e2e-scoping-step.md +32 -0
  41. package/.claude/skills/docs/validation-policy.md +4 -5
  42. package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
  43. package/.claude/skills/local-implementation/SKILL.md +62 -145
  44. package/.claude/skills/loop-grill/SKILL.md +163 -0
  45. package/AGENTS.md +1 -0
  46. package/CHANGELOG.md +101 -0
  47. package/README.md +9 -9
  48. package/agents/dev-loop.agent.md +11 -10
  49. package/agents/developer.agent.md +1 -1
  50. package/agents/docs.agent.md +1 -1
  51. package/agents/fixer.agent.md +2 -1
  52. package/agents/quality.agent.md +1 -1
  53. package/agents/refiner.agent.md +3 -3
  54. package/agents/review.agent.md +5 -4
  55. package/cli/index.mjs +35 -42
  56. package/extension/README.md +4 -4
  57. package/extension/checks.ts +1 -5
  58. package/extension/harness-types.ts +1 -0
  59. package/extension/index.ts +6 -0
  60. package/extension/pi-extension-adapter.ts +2 -0
  61. package/extension/presentation.ts +7 -19
  62. package/package.json +12 -12
  63. package/scripts/_core-helpers.mjs +2 -0
  64. package/scripts/claude/headless-dev-loop.mjs +1 -1
  65. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  66. package/scripts/docs/validate-state-machine-conformance.mjs +1092 -0
  67. package/scripts/github/_review-thread-mutations.mjs +5 -2
  68. package/scripts/github/capture-review-threads.mjs +2 -9
  69. package/scripts/github/comment-issue.mjs +2 -9
  70. package/scripts/github/create-label.mjs +133 -0
  71. package/scripts/github/create-pr.mjs +119 -10
  72. package/scripts/github/detect-checkpoint-evidence.mjs +259 -18
  73. package/scripts/github/detect-linked-issue-pr.mjs +22 -6
  74. package/scripts/github/edit-pr.mjs +259 -0
  75. package/scripts/github/fetch-ci-logs.mjs +2 -9
  76. package/scripts/github/list-issues.mjs +2 -9
  77. package/scripts/github/list-review-threads.mjs +277 -0
  78. package/scripts/github/manage-sub-issues.mjs +46 -10
  79. package/scripts/github/offer-human-handoff.mjs +8 -5
  80. package/scripts/github/post-gate-findings.mjs +20 -5
  81. package/scripts/github/probe-ci-status.mjs +8 -2
  82. package/scripts/github/probe-copilot-review.mjs +21 -14
  83. package/scripts/github/ready-for-review.mjs +26 -8
  84. package/scripts/github/reconcile-draft-gate.mjs +7 -3
  85. package/scripts/github/reply-resolve-review-thread.mjs +16 -5
  86. package/scripts/github/reply-resolve-review-threads.mjs +69 -7
  87. package/scripts/github/request-copilot-review.mjs +98 -26
  88. package/scripts/github/resolve-handoff-candidates.mjs +7 -3
  89. package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
  90. package/scripts/github/stage-reviewer-draft.mjs +10 -2
  91. package/scripts/github/tick-verified-checkboxes.mjs +202 -0
  92. package/scripts/github/upsert-checkpoint-verdict.mjs +105 -15
  93. package/scripts/github/verify-briefing-prefixes.mjs +191 -0
  94. package/scripts/github/verify-fresh-review-context.mjs +226 -32
  95. package/scripts/github/view-pr.mjs +150 -0
  96. package/scripts/github/wait-pr-checks.mjs +171 -0
  97. package/scripts/github/write-gate-context.mjs +458 -65
  98. package/scripts/github/write-gate-findings-log.mjs +128 -2
  99. package/scripts/lib/jq-output.mjs +18 -0
  100. package/scripts/loop/_post-convergence-change.mjs +211 -0
  101. package/scripts/loop/_pr-runner-coordination.mjs +70 -0
  102. package/scripts/loop/_repo-root-resolver.mjs +47 -0
  103. package/scripts/loop/build-handoff-envelope.mjs +14 -4
  104. package/scripts/loop/check-retro-tooling.mjs +14 -3
  105. package/scripts/loop/checkpoint-contract.mjs +7 -4
  106. package/scripts/loop/cleanup-worktree.mjs +12 -3
  107. package/scripts/loop/conductor-monitor.mjs +12 -18
  108. package/scripts/loop/copilot-pr-handoff.mjs +162 -14
  109. package/scripts/loop/debt-remediate.mjs +24 -12
  110. package/scripts/loop/detect-change-scope.mjs +38 -9
  111. package/scripts/loop/detect-copilot-loop-state.mjs +34 -8
  112. package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
  113. package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
  114. package/scripts/loop/detect-internal-only-pr.mjs +8 -2
  115. package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
  116. package/scripts/loop/detect-pr-gate-coordination-state.mjs +184 -69
  117. package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
  118. package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
  119. package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
  120. package/scripts/loop/ensure-worktree.mjs +8 -3
  121. package/scripts/loop/info.mjs +12 -4
  122. package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
  123. package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
  124. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
  125. package/scripts/loop/inspect-run-viewer.mjs +67 -4
  126. package/scripts/loop/inspect-run.mjs +8 -2
  127. package/scripts/loop/outer-loop.mjs +8 -4
  128. package/scripts/loop/pr-runner-coordination.mjs +2 -9
  129. package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
  130. package/scripts/loop/pre-flight-gate.mjs +9 -9
  131. package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
  132. package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
  133. package/scripts/loop/provision-worktree.mjs +74 -3
  134. package/scripts/loop/resolve-dev-loop-startup.mjs +216 -10
  135. package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
  136. package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
  137. package/scripts/loop/run-conductor-cycle.mjs +8 -2
  138. package/scripts/loop/run-queue.mjs +18 -9
  139. package/scripts/loop/run-refinement-audit.mjs +8 -9
  140. package/scripts/loop/run-watch-cycle.mjs +2 -9
  141. package/scripts/loop/sanctioned-commands.mjs +106 -0
  142. package/scripts/loop/steer-loop.mjs +29 -16
  143. package/scripts/loop/validate-pr-body-spec.mjs +223 -0
  144. package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
  145. package/scripts/pages/build-site.mjs +59 -8
  146. package/scripts/pages/build-state-atlas.mjs +443 -0
  147. package/scripts/projects/_resolve-project.mjs +1 -0
  148. package/scripts/projects/add-queue-item.mjs +60 -54
  149. package/scripts/projects/archive-done-items.mjs +49 -84
  150. package/scripts/projects/ensure-queue-board.mjs +10 -36
  151. package/scripts/projects/list-queue-items.mjs +59 -382
  152. package/scripts/projects/move-queue-item.mjs +21 -449
  153. package/scripts/projects/reconcile-queue.mjs +253 -0
  154. package/scripts/projects/reorder-queue-item.mjs +43 -68
  155. package/scripts/projects/resolve-active-board-item.mjs +108 -46
  156. package/scripts/projects/sync-item-status.mjs +15 -10
  157. package/scripts/refine/_refine-helpers.mjs +21 -5
  158. package/scripts/refine/exit-spike.mjs +18 -8
  159. package/scripts/refine/promote-plan.mjs +22 -16
  160. package/scripts/refine/prose-linkage-detector.mjs +1 -1
  161. package/scripts/refine/refine-plan-file.mjs +13 -4
  162. package/scripts/refine/refinement-completeness-checker.mjs +1 -1
  163. package/scripts/refine/scaffold-spike-file.mjs +4 -8
  164. package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
  165. package/scripts/refine/tree-integrity-validator.mjs +1 -1
  166. package/scripts/refine/validate-plan-file.mjs +1 -1
  167. package/scripts/refine/validate-spike-file.mjs +1 -1
  168. package/scripts/refine/verify.mjs +11 -6
  169. package/scripts/release/assert-core-dependency-version.mjs +123 -0
  170. package/scripts/release/extract-changelog-section.mjs +16 -2
  171. package/skills/copilot-pr-followup/SKILL.md +60 -34
  172. package/skills/dev-loop/SKILL.md +11 -7
  173. package/skills/docs/acceptance-criteria-verification.md +8 -3
  174. package/skills/docs/anti-patterns.md +5 -3
  175. package/skills/docs/artifact-authority-contract.md +30 -11
  176. package/skills/docs/confirmation-rules.md +1 -0
  177. package/skills/docs/contract-style-guide.md +37 -0
  178. package/skills/docs/copilot-loop-operations.md +19 -15
  179. package/skills/docs/cross-harness-regression-contract.md +60 -0
  180. package/skills/docs/debt-remediation-contract.md +1 -1
  181. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  182. package/skills/docs/issue-intake-procedure.md +26 -19
  183. package/skills/docs/local-planning-flow.md +1 -1
  184. package/skills/docs/local-planning-worked-example.md +1 -1
  185. package/skills/docs/merge-preconditions.md +30 -12
  186. package/skills/docs/plan-file-contract.md +1 -1
  187. package/skills/docs/pr-lifecycle-contract.md +44 -35
  188. package/skills/docs/public-dev-loop-contract.md +60 -44
  189. package/skills/docs/required-rules.json +149 -0
  190. package/skills/docs/retrospective-checkpoint-contract.md +106 -79
  191. package/skills/docs/spike-mode-contract.md +14 -7
  192. package/skills/docs/stop-conditions.md +30 -15
  193. package/skills/docs/tracker-first-loop-state.md +13 -8
  194. package/skills/docs/ui-e2e-scoping-step.md +32 -0
  195. package/skills/docs/validation-policy.md +4 -5
  196. package/skills/docs/workflow-handoff-contract.md +39 -0
  197. package/skills/local-implementation/SKILL.md +62 -145
  198. package/skills/loop-grill/SKILL.md +165 -0
  199. package/.claude/commands/continue.md +0 -15
  200. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
  201. package/scripts/loop/conductor.mjs +0 -233
  202. package/scripts/loop/detect-stale-runner.mjs +0 -265
  203. package/scripts/loop/pre-push-main-guard.mjs +0 -117
@@ -1,28 +1,42 @@
1
1
  #!/usr/bin/env node
2
- // Collapse the board's "In Progress" column to a SINGLE continue target (#988 P1).
2
+ // Resolve the board's single continue target for the live `/loop-continue` path
3
+ // (#988 P1, extended #1091). It lists the "In Progress" column and, failing
4
+ // that, the "Next Up" column via list-queue-items.mjs — with NO routing opinions
5
+ // beyond a single pick and NO guessing. It never touches Backlog.
3
6
  //
4
- // Pure list->single-target collapse with NO routing opinions: it lists the
5
- // In-Progress items via list-queue-items.mjs and either returns the lone target
6
- // or FAILS CLOSED. It never guesses among multiple active items.
7
- //
8
- // exactly one -> { ok: true, target: { kind: "issue"|"pr", number } }
9
- // zero -> { ok: false, reason: "..." } (no in-progress item)
10
- // multiple -> { ok: false, reason: "..." } (names the items)
7
+ // exactly one In Progress -> { ok: true, target: {kind,number}, source: "in-progress" }
8
+ // multiple In Progress -> { ok: false, reason: "..." } (names the items)
9
+ // zero In Progress:
10
+ // Next Up has items -> { ok: true, target: {kind,number}, source: "next-up" }
11
+ // (HEAD of Next Up, by POSITION ascending)
12
+ // Next Up empty -> { ok: false, reason: <canonical empty-queue msg>, source: "next-up" }
13
+ // Next Up query errors -> propagates (fail closed surface it, no fallback)
11
14
  //
12
15
  // Downstream (the dev-loop skill) resolves authoritative state from this number;
13
16
  // this helper deliberately makes no further decisions.
14
17
  import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
15
18
  import { parseArgs } from "node:util";
16
- import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
19
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
17
20
  import { main as listQueueItems } from "./list-queue-items.mjs";
21
+ import { EMPTY_NEXT_UP_MESSAGE } from "@dev-loops/core/loop/queue-board-ordering";
22
+ import { loadStateColumnMap, LOGICAL_COLUMN } from "@dev-loops/core/loop/queue-board-sync";
18
23
 
24
+ // Illustrative default labels for USAGE/help + comments only; the actual query
25
+ // columns resolve through queue.statusColumns.in_progress / .next_up at
26
+ // runtime (#1098, #1143).
19
27
  const IN_PROGRESS_COLUMN = "In Progress";
28
+ const NEXT_UP_COLUMN = "Next Up";
29
+ // Canonical fail-closed empty-queue message — matches queue-driver.mjs so
30
+ // operators see one string regardless of which layer detects it (#1091).
31
+ const EMPTY_QUEUE_REASON = EMPTY_NEXT_UP_MESSAGE;
20
32
 
21
33
  const USAGE = `Usage: dev-loops queue resolve-active --repo <owner/name> --project <number|id>
22
34
 
23
- Collapse the board's "${IN_PROGRESS_COLUMN}" column to a single continue target.
24
- Used by bare \`/continue\` to pick up the one in-progress item. Fails closed
25
- (no guessing) when the board has zero or more than one in-progress item.
35
+ Resolve the board's single continue target for bare \`/loop-continue\`:
36
+ continues the one "${IN_PROGRESS_COLUMN}" item; if there is none, picks the HEAD
37
+ of "${NEXT_UP_COLUMN}" by POSITION ascending. Fails closed (no guessing) on
38
+ multiple in-progress items, and when "${NEXT_UP_COLUMN}" is empty. Never pulls
39
+ from Backlog.
26
40
 
27
41
  Options:
28
42
  --repo <owner/name> Required. Repository to scope the project search.
@@ -31,19 +45,21 @@ Options:
31
45
 
32
46
  Output (stdout):
33
47
  JSON, exactly one in-progress item:
34
- { ok: true, target: { kind: "issue"|"pr", number } }
35
- JSON, zero or multiple (fail closed):
48
+ { ok: true, target: { kind: "issue"|"pr", number }, source: "in-progress" }
49
+ JSON, zero in-progress + "${NEXT_UP_COLUMN}" head:
50
+ { ok: true, target: { kind: "issue"|"pr", number }, source: "next-up" }
51
+ JSON, fail closed (multiple in-progress, or empty "${NEXT_UP_COLUMN}"):
36
52
  { ok: false, reason: "..." }
37
53
 
38
54
  ${JQ_OUTPUT_USAGE}
39
55
 
40
56
  Exit codes (default / unfiltered output):
41
- 0 — exactly one in-progress item resolved
57
+ 0 — a single continue target resolved (in-progress or "${NEXT_UP_COLUMN}" head)
42
58
  1 — usage or argument error
43
59
  2 — GitHub API error / invalid --jq filter
44
- 3 — fail closed (pass an explicit issue/PR): zero or multiple in-progress
45
- items, or the board/project could not be resolved (project, status
46
- field, or "${IN_PROGRESS_COLUMN}" column not found)
60
+ 3 — fail closed (pass an explicit issue/PR): multiple in-progress items, an
61
+ empty "${NEXT_UP_COLUMN}" column, or the board/project could not be
62
+ resolved (project, status field, or the column not found)
47
63
 
48
64
  With --jq/--silent the result is filtered to a value/predicate, so the exit code
49
65
  follows the shared jq-output contract (0 = truthy/ok, 1 = falsy/non-ok, 2 =
@@ -94,14 +110,10 @@ function parseCliArgs(argv) {
94
110
  case "project":
95
111
  args.project = requireValue(token, "--project requires a value (number or node ID)");
96
112
  break;
97
- case "jq":
98
- args.jq = requireValue(token, "--jq requires a filter");
99
- break;
100
- case "silent":
101
- args.silent = true;
102
- break;
103
- default:
113
+ default: {
114
+ if (matchJqOutputToken(token, args, (t) => requireValue(t, "--jq requires a filter"))) break;
104
115
  throw parseError(`Unknown flag: ${token.rawName}`);
116
+ }
105
117
  }
106
118
  }
107
119
  return args;
@@ -112,36 +124,86 @@ function describeItem(item) {
112
124
  return item.title ? `${ref} (${item.title})` : ref;
113
125
  }
114
126
 
115
- // Collapse a list of board items to a single continue target. Prefer the linked
116
- // PR number when present (the canonical artifact once work is in flight), else
117
- // the issue. No routing opinion beyond that single pick.
127
+ // Prefer the linked PR number when present (the canonical artifact once work is
128
+ // in flight), else the issue. No routing opinion beyond that single pick.
129
+ function itemToTarget(item) {
130
+ return item.prNumber != null
131
+ ? { kind: "pr", number: item.prNumber }
132
+ : { kind: "issue", number: item.issueNumber };
133
+ }
134
+
135
+ // Collapse the "In Progress" column to a single continue target. The caller only
136
+ // invokes this with a NON-EMPTY column (zero In Progress falls through to
137
+ // resolveNextUpHead in main()), so this only decides among the in-progress items
138
+ // and never guesses when there is more than one.
118
139
  function collapseToTarget(items) {
119
- if (items.length === 0) {
120
- return {
121
- ok: false,
122
- reason: `No in-progress board item to continue. Pass an explicit issue/PR, e.g. \`/continue #N\`.`,
123
- };
124
- }
125
140
  if (items.length > 1) {
126
141
  const listed = items.map(describeItem).join(", ");
127
142
  return {
128
143
  ok: false,
129
- reason: `${items.length} in-progress board items: ${listed}. Pass an explicit issue/PR to disambiguate, e.g. \`/continue #N\`.`,
144
+ reason: `${items.length} in-progress board items: ${listed}. Pass an explicit issue/PR to disambiguate, e.g. \`/loop-continue #N\`.`,
130
145
  };
131
146
  }
132
- const item = items[0];
133
- const target = item.prNumber != null
134
- ? { kind: "pr", number: item.prNumber }
135
- : { kind: "issue", number: item.issueNumber };
136
- return { ok: true, target };
147
+ return { ok: true, target: itemToTarget(items[0]), source: "in-progress" };
148
+ }
149
+
150
+ // Zero in-progress: the live continue path falls through to the "Next Up"
151
+ // column, HEAD by POSITION ascending (list-queue-items returns position order).
152
+ // NEVER pulls from Backlog; empty Next Up fails closed with the canonical
153
+ // message, and a query error propagates (fail closed — surface it, no fallback).
154
+ async function resolveNextUpHead(args, { env, runChild, cwd = process.cwd() } = {}) {
155
+ // Resolve the next_up column name through the SAME statusColumns mapping
156
+ // board-sync uses (#1098): a repo that renamed Next Up (e.g. to "Todo") gets
157
+ // its configured column queried, not the literal default. Pickup SEMANTICS
158
+ // (position-ascending HEAD, fail-closed on empty, never Backlog) are unchanged.
159
+ const { columnNames, error: configError } = loadStateColumnMap(cwd);
160
+ if (configError) {
161
+ // A malformed `.devloops` must fail CLOSED — never silently fall back to the
162
+ // literal "Next Up" and risk selecting the wrong item from a stale/renamed
163
+ // column (#1098). Throw so the CLI surfaces it (exit 2), mirroring a Next Up
164
+ // query error's "propagate, no fallback" contract.
165
+ throw Object.assign(
166
+ new Error(`could not resolve next_up column (config read/parse error: ${configError})`),
167
+ { code: "CONFIG_ERROR" },
168
+ );
169
+ }
170
+ const nextUpColumn = columnNames[LOGICAL_COLUMN.NEXT_UP];
171
+ const listed = await listQueueItems(
172
+ { repo: args.repo, project: args.project, column: nextUpColumn },
173
+ { env, runChild },
174
+ );
175
+ const items = listed.items ?? [];
176
+ if (items.length === 0) {
177
+ return { ok: false, reason: EMPTY_QUEUE_REASON, source: "next-up" };
178
+ }
179
+ return { ok: true, target: itemToTarget(items[0]), source: "next-up" };
137
180
  }
138
181
 
139
- async function main(args, { env = process.env, runChild } = {}) {
182
+ async function main(args, { env = process.env, runChild, cwd = process.cwd() } = {}) {
183
+ // Resolve the in_progress column name through the SAME statusColumns mapping
184
+ // board-sync uses (#1098, #1143): a repo that renamed In Progress gets its
185
+ // configured column queried, not the literal default. Fail CLOSED on a
186
+ // malformed `.devloops` — never silently query the literal "In Progress"
187
+ // and risk missing the active item on a renamed/stale column.
188
+ const { columnNames, error: configError } = loadStateColumnMap(cwd);
189
+ if (configError) {
190
+ throw Object.assign(
191
+ new Error(`could not resolve in_progress column (config read/parse error: ${configError})`),
192
+ { code: "CONFIG_ERROR" },
193
+ );
194
+ }
195
+ const inProgressColumn = columnNames[LOGICAL_COLUMN.IN_PROGRESS];
140
196
  const listed = await listQueueItems(
141
- { repo: args.repo, project: args.project, column: IN_PROGRESS_COLUMN },
197
+ { repo: args.repo, project: args.project, column: inProgressColumn },
142
198
  { env, runChild },
143
199
  );
144
- return collapseToTarget(listed.items ?? []);
200
+ const items = listed.items ?? [];
201
+ // Exactly one → continue it. Multiple → fail closed (never guess). Zero →
202
+ // fall through to the Next Up head (the live pickup path, #1091).
203
+ if (items.length === 0) {
204
+ return resolveNextUpHead(args, { env, runChild, cwd });
205
+ }
206
+ return collapseToTarget(items);
145
207
  }
146
208
 
147
209
  function classifyExitCode(err) {
@@ -150,7 +212,7 @@ function classifyExitCode(err) {
150
212
  return 2;
151
213
  }
152
214
 
153
- async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, runChild } = {}) {
215
+ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, runChild, cwd = process.cwd() } = {}) {
154
216
  let args;
155
217
  try {
156
218
  args = parseCliArgs(argv);
@@ -164,7 +226,7 @@ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr,
164
226
  return;
165
227
  }
166
228
  try {
167
- const result = await main(args, { env, runChild });
229
+ const result = await main(args, { env, runChild, cwd });
168
230
  // Fail closed (zero/multiple) is a clean, expected outcome — distinct exit code 3,
169
231
  // not a crash; --jq/--silent still apply so callers can probe `.ok`.
170
232
  process.exitCode = emitResult(result, {
@@ -190,4 +252,4 @@ if (isDirectCliRun(import.meta.url)) {
190
252
  });
191
253
  }
192
254
 
193
- export { main, collapseToTarget, runCli };
255
+ export { main, collapseToTarget, resolveNextUpHead, runCli };
@@ -3,6 +3,7 @@ import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
3
3
  import { runChild as _runChild } from "../_cli-primitives.mjs";
4
4
  import { syncBoardStatus } from "@dev-loops/core/loop/queue-board-sync";
5
5
  import { parseArgs } from "node:util";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
6
7
 
7
8
  const USAGE = `Usage: dev-loops queue sync-status --repo <owner/name> --item <number> --to-column <name>
8
9
  (dev-loops project sync-status … is a back-compat alias)
@@ -26,9 +27,12 @@ Output (stdout):
26
27
  { ok: true, skipped: false, result: { item: { newColumn } } }
27
28
  { ok: true, skipped: true, reason: "board not configured" }
28
29
 
30
+ ${JQ_OUTPUT_USAGE}
31
+
29
32
  Exit codes:
30
33
  0 — always on a parsed command (best-effort sync; skips/failures are reported in JSON)
31
34
  1 — usage or argument error
35
+ 2 — invalid --jq filter
32
36
  `.trim();
33
37
 
34
38
  function parseCliArgs(argv) {
@@ -48,6 +52,7 @@ function parseCliArgs(argv) {
48
52
  item: { type: "string" },
49
53
  "to-column": { type: "string" },
50
54
  help: { type: "boolean", short: "h" },
55
+ ...JQ_OUTPUT_PARSE_OPTIONS,
51
56
  },
52
57
  allowPositionals: true,
53
58
  strict: false,
@@ -77,8 +82,10 @@ function parseCliArgs(argv) {
77
82
  case "to-column":
78
83
  args.toColumn = requireValue(token, "--to-column requires a value", "INVALID_COLUMN");
79
84
  break;
80
- default:
85
+ default: {
86
+ if (matchJqOutputToken(token, args, (t) => requireValue(t, "--jq requires a filter", "INVALID_ARGS"))) break;
81
87
  throw Object.assign(new Error(`Unknown flag: ${token.rawName}`), { code: "INVALID_ARGS", usage: USAGE });
88
+ }
82
89
  }
83
90
  }
84
91
  return args;
@@ -174,17 +181,15 @@ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr,
174
181
  return;
175
182
  }
176
183
  // Defensive: any unexpected error stays best-effort/non-fatal — report it
177
- // as a skip and keep exit 0 so it never blocks the PR/merge caller.
178
- stdout.write(JSON.stringify({ ok: true, skipped: true, reason: err.message ?? "board sync failed" }) + "\n");
179
- // Explicitly assert success: a pre-existing non-zero process.exitCode from a
180
- // long-lived caller must not leak through this best-effort path.
181
- process.exitCode = 0;
184
+ // as a skip and keep exit 0 (ok:true) so it never blocks the PR/merge caller,
185
+ // unless --jq/--silent turns a filter/predicate into a non-zero exit same
186
+ // shared contract as the normal result path below.
187
+ process.exitCode = emitResult({ ok: true, skipped: true, reason: err.message ?? "board sync failed" }, { jq: args.jq, silent: args.silent, stdout, stderr });
182
188
  return;
183
189
  }
184
- stdout.write(JSON.stringify(result) + "\n");
185
- // Best-effort contract: a parsed command always reports success. Explicitly
186
- // clear any pre-existing non-zero process.exitCode so it cannot leak.
187
- process.exitCode = 0;
190
+ // Best-effort contract: result.ok is always true, so without --jq/--silent this
191
+ // always exits 0. An invalid --jq filter still fails closed (exit 2).
192
+ process.exitCode = emitResult(result, { jq: args.jq, silent: args.silent, stdout, stderr });
188
193
  }
189
194
 
190
195
  if (isDirectCliRun(import.meta.url)) {
@@ -5,6 +5,7 @@ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "
5
5
  import { parseArgs } from "node:util";
6
6
  import { parsePositiveInteger, requireTokenValue, runChild } from "../_cli-primitives.mjs";
7
7
  import { detectRepoSlug, parseRepoSlug } from "@dev-loops/core/github/repo-slug";
8
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
8
9
 
9
10
  export const FORBIDDEN_PROSE_PATTERNS = [
10
11
  /Child of #/iu,
@@ -16,7 +17,12 @@ export const FORBIDDEN_PROSE_PATTERNS = [
16
17
  export const DEFAULT_USAGE_SUFFIX = `
17
18
  Output:
18
19
  Default output is human-readable text.
19
- Add --json for machine-readable JSON.`.trim();
20
+ Add --json for machine-readable JSON.
21
+
22
+ ${JQ_OUTPUT_USAGE}
23
+ (--jq/--silent only apply together with --json; the verdict is always in the
24
+ payload, never the exit code — a parsed --json run always exits 0 unless
25
+ --jq/--silent explicitly turns the verdict into the exit code.)`.trim();
20
26
 
21
27
  export function normalizeIssueNumber(value, label, parseError) {
22
28
  return parsePositiveInteger(value, label, parseError);
@@ -26,7 +32,12 @@ export function parseCheckerCliArgs(argv, usage, checkerName) {
26
32
  const parseError = buildParseError(usage);
27
33
  const { tokens } = parseArgs({
28
34
  args: [...argv],
29
- options: { help: { type: "boolean", short: "h" }, input: { type: "string" }, json: { type: "boolean" } },
35
+ options: {
36
+ help: { type: "boolean", short: "h" },
37
+ input: { type: "string" },
38
+ json: { type: "boolean" },
39
+ ...JQ_OUTPUT_PARSE_OPTIONS,
40
+ },
30
41
  allowPositionals: true,
31
42
  strict: false,
32
43
  tokens: true,
@@ -51,6 +62,7 @@ export function parseCheckerCliArgs(argv, usage, checkerName) {
51
62
  options.json = true;
52
63
  continue;
53
64
  }
65
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
54
66
  throw parseError(`Unknown argument: ${token.rawName}`);
55
67
  }
56
68
  if (typeof options.input !== "string" || options.input.trim().length === 0) {
@@ -262,10 +274,13 @@ export function normalizeScopeToken(value) {
262
274
  .replace(/\s+/gu, " ");
263
275
  }
264
276
 
265
- export function writeCheckerOutput(result, { stdout = process.stdout, json = false }) {
277
+ // Returns the process exit code to use. Documented contract: the verdict lives
278
+ // in the payload, not the exit code — a parsed --json run exits 0 even when
279
+ // result.ok is false, unless --jq/--silent explicitly turns the verdict into
280
+ // the exit code (predicate/--silent semantics from the shared jq-output contract).
281
+ export function writeCheckerOutput(result, { stdout = process.stdout, stderr = process.stderr, json = false, jq, silent }) {
266
282
  if (json) {
267
- stdout.write(`${JSON.stringify(result)}\n`);
268
- return;
283
+ return emitResult(result, { jq, silent, stdout, stderr, ok: true });
269
284
  }
270
285
 
271
286
  const status = result.ok ? "PASS" : "FAIL";
@@ -279,6 +294,7 @@ export function writeCheckerOutput(result, { stdout = process.stdout, json = fal
279
294
  }
280
295
  }
281
296
  stdout.write(`${lines.join("\n")}\n`);
297
+ return 0;
282
298
  }
283
299
 
284
300
 
@@ -17,6 +17,7 @@ import {
17
17
  SPIKE_EXIT_ACTION,
18
18
  SPIKE_EXIT_DISPOSITION,
19
19
  } from "@dev-loops/core/loop/spike-exit-contract";
20
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
20
21
 
21
22
  const USAGE = `Usage:
22
23
  exit-spike.mjs --spike-file <path> --disposition <discard|graduate> [--plan-file <path>] [--json]
@@ -41,9 +42,14 @@ Optional:
41
42
  --plan-file <path> Output plan-file path (required for graduate)
42
43
  --json Machine-readable JSON output
43
44
  --help Show this help
45
+
46
+ ${JQ_OUTPUT_USAGE}
47
+ (--jq/--silent only apply together with --json; the default text output is unaffected.)
48
+
44
49
  Exit codes:
45
50
  0 Exit succeeded (discard recorded; or plan file written for graduate)
46
- 1 Argument error, or fail-closed (not ready / unknown disposition)`.trim();
51
+ 1 Argument error, or fail-closed (not ready / unknown disposition)
52
+ 2 Invalid --jq filter`.trim();
47
53
 
48
54
  const parseError = buildParseError(USAGE);
49
55
 
@@ -56,6 +62,7 @@ export function parseExitSpikeCliArgs(argv) {
56
62
  disposition: { type: "string" },
57
63
  "plan-file": { type: "string" },
58
64
  json: { type: "boolean" },
65
+ ...JQ_OUTPUT_PARSE_OPTIONS,
59
66
  },
60
67
  allowPositionals: true,
61
68
  strict: false,
@@ -87,6 +94,7 @@ export function parseExitSpikeCliArgs(argv) {
87
94
  options.json = true;
88
95
  continue;
89
96
  }
97
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
90
98
  throw parseError(`Unknown argument: ${token.rawName}`);
91
99
  }
92
100
  if (typeof options.spikeFile !== "string" || options.spikeFile.trim().length === 0) {
@@ -104,11 +112,14 @@ export function parseExitSpikeCliArgs(argv) {
104
112
  return options;
105
113
  }
106
114
 
107
- function emit(stdout, json, summary, humanLines) {
108
- if (json) {
109
- stdout.write(`${JSON.stringify(summary)}\n`);
115
+ // Sets process.exitCode from `summary.ok` (0/1), or the shared --jq/--silent
116
+ // contract's exit code (0/1/2) when --json + --jq/--silent are in play.
117
+ function emit(stdout, options, summary, humanLines) {
118
+ if (options.json) {
119
+ process.exitCode = emitResult(summary, { jq: options.jq, silent: options.silent, stdout });
110
120
  } else {
111
121
  stdout.write(`${humanLines.join("\n")}\n`);
122
+ process.exitCode = summary.ok ? 0 : 1;
112
123
  }
113
124
  }
114
125
 
@@ -136,15 +147,14 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
136
147
  if (!decision.ok) {
137
148
  // Fail closed: no tracker artifact, no plan file — surface the reason.
138
149
  const summary = { ok: false, reason: decision.reason, spikeIntakeState: decision.spikeIntakeState ?? spikeIntakeState };
139
- emit(stdout, options.json, summary, [`exit-spike: FAIL (${decision.reason})`]);
140
- process.exitCode = 1;
150
+ emit(stdout, options, summary, [`exit-spike: FAIL (${decision.reason})`]);
141
151
  return summary;
142
152
  }
143
153
 
144
154
  // DISCARD: zero artifacts. The findings doc on disk is the whole record.
145
155
  if (decision.action === SPIKE_EXIT_ACTION.DISCARD) {
146
156
  const summary = { ok: true, action: SPIKE_EXIT_ACTION.DISCARD, spikeFile: spikePath };
147
- emit(stdout, options.json, summary, [
157
+ emit(stdout, options, summary, [
148
158
  "exit-spike: PASS (discard)",
149
159
  ` spike: ${spikePath}`,
150
160
  " no tracker artifact created (findings doc is the record)",
@@ -170,7 +180,7 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
170
180
  spikeFile: spikePath,
171
181
  planFile: planPath,
172
182
  };
173
- emit(stdout, options.json, summary, [
183
+ emit(stdout, options, summary, [
174
184
  "exit-spike: PASS (graduate)",
175
185
  ` spike: ${spikePath}`,
176
186
  ` plan: ${planPath} (local-first; promote with scripts/refine/promote-plan.mjs)`,
@@ -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
  [