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
@@ -34,7 +34,10 @@ import {
34
34
  PLAN_FILE_REFINEMENT_SECTIONS,
35
35
  } from "@dev-loops/core/loop/plan-file-intake-contract";
36
36
  import { evaluateSpikeIntakeState } from "@dev-loops/core/loop/spike-intake-contract";
37
+ import { loadBoardConfig } from "@dev-loops/core/loop/queue-board-sync";
38
+ import { main as reconcileQueue } from "../projects/reconcile-queue.mjs";
37
39
  import { parseArgs } from "node:util";
40
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
38
41
  const USAGE = `Usage:
39
42
  resolve-dev-loop-startup.mjs --issue <number>
40
43
  resolve-dev-loop-startup.mjs --pr <number>
@@ -54,9 +57,23 @@ Required (exactly one):
54
57
  --input <path> Path to a JSON file with canonical-state payload
55
58
  --plan-file <path> Path to a phase-doc-format plan to start locally
56
59
  --spike <path> Path to a spike artifact to start a spike loop locally
60
+ Optional modifier:
61
+ --lightweight With --issue: use the PR body as the spec-of-record
62
+ (canonicalSpecSource: pr_body) — no phase/plan doc minted or
63
+ committed. Same gate sequence; only the backing artifact
64
+ differs. Rejected with --plan-file (its opposite). The secondary
65
+ heuristic (chore/fix commit type + no --plan-file + small change)
66
+ is a documented manual signal; --lightweight is the explicit,
67
+ deterministic trigger.
68
+ ${JQ_OUTPUT_USAGE}
69
+
57
70
  Exit codes:
58
71
  0 Success
59
- 1 Argument error, runtime failure, or async-start contract rejection`.trim();
72
+ 1 Argument error, runtime failure, or async-start contract rejection
73
+ 2 Invalid --jq filter`.trim();
74
+ // Upper bound on the awaited best-effort startup reconcile so a slow or hung gh
75
+ // can never delay startup completion.
76
+ const STARTUP_RECONCILE_BUDGET_MS = 20000;
60
77
  const SHARED_PUBLIC_CONTRACT = "skills/docs/public-dev-loop-contract.md";
61
78
  const SHARED_RETROSPECTIVE_CONTRACT = "skills/docs/retrospective-checkpoint-contract.md";
62
79
  const STRATEGY_REQUIRED_READS = {
@@ -123,6 +140,7 @@ export function parseResolveDevLoopStartupCliArgs(argv) {
123
140
  pr: undefined,
124
141
  planFile: undefined,
125
142
  spike: undefined,
143
+ lightweight: false,
126
144
  };
127
145
  const { tokens } = parseArgs({
128
146
  args: [...argv],
@@ -133,6 +151,8 @@ export function parseResolveDevLoopStartupCliArgs(argv) {
133
151
  pr: { type: "string" },
134
152
  "plan-file": { type: "string" },
135
153
  spike: { type: "string" },
154
+ lightweight: { type: "boolean" },
155
+ ...JQ_OUTPUT_PARSE_OPTIONS,
136
156
  },
137
157
  allowPositionals: true,
138
158
  strict: false,
@@ -169,6 +189,11 @@ export function parseResolveDevLoopStartupCliArgs(argv) {
169
189
  options.spike = requireTokenValue(token, parseError);
170
190
  continue;
171
191
  }
192
+ if (token.name === "lightweight") {
193
+ options.lightweight = true;
194
+ continue;
195
+ }
196
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
172
197
  throw parseError(`Unknown argument: ${token.rawName}`);
173
198
  }
174
199
  const modeCount = [options.inputPath, options.issue, options.pr, options.planFile, options.spike].filter(v => v !== undefined).length;
@@ -178,6 +203,15 @@ export function parseResolveDevLoopStartupCliArgs(argv) {
178
203
  if (modeCount === 0) {
179
204
  throw parseError("--input <path>, --issue <n>, --pr <n>, --plan-file <path>, or --spike <path> is required");
180
205
  }
206
+ // --lightweight is a MODIFIER (not a 6th mode): it makes the PR body the
207
+ // spec-of-record for the --issue local path. It is the opposite of --plan-file
208
+ // (which commits a durable plan doc as the spec) and only composes with --issue.
209
+ if (options.lightweight && options.planFile !== undefined) {
210
+ throw parseError("--lightweight and --plan-file are opposites: --plan-file commits a durable plan doc as the spec-of-record, --lightweight makes the PR body the spec. Provide only one.");
211
+ }
212
+ if (options.lightweight && options.issue === undefined) {
213
+ throw parseError("--lightweight is a modifier for the --issue path (the PR body becomes the spec-of-record). Combine it with --issue <n>.");
214
+ }
181
215
  return options;
182
216
  }
183
217
  function ghJson(args, cwd) {
@@ -264,7 +298,7 @@ function resolveTargetPreference(cwd) {
264
298
  } catch {
265
299
  }
266
300
  }
267
- return "prefer_github_first";
301
+ return "prefer_local";
268
302
  }
269
303
  function normalizeConfigInputSource(value) {
270
304
  if (value === "phase-docs") return "phase-docs";
@@ -541,7 +575,10 @@ export function buildResolveDevLoopStartupResult(input, { adapter = createPiAdap
541
575
  // evaluator does not model. Strip them before evaluation and re-apply them to
542
576
  // the result; `planFileExempt` waives the worktree-isolation guard because a
543
577
  // pre-promotion plan has no issue to key a worktree on.
544
- const { planFileExempt = false, planFileIntakeState = null, spikeIntakeState = null, ...routingInput } = input;
578
+ // `canonicalSpecSource` (issue #1025) is a resolver-only field the pure routing
579
+ // evaluator does not model — strip it before evaluation and re-attach to the
580
+ // result, mirroring planFileIntakeState/spikeIntakeState.
581
+ const { planFileExempt = false, planFileIntakeState = null, spikeIntakeState = null, canonicalSpecSource = null, ...routingInput } = input;
545
582
  input = routingInput;
546
583
  try {
547
584
  const checkpointText = readFileSync(
@@ -652,6 +689,7 @@ export function buildResolveDevLoopStartupResult(input, { adapter = createPiAdap
652
689
  canonicalStateSummary: summarizeCanonicalState(bundle),
653
690
  ...(planFileIntakeState !== null ? { planFileIntakeState } : {}),
654
691
  ...(spikeIntakeState !== null ? { spikeIntakeState } : {}),
692
+ ...(canonicalSpecSource !== null ? { canonicalSpecSource } : {}),
655
693
  bundle,
656
694
  };
657
695
  }
@@ -672,7 +710,7 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
672
710
  ? devLoopConfig?.strategy?.default === "local-first"
673
711
  ? "prefer_local"
674
712
  : "prefer_github_first"
675
- : "prefer_github_first";
713
+ : "prefer_local";
676
714
  const inputSource = configErrors.length === 0
677
715
  ? normalizeConfigInputSource(devLoopConfig?.inputSource?.default)
678
716
  : "tracker";
@@ -693,6 +731,7 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
693
731
  delete parsed.planFileExempt;
694
732
  delete parsed.planFileIntakeState;
695
733
  delete parsed.spikeIntakeState;
734
+ delete parsed.canonicalSpecSource;
696
735
  }
697
736
  input = parsed;
698
737
  } else if (options.issue !== undefined) {
@@ -702,6 +741,11 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
702
741
  targetPreference,
703
742
  inputSource,
704
743
  });
744
+ // --lightweight modifier (issue #1025): the PR body becomes the
745
+ // spec-of-record for this local session — no phase/plan doc minted.
746
+ if (options.lightweight) {
747
+ input = { ...input, canonicalSpecSource: "pr_body" };
748
+ }
705
749
  } else {
706
750
  input = buildAutoResolvedInput({
707
751
  pr: options.pr,
@@ -711,11 +755,36 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
711
755
  }
712
756
  const result = buildResolveDevLoopStartupResult(input, { asyncStartMode, adapter });
713
757
  if (result.ok === false) {
714
- stderr.write(`${JSON.stringify(result)}\n`);
715
- process.exitCode = 1;
758
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout: stderr, stderr });
716
759
  return;
717
760
  }
718
- stdout.write(`${JSON.stringify(result)}\n`);
761
+ // Emit the deterministic bundle FIRST, before the best-effort self-heal below,
762
+ // so a slow or hung gh reconcile can never delay the startup result.
763
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
764
+ // #1069: best-effort startup self-heal — converge the board from live GitHub
765
+ // state so merged→Done / ready→In Progress land deterministically. Gated on a
766
+ // configured board so it never shells out to gh in the no-.devloops unit tests;
767
+ // never writes stdout, never changes exit code, never throws. Skips
768
+ // --input/--plan-file/--spike modes.
769
+ if (options.issue !== undefined || options.pr !== undefined) {
770
+ let reconcileRoot = sessionCwd;
771
+ try {
772
+ reconcileRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
773
+ cwd: sessionCwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
774
+ }).trim();
775
+ } catch { /* fall back to sessionCwd */ }
776
+ if (loadBoardConfig(reconcileRoot).enabled === true) {
777
+ try {
778
+ // The budget bounds startup latency; reconcile is best-effort and the
779
+ // board self-heals on the next entry if it doesn't finish. The timer is
780
+ // unref'd so it never keeps the event loop alive on its own.
781
+ await Promise.race([
782
+ reconcileQueue({ repo: detectRepoSlug(reconcileRoot) }, { env: adapter.getEnv(), cwd: reconcileRoot, skipTerminalColumn: true }),
783
+ new Promise((resolve) => { const t = setTimeout(resolve, STARTUP_RECONCILE_BUDGET_MS); t.unref?.(); }),
784
+ ]);
785
+ } catch { /* best-effort */ }
786
+ }
787
+ }
719
788
  }
720
789
  if (isDirectCliRun(import.meta.url)) {
721
790
  runCli().catch((error) => {
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ import process from "node:process";
3
+ import { parseArgs } from "node:util";
4
+ import {
5
+ loadDevLoopConfig,
6
+ resolveGateDispatchMode,
7
+ GATE_FULL_LABEL,
8
+ } from "@dev-loops/core/config";
9
+ import { detectScope } from "./detect-change-scope.mjs";
10
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
11
+
12
+ const USAGE = `Usage: resolve-gate-dispatch.mjs --gate <draft|preApproval> [--base <ref>] [--head <ref>] [--full-label] [--inline-severities <csv>]
13
+ Decide inline vs full fan-out for a gate from lightMode config + PR facts.
14
+ Options:
15
+ --gate <draft|preApproval> Gate to resolve dispatch for (required)
16
+ --base <ref> Base ref for scope detection (default: HEAD~1)
17
+ --head <ref> Head ref; ignored unless --base is also set
18
+ --full-label PR has the ${GATE_FULL_LABEL} label (forces full fan-out)
19
+ --inline-severities <csv> Comma-separated severities from the inline pass (escalation phase)
20
+ --help, -h Show this help
21
+ Output (stdout, JSON):
22
+ { "ok": true, "gate": "draft", "scope": { "ok": true, "filesChanged": 1, "linesChanged": 5 }, "mode": "inline", "reason": "under_threshold", "threshold": { "maxFiles": 2, "maxLines": 20 } }
23
+ { "ok": true, "gate": "draft", "scope": { "ok": true, "filesChanged": 9, "linesChanged": 300 }, "mode": "full_fanout", "reason": "over_threshold", "threshold": { "maxFiles": 2, "maxLines": 20 } }
24
+ { "ok": true, "gate": "draft", "scope": { "ok": false, ... }, "mode": "full_fanout", "reason": "scope_detection_failed", "threshold": null }
25
+ Error output (stderr, JSON):
26
+ { "ok": false, "error": "...", "usage"?: "..." }
27
+
28
+ ${JQ_OUTPUT_USAGE}
29
+
30
+ Exit codes:
31
+ 0 Success
32
+ 1 Error
33
+ 2 Invalid --jq filter
34
+ `;
35
+
36
+ const VALID_GATES = new Set(["draft", "preApproval"]);
37
+
38
+ /** Parse a comma-separated severity list. Returns `undefined` when the flag is absent, otherwise a trimmed array (which may be empty `[]` for empty/whitespace-only input). */
39
+ export function parseSeverities(csv) {
40
+ if (csv == null) return undefined;
41
+ const list = String(csv)
42
+ .split(",")
43
+ .map((s) => s.trim())
44
+ .filter((s) => s.length > 0);
45
+ return list.length > 0 ? list : [];
46
+ }
47
+
48
+ function parseCliArgs(argv) {
49
+ const { values } = parseArgs({
50
+ args: [...argv],
51
+ options: {
52
+ gate: { type: "string" },
53
+ base: { type: "string" },
54
+ head: { type: "string" },
55
+ "full-label": { type: "boolean", default: false },
56
+ "inline-severities": { type: "string" },
57
+ help: { type: "boolean", short: "h" },
58
+ ...JQ_OUTPUT_PARSE_OPTIONS,
59
+ },
60
+ allowPositionals: true,
61
+ strict: true,
62
+ });
63
+ if (values.help) {
64
+ process.stdout.write(USAGE);
65
+ process.exit(0);
66
+ }
67
+ if (!values.gate || !VALID_GATES.has(values.gate)) {
68
+ throw new Error("--gate must be one of: draft, preApproval");
69
+ }
70
+ return {
71
+ gate: values.gate,
72
+ base: values.base ?? null,
73
+ head: values.head ?? null,
74
+ hasFullLabel: Boolean(values["full-label"]),
75
+ inlineFindingSeverities: parseSeverities(values["inline-severities"]),
76
+ jq: values.jq,
77
+ silent: values.silent === true,
78
+ };
79
+ }
80
+
81
+ export async function run(argv) {
82
+ let opts;
83
+ try {
84
+ opts = parseCliArgs(argv);
85
+ } catch (err) {
86
+ process.stderr.write(
87
+ JSON.stringify({ ok: false, error: err.message, usage: USAGE.trim() }) + "\n"
88
+ );
89
+ process.exitCode = 1;
90
+ return;
91
+ }
92
+ try {
93
+ const { config } = await loadDevLoopConfig({ repoRoot: process.cwd() });
94
+ const scope = detectScope({ base: opts.base, head: opts.head });
95
+ // Fail CLOSED on unmeasurable scope: a broken/failed diff must route to the
96
+ // full gate, never silently collapse to inline (which would bypass review).
97
+ if (scope.ok === false) {
98
+ process.exitCode = emitResult({
99
+ ok: true,
100
+ gate: opts.gate,
101
+ scope,
102
+ mode: "full_fanout",
103
+ reason: "scope_detection_failed",
104
+ threshold: null,
105
+ }, { jq: opts.jq, silent: opts.silent });
106
+ return;
107
+ }
108
+ const decision = resolveGateDispatchMode(config, opts.gate, {
109
+ scope,
110
+ hasFullLabel: opts.hasFullLabel,
111
+ inlineFindingSeverities: opts.inlineFindingSeverities,
112
+ });
113
+ process.exitCode = emitResult(
114
+ { ok: true, gate: opts.gate, scope, ...decision },
115
+ { jq: opts.jq, silent: opts.silent },
116
+ );
117
+ } catch (err) {
118
+ process.stderr.write(
119
+ JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }) + "\n"
120
+ );
121
+ process.exitCode = 1;
122
+ }
123
+ }
124
+
125
+ const isDirectRun =
126
+ process.argv[1] && process.argv[1].includes("resolve-gate-dispatch.mjs");
127
+ if (isDirectRun) {
128
+ run(process.argv.slice(2)).catch((err) => {
129
+ process.stderr.write(
130
+ JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }) + "\n"
131
+ );
132
+ process.exitCode = 1;
133
+ });
134
+ }
@@ -6,6 +6,7 @@ import { parseArgs } from "node:util";
6
6
 
7
7
  import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
8
8
  import { requireTokenValue } from "../_cli-primitives.mjs";
9
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
9
10
 
10
11
  const USAGE = `Usage: resolve-pr-conflicts.mjs [--base <branch>] [--repo-root <dir>] [--no-verify] [--push] [--json]
11
12
 
@@ -36,9 +37,14 @@ Output (stdout, JSON with --json):
36
37
  Error output:
37
38
  { "ok": false, "error": "...", "conflictFiles": ["..."] }
38
39
 
40
+ ${JQ_OUTPUT_USAGE}
41
+ (--jq/--silent apply to the JSON error output always, and to the success output
42
+ with --json; the default non-JSON success summary is unaffected.)
43
+
39
44
  Exit codes:
40
45
  0 Clean merge (incl. already up to date) or auto-resolved CHANGELOG
41
- 1 Argument error, git failure, or UNRESOLVABLE conflict (fail closed)`.trim();
46
+ 1 Argument error, git failure, or UNRESOLVABLE conflict (fail closed)
47
+ 2 Invalid --jq filter`.trim();
42
48
 
43
49
  const parseError = buildParseError(USAGE);
44
50
 
@@ -70,6 +76,7 @@ export function parseResolvePrConflictsCliArgs(argv) {
70
76
  "no-verify": { type: "boolean" },
71
77
  push: { type: "boolean" },
72
78
  json: { type: "boolean" },
79
+ ...JQ_OUTPUT_PARSE_OPTIONS,
73
80
  },
74
81
  allowPositionals: true,
75
82
  strict: false,
@@ -101,8 +108,10 @@ export function parseResolvePrConflictsCliArgs(argv) {
101
108
  case "json":
102
109
  options.json = true;
103
110
  break;
104
- default:
111
+ default: {
112
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) break;
105
113
  throw parseError(`Unknown argument: ${token.rawName}`);
114
+ }
106
115
  }
107
116
  }
108
117
  return options;
@@ -337,18 +346,16 @@ export async function runCli(argv, { stdout = process.stdout, stderr = process.s
337
346
  try {
338
347
  const result = await resolvePrConflicts(options, { env });
339
348
  if (options.json) {
340
- stdout.write(`${JSON.stringify(result)}\n`);
341
- } else {
342
- stdout.write(`${result.action} (base ${result.base}${result.resolvedFiles.length ? `, resolved ${result.resolvedFiles.join(", ")}` : ""}${result.pushed ? ", pushed" : ""})\n`);
349
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
343
350
  }
351
+ stdout.write(`${result.action} (base ${result.base}${result.resolvedFiles.length ? `, resolved ${result.resolvedFiles.join(", ")}` : ""}${result.pushed ? ", pushed" : ""})\n`);
344
352
  return 0;
345
353
  } catch (error) {
346
354
  const payload = { ok: false, error: error instanceof Error ? error.message : String(error) };
347
355
  if (Array.isArray(error?.conflictFiles)) {
348
356
  payload.conflictFiles = error.conflictFiles;
349
357
  }
350
- stderr.write(`${JSON.stringify(payload)}\n`);
351
- return 1;
358
+ return emitResult(payload, { jq: options.jq, silent: options.silent, stdout: stderr, stderr });
352
359
  }
353
360
  }
354
361
 
@@ -15,9 +15,12 @@ import {
15
15
  } from "./_handoff-contract.mjs";
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
  export { listOpenPrs };
19
20
  const USAGE = `Usage: run-conductor-cycle.mjs --repo <owner/name>
20
- Poll all open PRs, detect state, and output an ordered action queue.`.trim();
21
+ Poll all open PRs, detect state, and output an ordered action queue.
22
+
23
+ ${JQ_OUTPUT_USAGE}`.trim();
21
24
  export const CHECKPOINT_ACTION_TO_CONDUCTOR_ACTION = Object.freeze({
22
25
  [PR_CHECKPOINT_ACTION.ADDRESS_REVIEW_FEEDBACK]: "fix_threads",
23
26
  [PR_CHECKPOINT_ACTION.REPLY_RESOLVE_REVIEW_THREADS]: "fix_threads",
@@ -32,11 +35,16 @@ export const CHECKPOINT_ACTION_TO_CONDUCTOR_ACTION = Object.freeze({
32
35
  [PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY]: "merge",
33
36
  [PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL]: "await_approval",
34
37
  [PR_CHECKPOINT_ACTION.RESOLVE_MERGE_CONFLICTS]: "resolve_conflicts",
38
+ [PR_CHECKPOINT_ACTION.RUN_UI_E2E_SUITE]: "run_ui_e2e",
35
39
  [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]: "blocked",
36
40
  [PR_CHECKPOINT_ACTION.REPORT_DONE]: "done",
37
41
  });
38
42
  export const ACTION_PRIORITY = Object.freeze({
39
43
  merge: 100,
44
+ // UI e2e auto-scoping fix work (#976): a path-triggered, fail-closed
45
+ // precondition — surface it ahead of feedback/draft work so the rendered
46
+ // artifact gets registered + covered before the gate proceeds.
47
+ run_ui_e2e: 95,
40
48
  fix_threads: 90,
41
49
  run_pre_approval: 80,
42
50
  draft_gate: 70,
@@ -73,6 +81,7 @@ export function parseCliArgs(argv) {
73
81
  options: {
74
82
  help: { type: "boolean", short: "h" },
75
83
  repo: { type: "string" },
84
+ ...JQ_OUTPUT_PARSE_OPTIONS,
76
85
  },
77
86
  allowPositionals: true,
78
87
  strict: false,
@@ -93,6 +102,7 @@ export function parseCliArgs(argv) {
93
102
  options.repo = requireTokenValue(token, parseError).trim();
94
103
  continue;
95
104
  }
105
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
96
106
  throw parseError(`Unknown argument: ${token.rawName}`);
97
107
  }
98
108
  if (options.repo === undefined) {
@@ -275,6 +285,7 @@ export async function runCli(
275
285
  argv = process.argv.slice(2),
276
286
  {
277
287
  stdout = process.stdout,
288
+ stderr = process.stderr,
278
289
  env = process.env,
279
290
  ghCommand = "gh",
280
291
  cwd = process.cwd(),
@@ -290,7 +301,7 @@ export async function runCli(
290
301
  ghCommand,
291
302
  repoRoot: cwd,
292
303
  });
293
- stdout.write(`${JSON.stringify(result)}\n`);
304
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
294
305
  }
295
306
  if (isDirectCliRun(import.meta.url)) {
296
307
  runCli().catch((error) => {
@@ -21,6 +21,11 @@ import { readQueue } from "@dev-loops/core/loop/queue-state";
21
21
  import { reconcileBoardMembership } from "@dev-loops/core/loop/queue-membership";
22
22
  import { parsePositiveInteger } from "@dev-loops/core/cli/primitives";
23
23
  import { loadDevLoopConfig, resolveEffectiveMergeAuthorizedFromLoad } from "@dev-loops/core/config";
24
+ import {
25
+ REASON_NEXT_UP_EMPTY,
26
+ REASON_BOARD_QUERY_ERROR,
27
+ EMPTY_NEXT_UP_MESSAGE,
28
+ } from "@dev-loops/core/loop/queue-board-ordering";
24
29
 
25
30
  const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
26
31
 
@@ -129,24 +134,28 @@ async function main() {
129
134
  // resolution failure (which falls through to the local queue below).
130
135
  console.log(JSON.stringify({
131
136
  ok: true,
132
- message: "Board configured but Next Up is empty; nothing to run",
137
+ // Canonical empty-Next-Up outcome matches queue-driver.mjs so operators
138
+ // see one message regardless of which layer detects it.
139
+ message: EMPTY_NEXT_UP_MESSAGE,
133
140
  boardConfigured: true,
134
- reason: null,
141
+ reason: REASON_NEXT_UP_EMPTY,
135
142
  results: [],
136
143
  }));
137
144
  return;
138
145
  }
139
146
 
140
147
  if (membership.emptiness === "board_unavailable") {
141
- // The board IS configured but Next Up resolution failed (fail-open) and the
142
- // local queue had nothing to fall back to. Do NOT claim "Next Up is empty";
143
- // surface the real reason so consumers can distinguish an outage from an
144
- // intentionally empty board.
148
+ // The board IS configured but Next Up resolution failed and the local queue
149
+ // had nothing to fall back to. Align with the driver's canonical fail-closed
150
+ // board-query-error outcome (reason + framing) so operators see one shape
151
+ // regardless of which layer detects the outage — an outage halts the run
152
+ // rather than draining Backlog/local order.
145
153
  console.log(JSON.stringify({
146
- ok: true,
147
- message: `Board configured but unavailable (${membership.reason}); nothing to run`,
154
+ ok: false,
155
+ stopped: true,
156
+ reason: REASON_BOARD_QUERY_ERROR,
157
+ message: `Next Up query failed (${membership.reason}); refusing to fall back to Backlog/local order — nothing to run`,
148
158
  boardConfigured: true,
149
- reason: membership.reason ?? null,
150
159
  results: [],
151
160
  }));
152
161
  return;
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
5
5
  import { parsePositiveInteger, requireTokenValue, runCommand } from "../_cli-primitives.mjs";
6
6
  import { parseArgs } from "node:util";
7
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
7
8
  const DEFAULT_MAX_LINES = 1000;
8
9
  const DEFAULT_DUPLICATE_WINDOW_LINES = 4;
9
10
  const DEFAULT_BRANCH_THRESHOLD = 25;
@@ -35,7 +36,9 @@ Success output (stdout, JSON):
35
36
  }
36
37
  Failure behavior (stderr, JSON, exit 1):
37
38
  - malformed arguments, blank paths, invalid thresholds, and zero auditable files fail closed
38
- - findings are not a process failure; findings still return exit 0`.trim();
39
+ - findings are not a process failure; findings still return exit 0
40
+
41
+ ${JQ_OUTPUT_USAGE}`.trim();
39
42
  const parseError = buildParseError(USAGE);
40
43
  const BRANCH_TOKEN_PATTERN = /\b(?:if|else|switch|case|for|while|catch|finally|break|continue)\b|&&|\|\||\?(?![?.])/gu;
41
44
  const PRIORITY_ORDER = new Map([
@@ -67,6 +70,7 @@ export function parseRefinementAuditCliArgs(argv) {
67
70
  "branch-threshold": { type: "string" },
68
71
  "thin-wrapper-max-lines": { type: "string" },
69
72
  output: { type: "string" },
73
+ ...JQ_OUTPUT_PARSE_OPTIONS,
70
74
  },
71
75
  allowPositionals: true,
72
76
  strict: false,
@@ -127,6 +131,7 @@ export function parseRefinementAuditCliArgs(argv) {
127
131
  options.output = requireTokenValue(token, parseError, { flagPattern: /^-/u });
128
132
  continue;
129
133
  }
134
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
130
135
  throw parseError(`Unknown argument: ${token.rawName}`);
131
136
  }
132
137
  if (options.paths !== undefined && options.pathsFile !== undefined) {
@@ -512,22 +517,16 @@ export async function runCli(
512
517
  fullRepoScan: false,
513
518
  },
514
519
  };
515
- const serializedPayload = `${JSON.stringify(payload)}\n`;
516
520
  if (typeof options.output === "string") {
517
521
  const outputPath = path.resolve(cwd, options.output);
518
522
  await mkdir(path.dirname(outputPath), { recursive: true });
519
- await writeFile(outputPath, serializedPayload, "utf8");
523
+ await writeFile(outputPath, `${JSON.stringify(payload)}\n`, "utf8");
520
524
  }
521
- stdout.write(serializedPayload);
525
+ process.exitCode = emitResult(payload, { jq: options.jq, silent: options.silent, stdout, stderr });
522
526
  return payload;
523
527
  }
524
528
  if (isDirectCliRun(import.meta.url)) {
525
529
  runCli()
526
- .then((result) => {
527
- if (result?.ok === false) {
528
- process.exitCode = 1;
529
- }
530
- })
531
530
  .catch((error) => {
532
531
  process.stderr.write(`${formatCliError(error)}\n`);
533
532
  process.exitCode = 1;
@@ -15,7 +15,7 @@ import {
15
15
  EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY,
16
16
  enforceExternalHealthyWaitTimeout,
17
17
  } from "@dev-loops/core/loop/timeout-policy";
18
- import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
18
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
19
19
  const REMOVED_FLAGS = new Set([
20
20
  "--force-rerequest-review",
21
21
  "--probe-only",
@@ -254,14 +254,7 @@ export function parseWatchCycleCliArgs(argv) {
254
254
  options.concise = true;
255
255
  continue;
256
256
  }
257
- if (token.name === "jq") {
258
- options.jq = requireTokenValue(token, parseError);
259
- continue;
260
- }
261
- if (token.name === "silent") {
262
- options.silent = true;
263
- continue;
264
- }
257
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
265
258
  throw parseError(`Unknown argument: ${token.rawName}`);
266
259
  }
267
260
  if (options.repo === undefined || options.pr === undefined) {