dev-loops 0.5.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 (177) 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/loop-auto.md +7 -0
  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/loop-info.md +7 -0
  9. package/.claude/commands/loop-queue-status.md +24 -0
  10. package/.claude/commands/loop-start-spike.md +16 -0
  11. package/.claude/commands/loop-start.md +7 -0
  12. package/.claude/commands/loop-status.md +6 -0
  13. package/.claude/hooks/_bash-command-classify.mjs +333 -29
  14. package/.claude/hooks/_hook-decisions.mjs +138 -15
  15. package/.claude/hooks/_run-context.mjs +11 -4
  16. package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
  17. package/.claude/skills/copilot-pr-followup/SKILL.md +24 -12
  18. package/.claude/skills/dev-loop/SKILL.md +19 -10
  19. package/.claude/skills/docs/acceptance-criteria-verification.md +6 -2
  20. package/.claude/skills/docs/anti-patterns.md +2 -1
  21. package/.claude/skills/docs/artifact-authority-contract.md +22 -4
  22. package/.claude/skills/docs/copilot-loop-operations.md +1 -1
  23. package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
  24. package/.claude/skills/docs/issue-intake-procedure.md +1 -0
  25. package/.claude/skills/docs/local-planning-flow.md +1 -1
  26. package/.claude/skills/docs/local-planning-worked-example.md +1 -1
  27. package/.claude/skills/docs/merge-preconditions.md +17 -1
  28. package/.claude/skills/docs/plan-file-contract.md +1 -1
  29. package/.claude/skills/docs/public-dev-loop-contract.md +1 -1
  30. package/.claude/skills/docs/release-runbook.md +45 -0
  31. package/.claude/skills/docs/retrospective-checkpoint-contract.md +90 -76
  32. package/.claude/skills/docs/ui-e2e-scoping-step.md +134 -0
  33. package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
  34. package/.claude/skills/local-implementation/SKILL.md +26 -15
  35. package/.claude/skills/loop-grill/SKILL.md +163 -0
  36. package/AGENTS.md +1 -0
  37. package/CHANGELOG.md +125 -0
  38. package/README.md +29 -10
  39. package/agents/dev-loop.agent.md +11 -3
  40. package/agents/developer.agent.md +1 -1
  41. package/agents/docs.agent.md +1 -1
  42. package/agents/fixer.agent.md +1 -1
  43. package/agents/quality.agent.md +1 -1
  44. package/agents/refiner.agent.md +1 -1
  45. package/agents/review.agent.md +3 -2
  46. package/cli/index.mjs +35 -40
  47. package/extension/README.md +4 -4
  48. package/extension/checks.ts +1 -5
  49. package/extension/harness-types.ts +1 -0
  50. package/extension/index.ts +16 -1
  51. package/extension/pi-extension-adapter.ts +2 -0
  52. package/extension/presentation.ts +16 -13
  53. package/lib/dev-loops-core.mjs +141 -0
  54. package/package.json +10 -8
  55. package/scripts/claude/generate-claude-assets.mjs +15 -1
  56. package/scripts/github/capture-review-threads.mjs +2 -9
  57. package/scripts/github/comment-issue.mjs +174 -0
  58. package/scripts/github/create-label.mjs +133 -0
  59. package/scripts/github/detect-checkpoint-evidence.mjs +170 -14
  60. package/scripts/github/detect-linked-issue-pr.mjs +22 -6
  61. package/scripts/github/edit-pr.mjs +259 -0
  62. package/scripts/github/fetch-ci-logs.mjs +208 -0
  63. package/scripts/github/list-issues.mjs +184 -0
  64. package/scripts/github/manage-sub-issues.mjs +46 -10
  65. package/scripts/github/offer-human-handoff.mjs +8 -5
  66. package/scripts/github/post-gate-findings.mjs +14 -2
  67. package/scripts/github/probe-ci-status.mjs +8 -2
  68. package/scripts/github/probe-copilot-review.mjs +21 -14
  69. package/scripts/github/ready-for-review.mjs +26 -8
  70. package/scripts/github/reconcile-draft-gate.mjs +7 -3
  71. package/scripts/github/reply-resolve-review-thread.mjs +16 -5
  72. package/scripts/github/reply-resolve-review-threads.mjs +69 -7
  73. package/scripts/github/request-copilot-review.mjs +8 -2
  74. package/scripts/github/resolve-handoff-candidates.mjs +7 -3
  75. package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
  76. package/scripts/github/stage-reviewer-draft.mjs +10 -2
  77. package/scripts/github/tick-verified-checkboxes.mjs +202 -0
  78. package/scripts/github/upsert-checkpoint-verdict.mjs +31 -10
  79. package/scripts/github/verify-fresh-review-context.mjs +150 -31
  80. package/scripts/github/view-pr.mjs +150 -0
  81. package/scripts/github/write-gate-context.mjs +248 -61
  82. package/scripts/github/write-gate-findings-log.mjs +75 -1
  83. package/scripts/lib/jq-output.mjs +18 -0
  84. package/scripts/loop/_handoff-contract.mjs +1 -0
  85. package/scripts/loop/_post-convergence-change.mjs +211 -0
  86. package/scripts/loop/_pr-runner-coordination.mjs +70 -0
  87. package/scripts/loop/_repo-root-resolver.mjs +47 -0
  88. package/scripts/loop/build-handoff-envelope.mjs +14 -4
  89. package/scripts/loop/check-retro-tooling.mjs +14 -3
  90. package/scripts/loop/checkpoint-contract.mjs +7 -4
  91. package/scripts/loop/cleanup-worktree.mjs +12 -3
  92. package/scripts/loop/conductor-monitor.mjs +12 -18
  93. package/scripts/loop/copilot-pr-handoff.mjs +135 -14
  94. package/scripts/loop/debt-remediate.mjs +24 -12
  95. package/scripts/loop/detect-change-scope.mjs +36 -7
  96. package/scripts/loop/detect-copilot-loop-state.mjs +11 -5
  97. package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
  98. package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
  99. package/scripts/loop/detect-internal-only-pr.mjs +8 -2
  100. package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
  101. package/scripts/loop/detect-pr-gate-coordination-state.mjs +179 -66
  102. package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
  103. package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
  104. package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
  105. package/scripts/loop/ensure-worktree.mjs +8 -3
  106. package/scripts/loop/info.mjs +12 -4
  107. package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
  108. package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
  109. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
  110. package/scripts/loop/inspect-run-viewer.mjs +67 -4
  111. package/scripts/loop/inspect-run.mjs +8 -2
  112. package/scripts/loop/outer-loop.mjs +8 -4
  113. package/scripts/loop/pr-runner-coordination.mjs +2 -9
  114. package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
  115. package/scripts/loop/pre-flight-gate.mjs +9 -9
  116. package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
  117. package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
  118. package/scripts/loop/provision-worktree.mjs +74 -3
  119. package/scripts/loop/resolve-dev-loop-startup.mjs +76 -7
  120. package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
  121. package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
  122. package/scripts/loop/run-conductor-cycle.mjs +13 -2
  123. package/scripts/loop/run-queue.mjs +18 -9
  124. package/scripts/loop/run-refinement-audit.mjs +8 -9
  125. package/scripts/loop/run-watch-cycle.mjs +2 -9
  126. package/scripts/loop/sanctioned-commands.mjs +104 -0
  127. package/scripts/loop/steer-loop.mjs +29 -16
  128. package/scripts/loop/validate-pr-body-spec.mjs +207 -0
  129. package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
  130. package/scripts/pages/build-site.mjs +59 -8
  131. package/scripts/pages/build-state-atlas.mjs +441 -0
  132. package/scripts/projects/_resolve-project.mjs +148 -0
  133. package/scripts/projects/add-queue-item.mjs +60 -54
  134. package/scripts/projects/archive-done-items.mjs +49 -84
  135. package/scripts/projects/ensure-queue-board.mjs +10 -36
  136. package/scripts/projects/list-queue-items.mjs +116 -65
  137. package/scripts/projects/move-queue-item.mjs +28 -49
  138. package/scripts/projects/reconcile-queue.mjs +253 -0
  139. package/scripts/projects/reorder-queue-item.mjs +41 -47
  140. package/scripts/projects/resolve-active-board-item.mjs +255 -0
  141. package/scripts/projects/sync-item-status.mjs +15 -10
  142. package/scripts/refine/_refine-helpers.mjs +21 -5
  143. package/scripts/refine/exit-spike.mjs +18 -8
  144. package/scripts/refine/promote-plan.mjs +22 -16
  145. package/scripts/refine/prose-linkage-detector.mjs +1 -1
  146. package/scripts/refine/refine-plan-file.mjs +13 -4
  147. package/scripts/refine/refinement-completeness-checker.mjs +1 -1
  148. package/scripts/refine/scaffold-spike-file.mjs +179 -0
  149. package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
  150. package/scripts/refine/tree-integrity-validator.mjs +1 -1
  151. package/scripts/refine/validate-plan-file.mjs +1 -1
  152. package/scripts/refine/validate-spike-file.mjs +1 -1
  153. package/scripts/refine/verify.mjs +11 -6
  154. package/scripts/release/assert-core-dependency-version.mjs +123 -0
  155. package/scripts/release/extract-changelog-section.mjs +125 -0
  156. package/skills/copilot-pr-followup/SKILL.md +24 -12
  157. package/skills/dev-loop/SKILL.md +22 -6
  158. package/skills/docs/acceptance-criteria-verification.md +6 -2
  159. package/skills/docs/anti-patterns.md +2 -1
  160. package/skills/docs/artifact-authority-contract.md +22 -4
  161. package/skills/docs/copilot-loop-operations.md +1 -1
  162. package/skills/docs/cross-harness-regression-contract.md +60 -0
  163. package/skills/docs/issue-intake-procedure.md +1 -0
  164. package/skills/docs/local-planning-flow.md +1 -1
  165. package/skills/docs/local-planning-worked-example.md +1 -1
  166. package/skills/docs/merge-preconditions.md +17 -1
  167. package/skills/docs/plan-file-contract.md +1 -1
  168. package/skills/docs/public-dev-loop-contract.md +1 -1
  169. package/skills/docs/release-runbook.md +45 -0
  170. package/skills/docs/retrospective-checkpoint-contract.md +90 -76
  171. package/skills/docs/ui-e2e-scoping-step.md +134 -0
  172. package/skills/docs/workflow-handoff-contract.md +39 -0
  173. package/skills/local-implementation/SKILL.md +26 -15
  174. package/skills/loop-grill/SKILL.md +165 -0
  175. package/scripts/loop/conductor.mjs +0 -233
  176. package/scripts/loop/detect-stale-runner.mjs +0 -265
  177. package/scripts/loop/pre-push-main-guard.mjs +0 -117
@@ -1,15 +1,16 @@
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
7
  import { loadDevLoopConfig, 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 { ensureAsyncRunnerOwnership, releaseAsyncRunnerOwnership } from "./_pr-runner-coordination.mjs";
13
+ import { resolveRepoRoot } from "./_repo-root-resolver.mjs";
13
14
 
14
15
 
15
16
  import {
@@ -17,7 +18,7 @@ import {
17
18
  enforceExternalHealthyWaitTimeout,
18
19
  } from "@dev-loops/core/loop/timeout-policy";
19
20
  import { parseArgs } from "node:util";
20
- import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
21
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
21
22
  import {
22
23
  DEFAULT_POLL_INTERVAL_MS,
23
24
  COPILOT_REVIEW_WAIT_TIMEOUT_MS,
@@ -183,14 +184,7 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
183
184
  options.watchStatus = watchStatus;
184
185
  continue;
185
186
  }
186
- if (token.name === "jq") {
187
- options.jq = requireTokenValue(token, parseError);
188
- continue;
189
- }
190
- if (token.name === "silent") {
191
- options.silent = true;
192
- continue;
193
- }
187
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
194
188
  throw parseError(`Unknown argument: ${token.rawName}`);
195
189
  }
196
190
  if (options.pr === undefined) {
@@ -300,12 +294,32 @@ export async function detectRecentHumanComments({ repo, pr, claimedAtMs }, { env
300
294
  }
301
295
  }
302
296
 
297
+ // Facts needed by the round-cap escape-hatch significant-change detector
298
+ // (#1103, #1126): the current head, the Copilot reviews (to find the last
299
+ // reviewed head), and the PR's changed files. Fetched only when the interpreter
300
+ // already resolved ROUND_CAP_CLEAN_FALLBACK, so this extra call is off the hot path.
301
+ async function fetchReopenCycleFacts({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
302
+ const result = await runChild(
303
+ ghCommand,
304
+ ["pr", "view", String(pr), "--repo", repo, "--json", "headRefOid,reviews,files"],
305
+ env,
306
+ );
307
+ if (result.code !== 0) {
308
+ return null;
309
+ }
310
+ try {
311
+ return parseJsonText(result.stdout, { label: "gh pr view reopen-cycle facts" });
312
+ } catch {
313
+ return null;
314
+ }
315
+ }
316
+
303
317
  export async function runHandoff(options, { env = process.env, ghCommand = "gh" } = {}) {
304
318
  const runnerOwnership = await ensureAsyncRunnerOwnership({
305
319
  repo: options.repo,
306
320
  pr: options.pr,
307
321
  env,
308
- cwd: path.resolve(process.cwd()),
322
+ cwd: resolveRepoRoot(process.cwd()),
309
323
  claimIfMissing: true,
310
324
  });
311
325
  if (!runnerOwnership.ok) {
@@ -337,7 +351,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
337
351
  { repo: options.repo, pr: options.pr },
338
352
  { env, ghCommand },
339
353
  );
340
- const config = await loadDevLoopConfig({ repoRoot: path.resolve(process.cwd()) });
354
+ const config = await loadDevLoopConfig({ repoRoot: resolveRepoRoot(process.cwd()) });
341
355
  if (config.errors?.length > 0) {
342
356
  console.error("[copilot-pr-handoff] config warnings:", JSON.stringify(config.errors));
343
357
  }
@@ -358,6 +372,12 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
358
372
  const TERMINAL_STATES = new Set([STATE.NO_PR, STATE.DONE, STATE.BLOCKED_NEEDS_USER_DECISION]);
359
373
  const humanCommentUnavailable = humanCommentCheck.error && !humanCommentCheck.paused;
360
374
  if ((humanCommentCheck.paused || humanCommentUnavailable) && !TERMINAL_STATES.has(interpretation.state)) {
375
+ const runnerRelease = await releaseAsyncRunnerOwnership({
376
+ repo: options.repo,
377
+ pr: options.pr,
378
+ env,
379
+ cwd: resolveRepoRoot(process.cwd()),
380
+ });
361
381
  return {
362
382
  ok: true,
363
383
  action: "stop",
@@ -373,6 +393,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
373
393
  terminal: true,
374
394
  snapshot,
375
395
  runnerOwnership,
396
+ ...(runnerRelease.status !== "skipped_no_async_run_id" ? { runnerRelease } : {}),
376
397
  humanCommentPause: {
377
398
  reason: humanCommentCheck.paused ? "human_comment_detected" : "human_comment_check_unavailable",
378
399
  error: humanCommentCheck.error || undefined,
@@ -438,6 +459,76 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
438
459
  }
439
460
  }
440
461
 
462
+ // In-flight-rerequest race (#1165): the interpreter routes to
463
+ // ROUND_CAP_CLEAN_FALLBACK even when a Copilot review is REQUESTED and pending
464
+ // on the current head, because at the cap it treats any assignment as a stale
465
+ // leftover (copilot-loop-state.mjs). An in-flight request is usually a fresh
466
+ // review genuinely coming (e.g. a force-rerequest for a significant
467
+ // post-convergence change) — but it can also be a stale at-cap assignment
468
+ // mislabeled as requested, as detect-copilot-loop-state.mjs's own fallback
469
+ // notes. Proceeding to pre_approval_gate on a real in-flight review would skip
470
+ // it — the exact gate-integrity gap #1126 closes — and
471
+ // detect-pr-gate-coordination-state gates pre-approval here. The reopen escape
472
+ // hatch below can only recover the wait verdict via a SECOND, fail-silent gh
473
+ // fetch (fetchReopenCycleFacts) whose failure/compare-miss silently drops back
474
+ // to "proceed", diverging from detect (which reused already-validated facts).
475
+ // Fail closed instead: while the in-flight evidence stands, wait for it to
476
+ // resolve rather than stop. If it never lands, a --watch-status refresh
477
+ // (which skips this branch) re-resolves to the clean fallback, so this never
478
+ // dead-ends the loop.
479
+ const reviewRequestInFlight = snapshot.copilotReviewRequestStatus === "requested"
480
+ || snapshot.copilotReviewRequestStatus === "already-requested";
481
+ if (!internalOnlySkipCopilot
482
+ && options.watchStatus === undefined
483
+ && interpretation.state === STATE.ROUND_CAP_CLEAN_FALLBACK
484
+ && reviewRequestInFlight) {
485
+ interpretation = {
486
+ ...interpretation,
487
+ state: STATE.WAITING_FOR_COPILOT_REVIEW,
488
+ nextAction: NEXT_ACTIONS[STATE.WAITING_FOR_COPILOT_REVIEW],
489
+ allowedTransitions: [...(TRANSITIONS[STATE.WAITING_FOR_COPILOT_REVIEW] || [])],
490
+ roundCapCleanEligible: false,
491
+ };
492
+ }
493
+
494
+ // Round-cap escape hatch (#1103, #1126): the interpreter resolves
495
+ // ROUND_CAP_CLEAN_FALLBACK (stop, no re-request) at the cap. But when a
496
+ // SIGNIFICANT post-convergence change (new product/test-logic since the last
497
+ // Copilot review — not doc/comment-only) has landed, a new Copilot cycle is
498
+ // owed. Reopen it here via the SAME shared detector detect-pr-gate-coordination-state
499
+ // uses, so the two agree: both offer a re-request iff (cap reached AND
500
+ // significant post-convergence change). A doc-only change stays at the clean
501
+ // fallback (stop), unchanged.
502
+ let reopenedCapCycle = false;
503
+ if (!internalOnlySkipCopilot
504
+ && options.watchStatus === undefined
505
+ && interpretation.state === STATE.ROUND_CAP_CLEAN_FALLBACK) {
506
+ const reopenFacts = await fetchReopenCycleFacts(options, { env, ghCommand });
507
+ const significant = await detectPostConvergenceSignificantChange(
508
+ {
509
+ repo: options.repo,
510
+ pr: options.pr,
511
+ currentHeadSha: typeof reopenFacts?.headRefOid === "string" ? reopenFacts.headRefOid.trim() : null,
512
+ reviews: reopenFacts?.reviews,
513
+ changedFiles: reopenFacts?.files,
514
+ roundCapReached: true,
515
+ regularCopilotRounds: (snapshot.copilotReviewRoundCount ?? 0) > 0,
516
+ },
517
+ { env, ghCommand },
518
+ );
519
+ if (significant) {
520
+ reopenedCapCycle = true;
521
+ interpretation = {
522
+ ...interpretation,
523
+ state: STATE.READY_TO_REREQUEST_REVIEW,
524
+ nextAction: NEXT_ACTIONS[STATE.READY_TO_REREQUEST_REVIEW],
525
+ allowedTransitions: [...(TRANSITIONS[STATE.READY_TO_REREQUEST_REVIEW] || [])],
526
+ autoRerequestEligible: true,
527
+ roundCapCleanEligible: false,
528
+ };
529
+ }
530
+ }
531
+
441
532
  let reviewRequestStatus;
442
533
  const shouldRequestReview = !internalOnlySkipCopilot && options.watchStatus === undefined
443
534
  && (interpretation.state === STATE.PR_READY_NO_FEEDBACK
@@ -449,12 +540,31 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
449
540
  repo: options.repo,
450
541
  pr: options.pr,
451
542
  sameHeadCleanConverged: interpretation.sameHeadCleanConverged,
543
+ // A reopened cap cycle was decided via the shared significant-change
544
+ // detector; tell the requester to honor it. performCopilotReviewRequest
545
+ // still refuses unless the head actually advanced past the last review
546
+ // (its hasNewCommits guard), so this cannot force an over-cap same-head request.
547
+ forceRerequestReview: reopenedCapCycle,
452
548
  },
453
549
  { env, ghCommand },
454
550
  );
455
551
  reviewRequestStatus = requestResult.status;
456
552
  snapshot = applyConfirmedReviewRequest(snapshot, reviewRequestStatus);
457
553
  interpretation = interpretLoopState(snapshot, refinementConfig);
554
+ // The re-interpretation re-hits the round cap (rounds still >= max) and would
555
+ // flip a reopened cycle back to ROUND_CAP_CLEAN_FALLBACK. A confirmed request
556
+ // for a significant new change is a genuine new wait cycle, so map it to the
557
+ // honest WAITING_FOR_COPILOT_REVIEW state (what a below-cap re-request yields).
558
+ if (reopenedCapCycle
559
+ && (reviewRequestStatus === "requested" || reviewRequestStatus === "already-requested")) {
560
+ interpretation = {
561
+ ...interpretation,
562
+ state: STATE.WAITING_FOR_COPILOT_REVIEW,
563
+ nextAction: NEXT_ACTIONS[STATE.WAITING_FOR_COPILOT_REVIEW],
564
+ allowedTransitions: [...(TRANSITIONS[STATE.WAITING_FOR_COPILOT_REVIEW] || [])],
565
+ roundCapCleanEligible: false,
566
+ };
567
+ }
458
568
  }
459
569
  const interpretationSummary = summarizeLoopInterpretation(interpretation, refinementConfig);
460
570
  const effectiveReviewRequestStatus = reviewRequestStatus
@@ -517,6 +627,17 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
517
627
  requestStatus: normalizedRequestStatus,
518
628
  watchArgs: result.watchArgs,
519
629
  });
630
+ if (result.terminal === true) {
631
+ const runnerRelease = await releaseAsyncRunnerOwnership({
632
+ repo: options.repo,
633
+ pr: options.pr,
634
+ env,
635
+ cwd: resolveRepoRoot(process.cwd()),
636
+ });
637
+ if (runnerRelease.status !== "skipped_no_async_run_id") {
638
+ result.runnerRelease = runnerRelease;
639
+ }
640
+ }
520
641
  return result;
521
642
  }
522
643
  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`);
@@ -2,6 +2,7 @@
2
2
  import { execFileSync } from "node:child_process";
3
3
  import process from "node:process";
4
4
  import { parseArgs } from "node:util";
5
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
5
6
 
6
7
  const USAGE = `Usage: detect-change-scope.mjs [--base <ref>] [--head <ref>]
7
8
  Detect change scope from git diff for light-mode eligibility.
@@ -9,9 +10,13 @@ Options:
9
10
  --base <ref> Override base ref (default: HEAD~1)
10
11
  --head <ref> Override head ref; ignored unless --base is also set
11
12
  --help, -h Show this help
13
+
14
+ ${JQ_OUTPUT_USAGE}
15
+
12
16
  Exit codes:
13
17
  0 Success
14
18
  1 Error
19
+ 2 Invalid --jq filter
15
20
  `;
16
21
 
17
22
  function parseCliArgs(argv) {
@@ -21,6 +26,7 @@ function parseCliArgs(argv) {
21
26
  base: { type: "string" },
22
27
  head: { type: "string" },
23
28
  help: { type: "boolean", short: "h" },
29
+ ...JQ_OUTPUT_PARSE_OPTIONS,
24
30
  },
25
31
  allowPositionals: true,
26
32
  strict: false,
@@ -43,7 +49,9 @@ function parseCliArgs(argv) {
43
49
  }
44
50
  if (token.name === "head") {
45
51
  opts.head = token.value ?? null;
52
+ continue;
46
53
  }
54
+ if (matchJqOutputToken(token, opts)) continue;
47
55
  }
48
56
  }
49
57
  return opts;
@@ -88,6 +96,27 @@ function detectScope({ base, head } = {}) {
88
96
  function isEligibleForLightMode(scope, threshold) {
89
97
  return scope.filesChanged <= threshold.maxFiles && scope.linesChanged <= threshold.maxLines;
90
98
  }
99
+ /**
100
+ * Detect change scope for the merge-base diff between `base` and `head` (the
101
+ * three-dot `base...head` diff git resolves against merge-base(base, head)).
102
+ *
103
+ * Fails CLOSED: a missing base/head, or any git failure, returns `{ ok: false }`
104
+ * so callers that gate on scope (e.g. the light-mode pre-merge acceptance) reject
105
+ * rather than silently treating an unmeasurable diff as under threshold. Reuses
106
+ * the same `parseGitDiffStat` scope resolution as `detectScope`.
107
+ */
108
+ function detectMergeBaseScope({ base, head, cwd } = {}) {
109
+ if (!base || !head) {
110
+ return { ok: false, filesChanged: 0, linesChanged: 0, error: "base and head are required for merge-base scope detection" };
111
+ }
112
+ let output;
113
+ try {
114
+ output = execFileSync("git", ["diff", "--stat", `${base}...${head}`], { encoding: "utf8", maxBuffer: 1_000_000, cwd: cwd || undefined });
115
+ } catch (err) {
116
+ return { ok: false, filesChanged: 0, linesChanged: 0, error: err instanceof Error ? err.message : String(err) };
117
+ }
118
+ return { ok: true, ...parseGitDiffStat(output) };
119
+ }
91
120
  async function main() {
92
121
  const opts = parseCliArgs(process.argv.slice(2));
93
122
  const scope = detectScope(opts);
@@ -108,12 +137,12 @@ async function main() {
108
137
  }
109
138
  } catch {
110
139
  }
111
- process.stdout.write(
112
- JSON.stringify({
113
- ...scope,
114
- eligibleForLightMode: eligible,
115
- threshold,
116
- }) + "\n"
140
+ // This tool always exits 0 on a parsed run (scope.ok:false only reflects a git
141
+ // diff failure inside the payload, never the process outcome) — force ok:true
142
+ // as emitResult's default so --jq/--silent compose without changing that.
143
+ process.exitCode = emitResult(
144
+ { ...scope, eligibleForLightMode: eligible, threshold },
145
+ { jq: opts.jq, silent: opts.silent, ok: true },
117
146
  );
118
147
  }
119
148
  const isDirectRun =
@@ -124,4 +153,4 @@ if (isDirectRun) {
124
153
  process.exitCode = 1;
125
154
  });
126
155
  }
127
- export { detectScope, isEligibleForLightMode };
156
+ export { detectScope, detectMergeBaseScope, isEligibleForLightMode };
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile } from "node:fs/promises";
3
- import path from "node:path";
4
3
  import { parseArgs } from "node:util";
5
4
  import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
6
5
  import {
@@ -28,6 +27,8 @@ import {
28
27
  normalizeHeadScopedCommitStatus,
29
28
  normalizeHeadScopedCiContract,
30
29
  } from "@dev-loops/core/loop/copilot-ci-status";
30
+ import { resolveRepoRoot } from "./_repo-root-resolver.mjs";
31
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
31
32
  const USAGE = `Usage:
32
33
  detect-copilot-loop-state.mjs --repo <owner/name> --pr <number>
33
34
  detect-copilot-loop-state.mjs --input <path>
@@ -53,9 +54,11 @@ Error output (stderr, JSON):
53
54
  { "ok": false, "error": "...", "usage": "..." }
54
55
  gh/runtime failures:
55
56
  { "ok": false, "error": "..." }
57
+ ${JQ_OUTPUT_USAGE}
56
58
  Exit codes:
57
59
  0 Success
58
- 1 Argument error, gh failure, or indeterminate state`.trim();
60
+ 1 Argument error, gh failure, or indeterminate state
61
+ 2 Invalid --jq filter`.trim();
59
62
  const VALID_OVERRIDE_STATUSES = new Set(["requested", "already-requested", "unavailable", "none", "failed"]);
60
63
  const parseError = buildParseError(USAGE);
61
64
  export function parseDetectCliArgs(argv) {
@@ -72,6 +75,7 @@ export function parseDetectCliArgs(argv) {
72
75
  input: { type: "string" },
73
76
  repo: { type: "string" },
74
77
  pr: { type: "string" },
78
+ ...JQ_OUTPUT_PARSE_OPTIONS,
75
79
  },
76
80
  allowPositionals: true,
77
81
  strict: false,
@@ -100,6 +104,7 @@ export function parseDetectCliArgs(argv) {
100
104
  options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
101
105
  continue;
102
106
  }
107
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
103
108
  throw parseError(`Unknown argument: ${token.rawName}`);
104
109
  }
105
110
  if (options.inputPath !== undefined) {
@@ -419,6 +424,7 @@ export async function runCli(
419
424
  argv = process.argv.slice(2),
420
425
  {
421
426
  stdout = process.stdout,
427
+ stderr = process.stderr,
422
428
  env = process.env,
423
429
  ghCommand = "gh",
424
430
  } = {},
@@ -445,13 +451,13 @@ export async function runCli(
445
451
  interpretationInput = snapshot;
446
452
  }
447
453
  let interpretation;
448
- const config = await loadDevLoopConfig({ repoRoot: path.resolve(process.cwd()) });
454
+ const config = await loadDevLoopConfig({ repoRoot: resolveRepoRoot(process.cwd()) });
449
455
  const refinementConfig = config.errors.length > 0
450
456
  ? resolveRefinement({ version: 1 })
451
457
  : resolveRefinement(config.config);
452
458
  interpretation = interpretLoopState(interpretationInput, refinementConfig);
453
459
  const interpretationSummary = summarizeLoopInterpretation(interpretation);
454
- stdout.write(`${JSON.stringify({
460
+ process.exitCode = emitResult({
455
461
  ok: true,
456
462
  snapshot,
457
463
  state: interpretation.state,
@@ -461,7 +467,7 @@ export async function runCli(
461
467
  sameHeadCleanConverged: interpretation.sameHeadCleanConverged,
462
468
  loopDisposition: interpretationSummary.loopDisposition,
463
469
  terminal: interpretationSummary.terminal,
464
- })}\n`);
470
+ }, { jq: options.jq, silent: options.silent, stdout, stderr });
465
471
  }
466
472
  if (isDirectCliRun(import.meta.url)) {
467
473
  runCli().catch((error) => {
@@ -3,6 +3,7 @@ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "
3
3
  import { parsePositiveInteger, requireTokenValue, runChild } from "../_cli-primitives.mjs";
4
4
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
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
  const USAGE = `Usage: detect-copilot-session-activity.mjs --repo <owner/name> --branch <name> [--limit <number>]
7
8
  Detect Copilot GitHub Actions session activity on a branch.
8
9
  Required:
@@ -32,7 +33,8 @@ Error output (stderr, JSON):
32
33
  Argument/usage errors:
33
34
  { "ok": false, "error": "...", "usage": "..." }
34
35
  gh/runtime failures:
35
- { "ok": false, "error": "..." }`.trim();
36
+ { "ok": false, "error": "..." }
37
+ ${JQ_OUTPUT_USAGE}`.trim();
36
38
  const DEFAULT_LIMIT = 20;
37
39
  const ACTIVE_RUN_STATUSES = new Set(["queued", "in_progress", "pending", "requested", "waiting"]);
38
40
  const COPILOT_RUN_NAME_PATTERNS = Object.freeze([
@@ -55,6 +57,7 @@ export function parseDetectCopilotSessionActivityCliArgs(argv) {
55
57
  repo: { type: "string" },
56
58
  branch: { type: "string" },
57
59
  limit: { type: "string" },
60
+ ...JQ_OUTPUT_PARSE_OPTIONS,
58
61
  },
59
62
  allowPositionals: true,
60
63
  strict: false,
@@ -83,6 +86,7 @@ export function parseDetectCopilotSessionActivityCliArgs(argv) {
83
86
  options.limit = parsePositiveInteger(requireTokenValue(token, parseError), "--limit", parseError);
84
87
  continue;
85
88
  }
89
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
86
90
  throw parseError(`Unknown argument: ${token.rawName}`);
87
91
  }
88
92
  if (options.repo === undefined || options.branch === undefined || options.branch.length === 0) {
@@ -185,7 +189,7 @@ export async function detectCopilotSessionActivity({ repo, branch, limit = DEFAU
185
189
  }
186
190
  export async function runCli(
187
191
  argv = process.argv.slice(2),
188
- { stdout = process.stdout, env = process.env, ghCommand = "gh" } = {},
192
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh" } = {},
189
193
  ) {
190
194
  const options = parseDetectCopilotSessionActivityCliArgs(argv);
191
195
  if (options.help) {
@@ -193,7 +197,7 @@ export async function runCli(
193
197
  return;
194
198
  }
195
199
  const result = await detectCopilotSessionActivity(options, { env, ghCommand });
196
- stdout.write(`${JSON.stringify(result)}\n`);
200
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
197
201
  }
198
202
  if (isDirectCliRun(import.meta.url)) {
199
203
  runCli().catch((error) => {
@@ -5,6 +5,7 @@ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
5
5
  import { detectLinkedIssuePr } from "../github/detect-linked-issue-pr.mjs";
6
6
  import { detectCopilotSessionActivity } from "./detect-copilot-session-activity.mjs";
7
7
  import { parseArgs } from "node:util";
8
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
8
9
  const USAGE = `Usage: detect-initial-copilot-pr-state.mjs --repo <owner/name> --issue <number>
9
10
  Detect whether an assigned issue is still on the bootstrap-only Copilot draft PR
10
11
  or has moved into normal linked-PR follow-up.
@@ -43,7 +44,8 @@ Error output (stderr, JSON):
43
44
  Argument/usage errors:
44
45
  { "ok": false, "error": "...", "usage": "..." }
45
46
  gh/runtime failures:
46
- { "ok": false, "error": "..." }`.trim();
47
+ { "ok": false, "error": "..." }
48
+ ${JQ_OUTPUT_USAGE}`.trim();
47
49
  export const LINKED_PR_STATE = Object.freeze({
48
50
  NO_LINKED_PR: "no_linked_pr",
49
51
  PRIOR_LINKED_PR_CLOSED_UNMERGED: "prior_linked_pr_closed_unmerged",
@@ -91,6 +93,7 @@ export function parseDetectInitialCopilotPrStateCliArgs(argv) {
91
93
  help: { type: "boolean", short: "h" },
92
94
  repo: { type: "string" },
93
95
  issue: { type: "string" },
96
+ ...JQ_OUTPUT_PARSE_OPTIONS,
94
97
  },
95
98
  allowPositionals: true,
96
99
  strict: false,
@@ -115,6 +118,7 @@ export function parseDetectInitialCopilotPrStateCliArgs(argv) {
115
118
  options.issue = parseIssueNumber(requireTokenValue(token, parseError), parseError);
116
119
  continue;
117
120
  }
121
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
118
122
  throw parseError(`Unknown argument: ${token.rawName}`);
119
123
  }
120
124
  if (options.repo === undefined || options.issue === undefined) {
@@ -313,7 +317,7 @@ export async function detectInitialCopilotPrState({ repo, issue }, { env = proce
313
317
  }
314
318
  export async function runCli(
315
319
  argv = process.argv.slice(2),
316
- { stdout = process.stdout, env = process.env, ghCommand = "gh" } = {},
320
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh" } = {},
317
321
  ) {
318
322
  const options = parseDetectInitialCopilotPrStateCliArgs(argv);
319
323
  if (options.help) {
@@ -324,7 +328,7 @@ export async function runCli(
324
328
  { repo: options.repo, issue: options.issue },
325
329
  { env, ghCommand },
326
330
  );
327
- stdout.write(`${JSON.stringify(result)}\n`);
331
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
328
332
  }
329
333
  if (isDirectCliRun(import.meta.url)) {
330
334
  runCli().catch((error) => {
@@ -6,6 +6,7 @@ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helper
6
6
  import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
7
7
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
8
8
  import { parseArgs } from "node:util";
9
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
9
10
 
10
11
  const USAGE = `Usage: detect-internal-only-pr.mjs --repo <owner/name> --pr <number> [--config <path>]
11
12
  Detect whether a PR only touches internal tooling files (scripts, docs, tests, config)
@@ -20,9 +21,11 @@ Optional:
20
21
  Output (stdout, JSON):
21
22
  { "ok": true, "internalOnly": true|false, "files": ["path1", "path2", ...],
22
23
  "reason": "...", "repo": "...", "pr": N }
24
+ ${JQ_OUTPUT_USAGE}
23
25
  Exit codes:
24
26
  0 Success
25
- 1 Argument error or gh failure`.trim();
27
+ 1 Argument error or gh failure
28
+ 2 Invalid --jq filter`.trim();
26
29
 
27
30
  const parseError = buildParseError(USAGE);
28
31
 
@@ -134,6 +137,7 @@ export function parseCliArgs(argv) {
134
137
  pr: { type: "string" },
135
138
  config: { type: "string" },
136
139
  "label-check": { type: "boolean" },
140
+ ...JQ_OUTPUT_PARSE_OPTIONS,
137
141
  },
138
142
  allowPositionals: true,
139
143
  strict: false,
@@ -166,6 +170,7 @@ export function parseCliArgs(argv) {
166
170
  options.labelCheck = true;
167
171
  continue;
168
172
  }
173
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
169
174
  throw parseError(`Unknown argument: ${token.rawName}`);
170
175
  }
171
176
  if (options.repo === undefined || options.pr === undefined) {
@@ -265,6 +270,7 @@ export async function runCli(
265
270
  argv = process.argv.slice(2),
266
271
  {
267
272
  stdout = process.stdout,
273
+ stderr = process.stderr,
268
274
  env = process.env,
269
275
  ghCommand = "gh",
270
276
  } = {},
@@ -275,7 +281,7 @@ export async function runCli(
275
281
  return;
276
282
  }
277
283
  const result = await detectInternalOnly(options, { env, ghCommand });
278
- stdout.write(`${JSON.stringify(result)}\n`);
284
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
279
285
  }
280
286
 
281
287
  if (isDirectCliRun(import.meta.url)) {