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
@@ -54,6 +54,7 @@ import { readFileSync } from "node:fs";
54
54
  import process from "node:process";
55
55
  import { parseArgs } from "node:util";
56
56
  import { isDirectCliRun } from "@dev-loops/core/cli/helpers";
57
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
57
58
 
58
59
  const USAGE = `Usage: node scripts/loop/check-retro-tooling.mjs [--transcript <path>] [--json]
59
60
 
@@ -65,10 +66,13 @@ Options:
65
66
  --transcript <path> File of newline-delimited commands (default: read stdin)
66
67
  --json Emit machine-readable JSON (default: human summary)
67
68
 
69
+ ${JQ_OUTPUT_USAGE}
70
+ (--jq/--silent only apply together with --json; the default text output is unaffected.)
71
+
68
72
  Exit codes:
69
73
  0 No violations
70
74
  1 One or more violations found
71
- 2 Argument/runtime error`;
75
+ 2 Argument/runtime error, or invalid --jq filter`;
72
76
 
73
77
  /**
74
78
  * Write-ops that currently have no internal dev-loops wrapper. Recorded
@@ -81,6 +85,10 @@ const ALLOWED_WRITE_OPS = Object.freeze([
81
85
  /^gh\s+pr\s+ready\b/,
82
86
  /^gh\s+issue\s+create\b/,
83
87
  /^gh\s+issue\s+edit\b/,
88
+ // `gh label create` HAS a wrapper (scripts/github/create-label.mjs); this
89
+ // entry is belt-and-suspenders so a bare invocation (e.g. surfaced from the
90
+ // wrapper's own subprocess) classifies as an allowed write-op, not a violation.
91
+ /^gh\s+label\s+create\b/,
84
92
  ]);
85
93
 
86
94
  /** Split a command line into top-level segments on &&, ||, |, ;. */
@@ -190,6 +198,7 @@ function parseCliArgs(argv) {
190
198
  transcript: { type: "string" },
191
199
  json: { type: "boolean" },
192
200
  help: { type: "boolean", short: "h" },
201
+ ...JQ_OUTPUT_PARSE_OPTIONS,
193
202
  },
194
203
  strict: true,
195
204
  allowPositionals: false,
@@ -221,8 +230,10 @@ async function run(argv, { stdout, stderr }) {
221
230
  const { violations, allowedWriteOps, internalToolingOnly } = analyzeTranscript(transcript);
222
231
 
223
232
  if (values.json) {
224
- stdout.write(`${JSON.stringify({ ok: internalToolingOnly, internalToolingOnly, rawCallViolations: violations, allowedWriteOps })}\n`);
225
- } else if (internalToolingOnly) {
233
+ const payload = { ok: internalToolingOnly, internalToolingOnly, rawCallViolations: violations, allowedWriteOps };
234
+ return emitResult(payload, { jq: values.jq, silent: values.silent, stdout, stderr });
235
+ }
236
+ if (internalToolingOnly) {
226
237
  stdout.write(`internalToolingOnly: true — no agent-level raw gh/python/node -e calls found.\n`);
227
238
  if (allowedWriteOps.length > 0) {
228
239
  stdout.write(`Allowed write-ops (no wrapper yet): ${allowedWriteOps.length}\n`);
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import process from "node:process";
5
5
  import { parseArgs } from "node:util";
6
6
  import { isDirectCliRun } from "@dev-loops/core/cli/helpers";
7
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
7
8
 
8
9
  const CHECKPOINT_FILE = ".pi/dev-loop-retrospective-checkpoint.json";
9
10
  const ALLOWED_STATES = new Set(["required", "complete", "skipped", "none", "missing"]);
@@ -17,7 +18,9 @@ Required:
17
18
 
18
19
  Optional:
19
20
  --notes <text> Required when --state is complete
20
- --reason <text> Required when --state is skipped`;
21
+ --reason <text> Required when --state is skipped
22
+
23
+ ${JQ_OUTPUT_USAGE}`;
21
24
 
22
25
  function parseError(message) {
23
26
  return Object.assign(new Error(message), { usage: USAGE });
@@ -43,6 +46,7 @@ function parseCliArgs(argv) {
43
46
  notes: { type: "string" },
44
47
  reason: { type: "string" },
45
48
  help: { type: "boolean", short: "h" },
49
+ ...JQ_OUTPUT_PARSE_OPTIONS,
46
50
  },
47
51
  strict: true,
48
52
  allowPositionals: false,
@@ -67,7 +71,7 @@ function parseCliArgs(argv) {
67
71
  throw parseError('state "skipped" requires --reason');
68
72
  }
69
73
 
70
- return { state, notes: values.notes ?? null, reason: values.reason ?? null };
74
+ return { state, notes: values.notes ?? null, reason: values.reason ?? null, jq: values.jq, silent: values.silent === true };
71
75
  }
72
76
 
73
77
  async function run(argv) {
@@ -82,8 +86,7 @@ async function run(argv) {
82
86
  const checkpointPath = path.join(process.cwd(), CHECKPOINT_FILE);
83
87
  await mkdir(path.dirname(checkpointPath), { recursive: true });
84
88
  await writeFile(checkpointPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
85
- process.stdout.write(JSON.stringify({ ok: true, path: CHECKPOINT_FILE, checkpoint: payload }) + "\n");
86
- return 0;
89
+ return emitResult({ ok: true, path: CHECKPOINT_FILE, checkpoint: payload }, { jq: parsed.jq, silent: parsed.silent });
87
90
  }
88
91
 
89
92
  if (isDirectCliRun(import.meta.url)) {
@@ -21,6 +21,7 @@ import { requireTokenValue } from "../_cli-primitives.mjs";
21
21
  import { parseArgs } from "node:util";
22
22
  import { resolveWorktreePath, WORKTREE_NAMESPACE } from "@dev-loops/core/loop/handoff-envelope";
23
23
  import { canonicalize } from "./_worktree-path.mjs";
24
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
24
25
 
25
26
  const USAGE = `Usage:
26
27
  cleanup-worktree.mjs --repo-root <p> (--issue <n> | --pr <n> | --path <p>)
@@ -37,7 +38,9 @@ Optional:
37
38
  Output (stdout, JSON):
38
39
  { "ok": bool, "removed": <path>|null, "reason": "<why>" }
39
40
  ok is true on success/skip (incl. fail-soft git errors); false ONLY when the
40
- path is refused for being outside ${WORKTREE_NAMESPACE}/ (removed: null).`.trim();
41
+ path is refused for being outside ${WORKTREE_NAMESPACE}/ (removed: null).
42
+
43
+ ${JQ_OUTPUT_USAGE}`.trim();
41
44
 
42
45
  const parseError = buildParseError(USAGE);
43
46
 
@@ -57,6 +60,7 @@ export function parseCleanupWorktreeCliArgs(argv) {
57
60
  issue: { type: "string" },
58
61
  pr: { type: "string" },
59
62
  path: { type: "string" },
63
+ ...JQ_OUTPUT_PARSE_OPTIONS,
60
64
  },
61
65
  allowPositionals: true,
62
66
  strict: false,
@@ -85,6 +89,7 @@ export function parseCleanupWorktreeCliArgs(argv) {
85
89
  options.path = requireTokenValue(token, parseError, { flagPattern: /^-/u });
86
90
  continue;
87
91
  }
92
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
88
93
  throw parseError(`Unknown argument: ${token.rawName}`);
89
94
  }
90
95
  if (options.help) return options;
@@ -155,14 +160,18 @@ export function cleanupWorktree({ repoRoot, issue, pr, path: explicitPath }, { g
155
160
  return { ok: true, removed: target, reason: "removed" };
156
161
  }
157
162
 
158
- export function runCli(argv = process.argv.slice(2), { stdout = process.stdout } = {}) {
163
+ export function runCli(argv = process.argv.slice(2), { stdout = process.stdout, stderr = process.stderr } = {}) {
159
164
  const options = parseCleanupWorktreeCliArgs(argv);
160
165
  if (options.help) {
161
166
  stdout.write(`${USAGE}\n`);
162
167
  return;
163
168
  }
164
169
  const result = cleanupWorktree(options);
165
- stdout.write(`${JSON.stringify(result)}\n`);
170
+ // FAIL-SOFT contract (see file header): a parsed command always exits 0 on the
171
+ // non-jq path, even when `ok:false` (path refused) — it must never break a
172
+ // merge-completion caller. Force ok:true here so the default path keeps that
173
+ // contract; --jq/--silent can still read the real `ok` field explicitly.
174
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr, ok: true });
166
175
  }
167
176
 
168
177
  if (isDirectCliRun(import.meta.url)) {
@@ -4,7 +4,7 @@ import { access, open, readFile, readdir } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { requireTokenValue } from "../_cli-primitives.mjs";
7
- import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
7
+ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText, readJsonIfExists } from "../_core-helpers.mjs";
8
8
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
9
9
  import { autoDetectSnapshot } from "./detect-copilot-loop-state.mjs";
10
10
  import {
@@ -15,6 +15,7 @@ import {
15
15
  import { interpretLoopState, summarizeLoopInterpretation } from "@dev-loops/core/loop/copilot-loop-state";
16
16
  import { listOpenPrs } from "./_loop-pr-aggregation.mjs";
17
17
  import { parseArgs } from "node:util";
18
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
18
19
  const USAGE = `Usage: conductor-monitor.mjs --repo <owner/name> [--auto-resume]
19
20
  Aggregate Copilot-loop status across all open PRs in one repo.
20
21
  Required:
@@ -78,9 +79,11 @@ Error output (stderr, JSON):
78
79
  { "ok": false, "error": "...", "usage": "..." }
79
80
  gh/runtime failures:
80
81
  { "ok": false, "error": "..." }
82
+ ${JQ_OUTPUT_USAGE}
81
83
  Exit codes:
82
84
  0 Success
83
- 1 Argument error, gh failure, or indeterminate PR status`.trim();
85
+ 1 Argument error, gh failure, or indeterminate PR status
86
+ 2 Invalid --jq filter`.trim();
84
87
  const parseError = buildParseError(USAGE);
85
88
  const DEFAULT_SESSION_ROOT = path.join(os.homedir(), ".pi", "agent", "sessions");
86
89
  const RUN_STATE = {
@@ -125,6 +128,7 @@ function parseCliArgs(argv) {
125
128
  help: { type: "boolean", short: "h" },
126
129
  repo: { type: "string" },
127
130
  "auto-resume": { type: "boolean" },
131
+ ...JQ_OUTPUT_PARSE_OPTIONS,
128
132
  },
129
133
  allowPositionals: true,
130
134
  strict: false,
@@ -149,6 +153,7 @@ function parseCliArgs(argv) {
149
153
  options.autoResume = true;
150
154
  continue;
151
155
  }
156
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
152
157
  throw parseError(`Unknown argument: ${token.rawName}`);
153
158
  }
154
159
  if (options.repo === undefined) {
@@ -306,17 +311,6 @@ async function readFirstLineIfExists(filePath, chunkSize = 4096) {
306
311
  await handle?.close().catch(() => {});
307
312
  }
308
313
  }
309
- async function readJsonIfExists(filePath) {
310
- const text = await readTextIfExists(filePath);
311
- if (text === null) {
312
- return null;
313
- }
314
- try {
315
- return parseJsonText(text);
316
- } catch {
317
- return null;
318
- }
319
- }
320
314
  function normalizeRunState(value) {
321
315
  const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
322
316
  switch (normalized) {
@@ -455,7 +449,7 @@ async function scanSessionArtifactRoot(artifactsDir, records) {
455
449
  const record = records.get(key) ?? createRunRecord(runId, childIndex);
456
450
  const filePath = path.join(artifactsDir, entry.name);
457
451
  if (entry.name.endsWith("_meta.json")) {
458
- const meta = await readJsonIfExists(filePath);
452
+ const meta = await readJsonIfExists(filePath).catch(() => null);
459
453
  if (meta && typeof meta === "object") {
460
454
  records.set(key, mergeRunRecord(record, {
461
455
  agent: typeof meta.agent === "string" ? meta.agent : (record.agent ?? agent),
@@ -540,7 +534,7 @@ async function scanAsyncRunRoot(asyncRoot, records) {
540
534
  const asyncDir = path.join(asyncRoot, runDirEntry.name);
541
535
  const statusPath = path.join(asyncDir, "status.json");
542
536
  const eventsPath = path.join(asyncDir, "events.jsonl");
543
- const status = await readJsonIfExists(statusPath);
537
+ const status = await readJsonIfExists(statusPath).catch(() => null);
544
538
  if (!status || typeof status !== "object") {
545
539
  continue;
546
540
  }
@@ -632,7 +626,7 @@ async function scanAsyncResultRoot(resultsRoot, records) {
632
626
  }
633
627
  const filePath = path.join(resultsRoot, entry.name);
634
628
  if (entry.name.endsWith(".json")) {
635
- const result = await readJsonIfExists(filePath);
629
+ const result = await readJsonIfExists(filePath).catch(() => null);
636
630
  if (!result || typeof result !== "object") {
637
631
  continue;
638
632
  }
@@ -1807,7 +1801,7 @@ export async function runConductorMonitor(
1807
1801
  }
1808
1802
  export async function runCli(
1809
1803
  argv = process.argv.slice(2),
1810
- { stdout = process.stdout, env = process.env, ghCommand = "gh", cwd = process.cwd() } = {},
1804
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", cwd = process.cwd() } = {},
1811
1805
  ) {
1812
1806
  const options = parseCliArgs(argv);
1813
1807
  if (options.help) {
@@ -1819,7 +1813,7 @@ export async function runCli(
1819
1813
  ghCommand,
1820
1814
  repoRoot: cwd,
1821
1815
  });
1822
- stdout.write(`${JSON.stringify(result)}\n`);
1816
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
1823
1817
  }
1824
1818
  if (isDirectCliRun(import.meta.url)) {
1825
1819
  runCli().catch((error) => {
@@ -1,15 +1,17 @@
1
1
  #!/usr/bin/env node
2
- import { buildParseError, formatCliError, isCopilotLogin, isDirectCliRun, normalizeTimestamp } from "../_core-helpers.mjs";
2
+ import { buildParseError, formatCliError, isCopilotLogin, isDirectCliRun, normalizeTimestamp, parseJsonText } from "../_core-helpers.mjs";
3
3
  import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
4
+ import { detectPostConvergenceSignificantChange } from "./_post-convergence-change.mjs";
4
5
  import { detectRepoSlug, parseRepoSlug } from "@dev-loops/core/github/repo-slug";
5
6
  import { resolveRunId } from "@dev-loops/core/loop/run-context";
6
- import path from "node:path";
7
- import { loadDevLoopConfig, resolveRefinement } from "@dev-loops/core/config";
7
+ import { loadDevLoopConfig, resolveEffectiveCopilotRoundCap, resolveRefinement } from "@dev-loops/core/config";
8
8
  import { autoDetectSnapshot } from "./detect-copilot-loop-state.mjs";
9
9
  import { performCopilotReviewRequest } from "../github/request-copilot-review.mjs";
10
10
  import { detectInternalOnly as detectPrInternalOnly } from "./detect-internal-only-pr.mjs";
11
11
  import { applyConfirmedReviewRequest, interpretLoopState, NEXT_ACTIONS, STATE, summarizeLoopInterpretation, TRANSITIONS } from "@dev-loops/core/loop/copilot-loop-state";
12
- import { ensureAsyncRunnerOwnership } from "./_pr-runner-coordination.mjs";
12
+ import { PR_LIFECYCLE_STATE } from "@dev-loops/core/loop/pr-lifecycle";
13
+ import { ensureAsyncRunnerOwnership, releaseAsyncRunnerOwnership } from "./_pr-runner-coordination.mjs";
14
+ import { resolveRepoRoot } from "./_repo-root-resolver.mjs";
13
15
 
14
16
 
15
17
  import {
@@ -17,7 +19,7 @@ import {
17
19
  enforceExternalHealthyWaitTimeout,
18
20
  } from "@dev-loops/core/loop/timeout-policy";
19
21
  import { parseArgs } from "node:util";
20
- import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
22
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
21
23
  import {
22
24
  DEFAULT_POLL_INTERVAL_MS,
23
25
  COPILOT_REVIEW_WAIT_TIMEOUT_MS,
@@ -37,6 +39,13 @@ Optional:
37
39
  --watch-status <status> Refresh deterministic loop state after a prior
38
40
  watcher result (changed|timeout|idle). This mode
39
41
  never requests review; it only re-detects state.
42
+ --lightweight This PR is light-dispatched (#1210): compose the
43
+ Copilot round cap with localImplementation.lightMode.
44
+ maxCopilotRounds (default 1) via
45
+ min(lightMode.maxCopilotRounds, refinement.maxCopilotRounds)
46
+ instead of using refinement.maxCopilotRounds alone.
47
+ refinement.maxCopilotRounds: 0 still disables Copilot
48
+ rounds even with --lightweight.
40
49
  Output (stdout, JSON):
41
50
  { "ok": true, "action": "watch"|"fix"|"stop", "state": "...",
42
51
  "allowedTransitions": [...], "nextAction": "...", "snapshot": {...},
@@ -99,7 +108,7 @@ function summarizeRequestWatchContract({
99
108
  || interpretation.state === STATE.READY_TO_REREQUEST_REVIEW
100
109
  && interpretation.sameHeadCleanConverged !== true
101
110
  ) {
102
- routingState = "ready_state_needs_copilot_request";
111
+ routingState = PR_LIFECYCLE_STATE.READY_STATE_NEEDS_COPILOT_REQUEST;
103
112
  } else if (interpretation.state === STATE.INTERNAL_TOOLING_DIRECT_GATE) {
104
113
  routingState = "internal_tooling_skip_copilot";
105
114
  }
@@ -137,6 +146,7 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
137
146
  repo: undefined,
138
147
  pr: undefined,
139
148
  watchStatus: undefined,
149
+ lightweight: false,
140
150
  jq: undefined,
141
151
  silent: false,
142
152
  };
@@ -147,6 +157,7 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
147
157
  repo: { type: "string" },
148
158
  pr: { type: "string" },
149
159
  "watch-status": { type: "string" },
160
+ lightweight: { type: "boolean" },
150
161
  ...JQ_OUTPUT_PARSE_OPTIONS,
151
162
  },
152
163
  allowPositionals: true,
@@ -183,14 +194,11 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
183
194
  options.watchStatus = watchStatus;
184
195
  continue;
185
196
  }
186
- if (token.name === "jq") {
187
- options.jq = requireTokenValue(token, parseError);
188
- continue;
189
- }
190
- if (token.name === "silent") {
191
- options.silent = true;
197
+ if (token.name === "lightweight") {
198
+ options.lightweight = true;
192
199
  continue;
193
200
  }
201
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
194
202
  throw parseError(`Unknown argument: ${token.rawName}`);
195
203
  }
196
204
  if (options.pr === undefined) {
@@ -300,12 +308,32 @@ export async function detectRecentHumanComments({ repo, pr, claimedAtMs }, { env
300
308
  }
301
309
  }
302
310
 
311
+ // Facts needed by the round-cap escape-hatch significant-change detector
312
+ // (#1103, #1126): the current head, the Copilot reviews (to find the last
313
+ // reviewed head), and the PR's changed files. Fetched only when the interpreter
314
+ // already resolved ROUND_CAP_CLEAN_FALLBACK, so this extra call is off the hot path.
315
+ async function fetchReopenCycleFacts({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
316
+ const result = await runChild(
317
+ ghCommand,
318
+ ["pr", "view", String(pr), "--repo", repo, "--json", "headRefOid,reviews,files"],
319
+ env,
320
+ );
321
+ if (result.code !== 0) {
322
+ return null;
323
+ }
324
+ try {
325
+ return parseJsonText(result.stdout, { label: "gh pr view reopen-cycle facts" });
326
+ } catch {
327
+ return null;
328
+ }
329
+ }
330
+
303
331
  export async function runHandoff(options, { env = process.env, ghCommand = "gh" } = {}) {
304
332
  const runnerOwnership = await ensureAsyncRunnerOwnership({
305
333
  repo: options.repo,
306
334
  pr: options.pr,
307
335
  env,
308
- cwd: path.resolve(process.cwd()),
336
+ cwd: resolveRepoRoot(process.cwd()),
309
337
  claimIfMissing: true,
310
338
  });
311
339
  if (!runnerOwnership.ok) {
@@ -337,13 +365,22 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
337
365
  { repo: options.repo, pr: options.pr },
338
366
  { env, ghCommand },
339
367
  );
340
- const config = await loadDevLoopConfig({ repoRoot: path.resolve(process.cwd()) });
368
+ const config = await loadDevLoopConfig({ repoRoot: resolveRepoRoot(process.cwd()) });
341
369
  if (config.errors?.length > 0) {
342
370
  console.error("[copilot-pr-handoff] config warnings:", JSON.stringify(config.errors));
343
371
  }
344
372
  const refinementConfig = config.errors?.length > 0
345
373
  ? resolveRefinement({ version: 1 })
346
374
  : resolveRefinement(config.config);
375
+ if (options.lightweight) {
376
+ // Compose (not replace) the round cap for light-dispatched PRs (#1210):
377
+ // min(lightMode.maxCopilotRounds ?? 1, refinement.maxCopilotRounds), so
378
+ // maxCopilotRounds: 0 still disables Copilot rounds everywhere.
379
+ refinementConfig.maxCopilotRounds = resolveEffectiveCopilotRoundCap(
380
+ config.errors?.length > 0 ? { version: 1 } : config.config,
381
+ { lightweight: true },
382
+ );
383
+ }
347
384
  let interpretation = interpretLoopState(snapshot, refinementConfig);
348
385
 
349
386
  // Check for human comments since last subagent action
@@ -358,6 +395,12 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
358
395
  const TERMINAL_STATES = new Set([STATE.NO_PR, STATE.DONE, STATE.BLOCKED_NEEDS_USER_DECISION]);
359
396
  const humanCommentUnavailable = humanCommentCheck.error && !humanCommentCheck.paused;
360
397
  if ((humanCommentCheck.paused || humanCommentUnavailable) && !TERMINAL_STATES.has(interpretation.state)) {
398
+ const runnerRelease = await releaseAsyncRunnerOwnership({
399
+ repo: options.repo,
400
+ pr: options.pr,
401
+ env,
402
+ cwd: resolveRepoRoot(process.cwd()),
403
+ });
361
404
  return {
362
405
  ok: true,
363
406
  action: "stop",
@@ -373,6 +416,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
373
416
  terminal: true,
374
417
  snapshot,
375
418
  runnerOwnership,
419
+ ...(runnerRelease.status !== "skipped_no_async_run_id" ? { runnerRelease } : {}),
376
420
  humanCommentPause: {
377
421
  reason: humanCommentCheck.paused ? "human_comment_detected" : "human_comment_check_unavailable",
378
422
  error: humanCommentCheck.error || undefined,
@@ -438,6 +482,76 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
438
482
  }
439
483
  }
440
484
 
485
+ // In-flight-rerequest race (#1165): the interpreter routes to
486
+ // ROUND_CAP_CLEAN_FALLBACK even when a Copilot review is REQUESTED and pending
487
+ // on the current head, because at the cap it treats any assignment as a stale
488
+ // leftover (copilot-loop-state.mjs). An in-flight request is usually a fresh
489
+ // review genuinely coming (e.g. a force-rerequest for a significant
490
+ // post-convergence change) — but it can also be a stale at-cap assignment
491
+ // mislabeled as requested, as detect-copilot-loop-state.mjs's own fallback
492
+ // notes. Proceeding to pre_approval_gate on a real in-flight review would skip
493
+ // it — the exact gate-integrity gap #1126 closes — and
494
+ // detect-pr-gate-coordination-state gates pre-approval here. The reopen escape
495
+ // hatch below can only recover the wait verdict via a SECOND, fail-silent gh
496
+ // fetch (fetchReopenCycleFacts) whose failure/compare-miss silently drops back
497
+ // to "proceed", diverging from detect (which reused already-validated facts).
498
+ // Fail closed instead: while the in-flight evidence stands, wait for it to
499
+ // resolve rather than stop. If it never lands, a --watch-status refresh
500
+ // (which skips this branch) re-resolves to the clean fallback, so this never
501
+ // dead-ends the loop.
502
+ const reviewRequestInFlight = snapshot.copilotReviewRequestStatus === "requested"
503
+ || snapshot.copilotReviewRequestStatus === "already-requested";
504
+ if (!internalOnlySkipCopilot
505
+ && options.watchStatus === undefined
506
+ && interpretation.state === STATE.ROUND_CAP_CLEAN_FALLBACK
507
+ && reviewRequestInFlight) {
508
+ interpretation = {
509
+ ...interpretation,
510
+ state: STATE.WAITING_FOR_COPILOT_REVIEW,
511
+ nextAction: NEXT_ACTIONS[STATE.WAITING_FOR_COPILOT_REVIEW],
512
+ allowedTransitions: [...(TRANSITIONS[STATE.WAITING_FOR_COPILOT_REVIEW] || [])],
513
+ roundCapCleanEligible: false,
514
+ };
515
+ }
516
+
517
+ // Round-cap escape hatch (#1103, #1126): the interpreter resolves
518
+ // ROUND_CAP_CLEAN_FALLBACK (stop, no re-request) at the cap. But when a
519
+ // SIGNIFICANT post-convergence change (new product/test-logic since the last
520
+ // Copilot review — not doc/comment-only) has landed, a new Copilot cycle is
521
+ // owed. Reopen it here via the SAME shared detector detect-pr-gate-coordination-state
522
+ // uses, so the two agree: both offer a re-request iff (cap reached AND
523
+ // significant post-convergence change). A doc-only change stays at the clean
524
+ // fallback (stop), unchanged.
525
+ let reopenedCapCycle = false;
526
+ if (!internalOnlySkipCopilot
527
+ && options.watchStatus === undefined
528
+ && interpretation.state === STATE.ROUND_CAP_CLEAN_FALLBACK) {
529
+ const reopenFacts = await fetchReopenCycleFacts(options, { env, ghCommand });
530
+ const significant = await detectPostConvergenceSignificantChange(
531
+ {
532
+ repo: options.repo,
533
+ pr: options.pr,
534
+ currentHeadSha: typeof reopenFacts?.headRefOid === "string" ? reopenFacts.headRefOid.trim() : null,
535
+ reviews: reopenFacts?.reviews,
536
+ changedFiles: reopenFacts?.files,
537
+ roundCapReached: true,
538
+ regularCopilotRounds: (snapshot.copilotReviewRoundCount ?? 0) > 0,
539
+ },
540
+ { env, ghCommand },
541
+ );
542
+ if (significant) {
543
+ reopenedCapCycle = true;
544
+ interpretation = {
545
+ ...interpretation,
546
+ state: STATE.READY_TO_REREQUEST_REVIEW,
547
+ nextAction: NEXT_ACTIONS[STATE.READY_TO_REREQUEST_REVIEW],
548
+ allowedTransitions: [...(TRANSITIONS[STATE.READY_TO_REREQUEST_REVIEW] || [])],
549
+ autoRerequestEligible: true,
550
+ roundCapCleanEligible: false,
551
+ };
552
+ }
553
+ }
554
+
441
555
  let reviewRequestStatus;
442
556
  const shouldRequestReview = !internalOnlySkipCopilot && options.watchStatus === undefined
443
557
  && (interpretation.state === STATE.PR_READY_NO_FEEDBACK
@@ -449,12 +563,35 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
449
563
  repo: options.repo,
450
564
  pr: options.pr,
451
565
  sameHeadCleanConverged: interpretation.sameHeadCleanConverged,
566
+ // A reopened cap cycle was decided via the shared significant-change
567
+ // detector; tell the requester to honor it. performCopilotReviewRequest
568
+ // still refuses unless the head actually advanced past the last review
569
+ // (its hasNewCommits guard), so this cannot force an over-cap same-head request.
570
+ forceRerequestReview: reopenedCapCycle,
571
+ // Thread the light-dispatch signal so the requester enforces the same
572
+ // composed round cap this handoff resolved (#1210) — the enforcement
573
+ // backstop must never permit rounds the interpreter already forbids.
574
+ lightweight: options.lightweight,
452
575
  },
453
576
  { env, ghCommand },
454
577
  );
455
578
  reviewRequestStatus = requestResult.status;
456
579
  snapshot = applyConfirmedReviewRequest(snapshot, reviewRequestStatus);
457
580
  interpretation = interpretLoopState(snapshot, refinementConfig);
581
+ // The re-interpretation re-hits the round cap (rounds still >= max) and would
582
+ // flip a reopened cycle back to ROUND_CAP_CLEAN_FALLBACK. A confirmed request
583
+ // for a significant new change is a genuine new wait cycle, so map it to the
584
+ // honest WAITING_FOR_COPILOT_REVIEW state (what a below-cap re-request yields).
585
+ if (reopenedCapCycle
586
+ && (reviewRequestStatus === "requested" || reviewRequestStatus === "already-requested")) {
587
+ interpretation = {
588
+ ...interpretation,
589
+ state: STATE.WAITING_FOR_COPILOT_REVIEW,
590
+ nextAction: NEXT_ACTIONS[STATE.WAITING_FOR_COPILOT_REVIEW],
591
+ allowedTransitions: [...(TRANSITIONS[STATE.WAITING_FOR_COPILOT_REVIEW] || [])],
592
+ roundCapCleanEligible: false,
593
+ };
594
+ }
458
595
  }
459
596
  const interpretationSummary = summarizeLoopInterpretation(interpretation, refinementConfig);
460
597
  const effectiveReviewRequestStatus = reviewRequestStatus
@@ -517,6 +654,17 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
517
654
  requestStatus: normalizedRequestStatus,
518
655
  watchArgs: result.watchArgs,
519
656
  });
657
+ if (result.terminal === true) {
658
+ const runnerRelease = await releaseAsyncRunnerOwnership({
659
+ repo: options.repo,
660
+ pr: options.pr,
661
+ env,
662
+ cwd: resolveRepoRoot(process.cwd()),
663
+ });
664
+ if (runnerRelease.status !== "skipped_no_async_run_id") {
665
+ result.runnerRelease = runnerRelease;
666
+ }
667
+ }
520
668
  return result;
521
669
  }
522
670
  export async function runCli(
@@ -24,6 +24,7 @@ import { DebtSignalSchema } from "@dev-loops/core/debt/signal";
24
24
  import { clusterSignalsEnriched } from "@dev-loops/core/debt/cluster";
25
25
  import { shapeFindings } from "@dev-loops/core/debt/shape";
26
26
  import { createRemediationIssue } from "@dev-loops/core/debt/remediation-to-issue";
27
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
27
28
 
28
29
  const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
29
30
 
@@ -45,9 +46,12 @@ Optional:
45
46
  Output (stdout, JSON):
46
47
  { "ok": true, "signals": N, "findings": N, "remediationItems": N, "issues": [...], "summary": "..." }
47
48
 
49
+ ${JQ_OUTPUT_USAGE}
50
+
48
51
  Exit codes:
49
52
  0 Success (all remediation issue creations succeeded)
50
- 1 Argument error, input validation failure, or issue creation failure`.trim();
53
+ 1 Argument error, input validation failure, or issue creation failure
54
+ 2 Invalid --jq filter`.trim();
51
55
 
52
56
  const parseError = buildParseError(USAGE);
53
57
 
@@ -160,6 +164,7 @@ export async function runCli(argv) {
160
164
  input: { type: "string" },
161
165
  repo: { type: "string" },
162
166
  "dry-run": { type: "boolean" },
167
+ ...JQ_OUTPUT_PARSE_OPTIONS,
163
168
  },
164
169
  allowPositionals: true,
165
170
  strict: false,
@@ -188,6 +193,7 @@ export async function runCli(argv) {
188
193
  options.dryRun = true;
189
194
  continue;
190
195
  }
196
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
191
197
  throw parseError(`Unknown flag: ${token.rawName}`);
192
198
  }
193
199
 
@@ -196,6 +202,14 @@ export async function runCli(argv) {
196
202
  return { exitCode: 0 };
197
203
  }
198
204
 
205
+ // Every early-exit failure below reports via the shared jq/silent contract on
206
+ // stderr (this script's existing error-output convention); `ok:false` maps to
207
+ // the existing exit 1 by default, and an invalid --jq filter still fails
208
+ // closed with exit 2 instead of being swallowed by this fixed exit code.
209
+ const failEarly = (payload) => ({
210
+ exitCode: emitResult(payload, { jq: options.jq, silent: options.silent, stdout: process.stderr, stderr: process.stderr }),
211
+ });
212
+
199
213
  if (!options.input) {
200
214
  throw parseError("Missing required flag: --input <path>");
201
215
  }
@@ -208,20 +222,20 @@ export async function runCli(argv) {
208
222
  try {
209
223
  rawInput = await readFile(inputPath, "utf-8");
210
224
  } catch (err) {
211
- return { exitCode: 1, output: { ok: false, error: `Cannot read input file: ${inputPath}`, detail: err.message } };
225
+ return failEarly({ ok: false, error: `Cannot read input file: ${inputPath}`, detail: err.message });
212
226
  }
213
227
 
214
228
  let signals;
215
229
  try {
216
230
  signals = JSON.parse(rawInput);
217
231
  } catch (err) {
218
- return { exitCode: 1, output: { ok: false, error: "Input file is not valid JSON", detail: err.message } };
232
+ return failEarly({ ok: false, error: "Input file is not valid JSON", detail: err.message });
219
233
  }
220
234
 
221
235
  // Validate signals
222
236
  const validation = validateSignals(signals);
223
237
  if (!validation.ok) {
224
- return { exitCode: 1, output: validation };
238
+ return failEarly(validation);
225
239
  }
226
240
 
227
241
  // Resolve repo
@@ -230,14 +244,14 @@ export async function runCli(argv) {
230
244
  try {
231
245
  repo = parseRepoSlug(options.repo);
232
246
  } catch {
233
- return { exitCode: 1, output: { ok: false, error: `Invalid repo slug: ${options.repo}` } };
247
+ return failEarly({ ok: false, error: `Invalid repo slug: ${options.repo}` });
234
248
  }
235
249
  } else {
236
250
  repo = detectRepo();
237
251
  }
238
252
 
239
253
  if (!repo) {
240
- return { exitCode: 1, output: { ok: false, error: "Cannot detect repository. Pass --repo <owner/name>." } };
254
+ return failEarly({ ok: false, error: "Cannot detect repository. Pass --repo <owner/name>." });
241
255
  }
242
256
 
243
257
  // Run pipeline: cluster → score → shape
@@ -298,8 +312,9 @@ export async function runCli(argv) {
298
312
  report.repo = `${repo.owner}/${repo.name}`;
299
313
 
300
314
  const outputTarget = report.ok ? process.stdout : process.stderr;
301
- outputTarget.write(JSON.stringify(report) + "\n");
302
- return { exitCode: anyIssueFailed ? 1 : 0 };
315
+ return {
316
+ exitCode: emitResult(report, { jq: options.jq, silent: options.silent, stdout: outputTarget, stderr: process.stderr }),
317
+ };
303
318
  }
304
319
 
305
320
  // ============================================================================
@@ -307,10 +322,7 @@ export async function runCli(argv) {
307
322
  // ============================================================================
308
323
 
309
324
  if (isDirectCliRun(import.meta.url)) {
310
- runCli(process.argv.slice(2)).then(({ exitCode, output }) => {
311
- if (output) {
312
- process.stderr.write(JSON.stringify(output) + "\n");
313
- }
325
+ runCli(process.argv.slice(2)).then(({ exitCode }) => {
314
326
  process.exitCode = exitCode;
315
327
  }).catch((err) => {
316
328
  process.stderr.write(`${formatCliError(err)}\n`);