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