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
@@ -21,7 +21,8 @@ import {
21
21
  } from "@dev-loops/core/loop/async-start-contract";
22
22
  import { detectRepoSlug } from "@dev-loops/core/github/repo-slug";
23
23
  import { isCopilotLogin } from "@dev-loops/core/github/copilot-helpers";
24
- import { loadDevLoopConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
24
+ import { loadDevLoopConfig, resolveLightMode, resolveWorkflowConfig } from "@dev-loops/core/config";
25
+ import { detectScope } from "./detect-change-scope.mjs";
25
26
  import { createPiAdapter } from "@dev-loops/core/harness";
26
27
  import { validatePlanFile } from "../refine/validate-plan-file.mjs";
27
28
  import {
@@ -34,7 +35,10 @@ import {
34
35
  PLAN_FILE_REFINEMENT_SECTIONS,
35
36
  } from "@dev-loops/core/loop/plan-file-intake-contract";
36
37
  import { evaluateSpikeIntakeState } from "@dev-loops/core/loop/spike-intake-contract";
38
+ import { loadBoardConfig } from "@dev-loops/core/loop/queue-board-sync";
39
+ import { main as reconcileQueue } from "../projects/reconcile-queue.mjs";
37
40
  import { parseArgs } from "node:util";
41
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
38
42
  const USAGE = `Usage:
39
43
  resolve-dev-loop-startup.mjs --issue <number>
40
44
  resolve-dev-loop-startup.mjs --pr <number>
@@ -54,9 +58,31 @@ Required (exactly one):
54
58
  --input <path> Path to a JSON file with canonical-state payload
55
59
  --plan-file <path> Path to a phase-doc-format plan to start locally
56
60
  --spike <path> Path to a spike artifact to start a spike loop locally
61
+ Optional modifier:
62
+ --lightweight With --issue: use the PR body as the spec-of-record
63
+ (canonicalSpecSource: pr_body) — no phase/plan doc minted or
64
+ committed. Same gate sequence; only the backing artifact
65
+ differs. Rejected with --plan-file (its opposite). The secondary
66
+ heuristic (chore/fix commit type + no --plan-file + small change)
67
+ is a documented manual signal; --lightweight is the explicit,
68
+ deterministic trigger.
69
+ Used ALONE (no --issue/--pr/--input/--plan-file/--spike):
70
+ issue-less PR-first (#1210) — no tracker binding at all
71
+ (canonicalSpecSource: pr_body, no issue-keyed worktree
72
+ requirement). Gated on localImplementation.lightMode being
73
+ enabled AND the live change scope (git diff) staying within
74
+ its maxFiles/maxLines threshold; fails closed with a distinct
75
+ reason (light mode disabled / scope undetectable / over
76
+ threshold) requiring --issue above the threshold.
77
+ ${JQ_OUTPUT_USAGE}
78
+
57
79
  Exit codes:
58
80
  0 Success
59
- 1 Argument error, runtime failure, or async-start contract rejection`.trim();
81
+ 1 Argument error, runtime failure, or async-start contract rejection
82
+ 2 Invalid --jq filter`.trim();
83
+ // Upper bound on the awaited best-effort startup reconcile so a slow or hung gh
84
+ // can never delay startup completion.
85
+ const STARTUP_RECONCILE_BUDGET_MS = 20000;
60
86
  const SHARED_PUBLIC_CONTRACT = "skills/docs/public-dev-loop-contract.md";
61
87
  const SHARED_RETROSPECTIVE_CONTRACT = "skills/docs/retrospective-checkpoint-contract.md";
62
88
  const STRATEGY_REQUIRED_READS = {
@@ -123,6 +149,7 @@ export function parseResolveDevLoopStartupCliArgs(argv) {
123
149
  pr: undefined,
124
150
  planFile: undefined,
125
151
  spike: undefined,
152
+ lightweight: false,
126
153
  };
127
154
  const { tokens } = parseArgs({
128
155
  args: [...argv],
@@ -133,6 +160,8 @@ export function parseResolveDevLoopStartupCliArgs(argv) {
133
160
  pr: { type: "string" },
134
161
  "plan-file": { type: "string" },
135
162
  spike: { type: "string" },
163
+ lightweight: { type: "boolean" },
164
+ ...JQ_OUTPUT_PARSE_OPTIONS,
136
165
  },
137
166
  allowPositionals: true,
138
167
  strict: false,
@@ -169,14 +198,34 @@ export function parseResolveDevLoopStartupCliArgs(argv) {
169
198
  options.spike = requireTokenValue(token, parseError);
170
199
  continue;
171
200
  }
201
+ if (token.name === "lightweight") {
202
+ options.lightweight = true;
203
+ continue;
204
+ }
205
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
172
206
  throw parseError(`Unknown argument: ${token.rawName}`);
173
207
  }
174
208
  const modeCount = [options.inputPath, options.issue, options.pr, options.planFile, options.spike].filter(v => v !== undefined).length;
175
209
  if (modeCount > 1) {
176
210
  throw parseError("--issue, --pr, --input, --plan-file, and --spike are mutually exclusive; provide exactly one");
177
211
  }
178
- if (modeCount === 0) {
179
- throw parseError("--input <path>, --issue <n>, --pr <n>, --plan-file <path>, or --spike <path> is required");
212
+ // --lightweight is normally a MODIFIER (not a 6th mode): it makes the PR body
213
+ // the spec-of-record for the --issue local path. Used ALONE (modeCount === 0,
214
+ // issue #1210) it is instead the issue-less PR-first trigger — no tracker
215
+ // binding at all — so the "no mode selected" error is skipped in that case.
216
+ if (modeCount === 0 && !options.lightweight) {
217
+ throw parseError("--input <path>, --issue <n>, --pr <n>, --plan-file <path>, --spike <path>, or --lightweight (issue-less PR-first) is required");
218
+ }
219
+ // --lightweight is the opposite of --plan-file (which commits a durable plan
220
+ // doc as the spec) regardless of mode.
221
+ if (options.lightweight && options.planFile !== undefined) {
222
+ 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.");
223
+ }
224
+ // When another mode IS selected, --lightweight only composes with --issue
225
+ // (not --pr/--input/--spike). Used with no mode selected at all, it is the
226
+ // issue-less PR-first trigger handled above.
227
+ if (options.lightweight && modeCount > 0 && options.issue === undefined) {
228
+ throw parseError("--lightweight is a modifier for the --issue path (the PR body becomes the spec-of-record). Combine it with --issue <n>, or use --lightweight alone (no other mode flag) for the issue-less PR-first path.");
180
229
  }
181
230
  return options;
182
231
  }
@@ -264,7 +313,7 @@ function resolveTargetPreference(cwd) {
264
313
  } catch {
265
314
  }
266
315
  }
267
- return "prefer_github_first";
316
+ return "prefer_local";
268
317
  }
269
318
  function normalizeConfigInputSource(value) {
270
319
  if (value === "phase-docs") return "phase-docs";
@@ -511,6 +560,120 @@ export function buildSpikeInput({ spikeFilePath }) {
511
560
  },
512
561
  };
513
562
  }
563
+ // Candidate default-branch refs for issue-less scope measurement, in preference
564
+ // order. origin/HEAD tracks the remote default branch when set; the rest cover
565
+ // the common names.
566
+ const ISSUELESS_BASE_REF_CANDIDATES = ["origin/HEAD", "origin/main", "origin/master", "main", "master"];
567
+
568
+ function resolveIssuelessMergeBase(cwd) {
569
+ for (const ref of ISSUELESS_BASE_REF_CANDIDATES) {
570
+ try {
571
+ return execFileSync("git", ["merge-base", ref, "HEAD"], {
572
+ cwd,
573
+ encoding: "utf8",
574
+ stdio: ["ignore", "pipe", "pipe"],
575
+ }).trim();
576
+ } catch {
577
+ // try the next candidate
578
+ }
579
+ }
580
+ return null;
581
+ }
582
+
583
+ /**
584
+ * Decide whether the live change scope is eligible for issue-less lightweight
585
+ * PR-first (#1210): reuses the same localImplementation.lightMode threshold
586
+ * that gates inline vs full-fanout gate dispatch, so "genuinely small" means
587
+ * the same thing everywhere in the repo.
588
+ *
589
+ * Scope is measured from the merge-base with the default branch to the WORKING
590
+ * TREE (`git diff --stat <merge-base>`), so multi-commit branches and
591
+ * uncommitted changes are both counted — a HEAD~1-only measure would fail OPEN
592
+ * on a branch whose earlier commits already exceed the threshold. Fails CLOSED
593
+ * on every negative path (disabled / no resolvable base / undetectable diff /
594
+ * over threshold) with a distinct reason so the caller can report why --issue
595
+ * is required instead of silently defaulting one way or the other.
596
+ *
597
+ * Every git invocation is bound to `cwd` (the adapter-resolved repoRoot),
598
+ * matching the rest of this file's git calls — process.cwd() is never relied
599
+ * on implicitly so this stays correct when invoked from a subdirectory or
600
+ * with a harness cwd that differs from the OS process cwd.
601
+ *
602
+ * @param {import("@dev-loops/core/config").DevLoopConfig} config
603
+ * @param {string} [cwd] - Repo root to run git commands in (defaults to process.cwd() when omitted).
604
+ * @returns {{ eligible: true, scope: object, threshold: {maxFiles:number,maxLines:number} } | { eligible: false, reason: "light_mode_disabled"|"scope_detection_failed"|"over_threshold", scope?: object, threshold?: object, detail?: string }}
605
+ */
606
+ export function resolveIssuelessLightweightEligibility(config, cwd) {
607
+ const threshold = resolveLightMode(config);
608
+ if (!threshold) {
609
+ return { eligible: false, reason: "light_mode_disabled" };
610
+ }
611
+ const mergeBase = resolveIssuelessMergeBase(cwd);
612
+ if (mergeBase === null) {
613
+ return {
614
+ eligible: false,
615
+ reason: "scope_detection_failed",
616
+ detail: `no merge-base with any default-branch candidate (${ISSUELESS_BASE_REF_CANDIDATES.join(", ")})`,
617
+ };
618
+ }
619
+ // detectScope with only `base` diffs base..working-tree in one measure
620
+ // (committed branch delta + uncommitted changes).
621
+ const scope = detectScope({ base: mergeBase, cwd });
622
+ if (scope.ok === false) {
623
+ return { eligible: false, reason: "scope_detection_failed", detail: scope.error };
624
+ }
625
+ if (scope.filesChanged > threshold.maxFiles || scope.linesChanged > threshold.maxLines) {
626
+ return { eligible: false, reason: "over_threshold", scope, threshold };
627
+ }
628
+ return { eligible: true, scope, threshold };
629
+ }
630
+
631
+ /**
632
+ * Build a `--lightweight` startup input with NO tracker binding at all
633
+ * (issue-less PR-first, #1210): `--lightweight` used alone, no --issue.
634
+ *
635
+ * Read-only: no tracker mutation, no GitHub calls, no issue/PR number. Gated
636
+ * by {@link resolveIssuelessLightweightEligibility} — an ineligible change
637
+ * throws so the CLI fails closed (exit 1, no readiness bundle) with a message
638
+ * naming the distinct reason, mirroring buildPlanFileInput/buildSpikeInput's
639
+ * fail-closed-on-invalid-input convention. Exempt from the worktree-isolation
640
+ * guard like the plan-file/spike paths: there is no issue number to key a
641
+ * worktree on.
642
+ *
643
+ * @param {{ config: import("@dev-loops/core/config").DevLoopConfig, cwd?: string }} params
644
+ * @returns {object} startup input with canonicalSpecSource: "pr_body"
645
+ */
646
+ export function buildLightweightIssuelessInput({ config, cwd }) {
647
+ const eligibility = resolveIssuelessLightweightEligibility(config, cwd);
648
+ if (!eligibility.eligible) {
649
+ if (eligibility.reason === "light_mode_disabled") {
650
+ throw new Error("--lightweight without --issue (issue-less PR-first) requires localImplementation.lightMode.enabled in .devloops; enable light mode or provide --issue <n>.");
651
+ }
652
+ if (eligibility.reason === "scope_detection_failed") {
653
+ throw new Error(`--lightweight without --issue (issue-less PR-first) requires a measurable change scope; git diff failed (${eligibility.detail}). Provide --issue <n> instead.`);
654
+ }
655
+ throw new Error(`--lightweight without --issue (issue-less PR-first) requires the change to stay within the light-mode threshold (maxFiles=${eligibility.threshold.maxFiles}, maxLines=${eligibility.threshold.maxLines}); this change is ${eligibility.scope.filesChanged} files / ${eligibility.scope.linesChanged} lines. Provide --issue <n> for above-threshold changes.`);
656
+ }
657
+ return {
658
+ intent: "start_issue_locally",
659
+ mode: "bounded_handoff",
660
+ targetPreference: "prefer_local",
661
+ artifactState: "not_applicable",
662
+ issueLinkageResolution: "not_applicable",
663
+ issueReadiness: "not_applicable",
664
+ issueAssignmentState: "not_applicable",
665
+ loopState: "implementation_pending",
666
+ canonicalSpecSource: "pr_body",
667
+ planFileExempt: true,
668
+ currentState: {
669
+ target: { kind: "local_phase", issue: null, pr: null, linkedPr: null, branch: null, phase: "lightweight-issueless" },
670
+ ownership: "local",
671
+ nextActor: "local",
672
+ status: "active",
673
+ authorization: "authorized",
674
+ },
675
+ };
676
+ }
514
677
  export function summarizeCanonicalState(bundle) {
515
678
  return {
516
679
  target: bundle.canonicalState?.target ?? null,
@@ -541,7 +704,10 @@ export function buildResolveDevLoopStartupResult(input, { adapter = createPiAdap
541
704
  // evaluator does not model. Strip them before evaluation and re-apply them to
542
705
  // the result; `planFileExempt` waives the worktree-isolation guard because a
543
706
  // pre-promotion plan has no issue to key a worktree on.
544
- const { planFileExempt = false, planFileIntakeState = null, spikeIntakeState = null, ...routingInput } = input;
707
+ // `canonicalSpecSource` (issue #1025) is a resolver-only field the pure routing
708
+ // evaluator does not model — strip it before evaluation and re-attach to the
709
+ // result, mirroring planFileIntakeState/spikeIntakeState.
710
+ const { planFileExempt = false, planFileIntakeState = null, spikeIntakeState = null, canonicalSpecSource = null, ...routingInput } = input;
545
711
  input = routingInput;
546
712
  try {
547
713
  const checkpointText = readFileSync(
@@ -652,6 +818,7 @@ export function buildResolveDevLoopStartupResult(input, { adapter = createPiAdap
652
818
  canonicalStateSummary: summarizeCanonicalState(bundle),
653
819
  ...(planFileIntakeState !== null ? { planFileIntakeState } : {}),
654
820
  ...(spikeIntakeState !== null ? { spikeIntakeState } : {}),
821
+ ...(canonicalSpecSource !== null ? { canonicalSpecSource } : {}),
655
822
  bundle,
656
823
  };
657
824
  }
@@ -672,7 +839,7 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
672
839
  ? devLoopConfig?.strategy?.default === "local-first"
673
840
  ? "prefer_local"
674
841
  : "prefer_github_first"
675
- : "prefer_github_first";
842
+ : "prefer_local";
676
843
  const inputSource = configErrors.length === 0
677
844
  ? normalizeConfigInputSource(devLoopConfig?.inputSource?.default)
678
845
  : "tracker";
@@ -693,6 +860,7 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
693
860
  delete parsed.planFileExempt;
694
861
  delete parsed.planFileIntakeState;
695
862
  delete parsed.spikeIntakeState;
863
+ delete parsed.canonicalSpecSource;
696
864
  }
697
865
  input = parsed;
698
866
  } else if (options.issue !== undefined) {
@@ -702,6 +870,19 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
702
870
  targetPreference,
703
871
  inputSource,
704
872
  });
873
+ // --lightweight modifier (issue #1025): the PR body becomes the
874
+ // spec-of-record for this local session — no phase/plan doc minted.
875
+ if (options.lightweight) {
876
+ input = { ...input, canonicalSpecSource: "pr_body" };
877
+ }
878
+ } else if (options.lightweight) {
879
+ // --lightweight used ALONE (no other mode flag): issue-less PR-first (#1210).
880
+ // A broken config must surface as ITS OWN failure, not decay to the
881
+ // misleading light_mode_disabled reason a bare {version:1} would produce.
882
+ if (configErrors.length > 0) {
883
+ throw new Error(`--lightweight without --issue (issue-less PR-first) requires a loadable dev-loop config, but config loading failed: ${configErrors.map((e) => e?.message ?? String(e)).join("; ")}. Fix the config or provide --issue <n>.`);
884
+ }
885
+ input = buildLightweightIssuelessInput({ config: devLoopConfig, cwd: repoRoot });
705
886
  } else {
706
887
  input = buildAutoResolvedInput({
707
888
  pr: options.pr,
@@ -711,11 +892,36 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
711
892
  }
712
893
  const result = buildResolveDevLoopStartupResult(input, { asyncStartMode, adapter });
713
894
  if (result.ok === false) {
714
- stderr.write(`${JSON.stringify(result)}\n`);
715
- process.exitCode = 1;
895
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout: stderr, stderr });
716
896
  return;
717
897
  }
718
- stdout.write(`${JSON.stringify(result)}\n`);
898
+ // Emit the deterministic bundle FIRST, before the best-effort self-heal below,
899
+ // so a slow or hung gh reconcile can never delay the startup result.
900
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
901
+ // #1069: best-effort startup self-heal — converge the board from live GitHub
902
+ // state so merged→Done / ready→In Progress land deterministically. Gated on a
903
+ // configured board so it never shells out to gh in the no-.devloops unit tests;
904
+ // never writes stdout, never changes exit code, never throws. Skips
905
+ // --input/--plan-file/--spike modes.
906
+ if (options.issue !== undefined || options.pr !== undefined) {
907
+ let reconcileRoot = sessionCwd;
908
+ try {
909
+ reconcileRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
910
+ cwd: sessionCwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
911
+ }).trim();
912
+ } catch { /* fall back to sessionCwd */ }
913
+ if (loadBoardConfig(reconcileRoot).enabled === true) {
914
+ try {
915
+ // The budget bounds startup latency; reconcile is best-effort and the
916
+ // board self-heals on the next entry if it doesn't finish. The timer is
917
+ // unref'd so it never keeps the event loop alive on its own.
918
+ await Promise.race([
919
+ reconcileQueue({ repo: detectRepoSlug(reconcileRoot) }, { env: adapter.getEnv(), cwd: reconcileRoot, skipTerminalColumn: true }),
920
+ new Promise((resolve) => { const t = setTimeout(resolve, STARTUP_RECONCILE_BUDGET_MS); t.unref?.(); }),
921
+ ]);
922
+ } catch { /* best-effort */ }
923
+ }
924
+ }
719
925
  }
720
926
  if (isDirectCliRun(import.meta.url)) {
721
927
  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",
@@ -78,6 +81,7 @@ export function parseCliArgs(argv) {
78
81
  options: {
79
82
  help: { type: "boolean", short: "h" },
80
83
  repo: { type: "string" },
84
+ ...JQ_OUTPUT_PARSE_OPTIONS,
81
85
  },
82
86
  allowPositionals: true,
83
87
  strict: false,
@@ -98,6 +102,7 @@ export function parseCliArgs(argv) {
98
102
  options.repo = requireTokenValue(token, parseError).trim();
99
103
  continue;
100
104
  }
105
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
101
106
  throw parseError(`Unknown argument: ${token.rawName}`);
102
107
  }
103
108
  if (options.repo === undefined) {
@@ -280,6 +285,7 @@ export async function runCli(
280
285
  argv = process.argv.slice(2),
281
286
  {
282
287
  stdout = process.stdout,
288
+ stderr = process.stderr,
283
289
  env = process.env,
284
290
  ghCommand = "gh",
285
291
  cwd = process.cwd(),
@@ -295,7 +301,7 @@ export async function runCli(
295
301
  ghCommand,
296
302
  repoRoot: cwd,
297
303
  });
298
- stdout.write(`${JSON.stringify(result)}\n`);
304
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
299
305
  }
300
306
  if (isDirectCliRun(import.meta.url)) {
301
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;