dev-loops 0.5.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +2 -1
  3. package/.claude/agents/review.md +2 -1
  4. package/.claude/commands/loop-auto.md +7 -0
  5. package/.claude/commands/loop-continue.md +15 -0
  6. package/.claude/commands/loop-enqueue.md +22 -0
  7. package/.claude/commands/loop-grill.md +17 -0
  8. package/.claude/commands/loop-info.md +7 -0
  9. package/.claude/commands/loop-queue-status.md +24 -0
  10. package/.claude/commands/loop-start-spike.md +16 -0
  11. package/.claude/commands/loop-start.md +7 -0
  12. package/.claude/commands/loop-status.md +6 -0
  13. package/.claude/hooks/_bash-command-classify.mjs +333 -29
  14. package/.claude/hooks/_hook-decisions.mjs +138 -15
  15. package/.claude/hooks/_run-context.mjs +11 -4
  16. package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
  17. package/.claude/skills/copilot-pr-followup/SKILL.md +24 -12
  18. package/.claude/skills/dev-loop/SKILL.md +19 -10
  19. package/.claude/skills/docs/acceptance-criteria-verification.md +6 -2
  20. package/.claude/skills/docs/anti-patterns.md +2 -1
  21. package/.claude/skills/docs/artifact-authority-contract.md +22 -4
  22. package/.claude/skills/docs/copilot-loop-operations.md +1 -1
  23. package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
  24. package/.claude/skills/docs/issue-intake-procedure.md +1 -0
  25. package/.claude/skills/docs/local-planning-flow.md +1 -1
  26. package/.claude/skills/docs/local-planning-worked-example.md +1 -1
  27. package/.claude/skills/docs/merge-preconditions.md +17 -1
  28. package/.claude/skills/docs/plan-file-contract.md +1 -1
  29. package/.claude/skills/docs/public-dev-loop-contract.md +1 -1
  30. package/.claude/skills/docs/release-runbook.md +45 -0
  31. package/.claude/skills/docs/retrospective-checkpoint-contract.md +90 -76
  32. package/.claude/skills/docs/ui-e2e-scoping-step.md +134 -0
  33. package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
  34. package/.claude/skills/local-implementation/SKILL.md +26 -15
  35. package/.claude/skills/loop-grill/SKILL.md +163 -0
  36. package/AGENTS.md +1 -0
  37. package/CHANGELOG.md +125 -0
  38. package/README.md +29 -10
  39. package/agents/dev-loop.agent.md +11 -3
  40. package/agents/developer.agent.md +1 -1
  41. package/agents/docs.agent.md +1 -1
  42. package/agents/fixer.agent.md +1 -1
  43. package/agents/quality.agent.md +1 -1
  44. package/agents/refiner.agent.md +1 -1
  45. package/agents/review.agent.md +3 -2
  46. package/cli/index.mjs +35 -40
  47. package/extension/README.md +4 -4
  48. package/extension/checks.ts +1 -5
  49. package/extension/harness-types.ts +1 -0
  50. package/extension/index.ts +16 -1
  51. package/extension/pi-extension-adapter.ts +2 -0
  52. package/extension/presentation.ts +16 -13
  53. package/lib/dev-loops-core.mjs +141 -0
  54. package/package.json +10 -8
  55. package/scripts/claude/generate-claude-assets.mjs +15 -1
  56. package/scripts/github/capture-review-threads.mjs +2 -9
  57. package/scripts/github/comment-issue.mjs +174 -0
  58. package/scripts/github/create-label.mjs +133 -0
  59. package/scripts/github/detect-checkpoint-evidence.mjs +170 -14
  60. package/scripts/github/detect-linked-issue-pr.mjs +22 -6
  61. package/scripts/github/edit-pr.mjs +259 -0
  62. package/scripts/github/fetch-ci-logs.mjs +208 -0
  63. package/scripts/github/list-issues.mjs +184 -0
  64. package/scripts/github/manage-sub-issues.mjs +46 -10
  65. package/scripts/github/offer-human-handoff.mjs +8 -5
  66. package/scripts/github/post-gate-findings.mjs +14 -2
  67. package/scripts/github/probe-ci-status.mjs +8 -2
  68. package/scripts/github/probe-copilot-review.mjs +21 -14
  69. package/scripts/github/ready-for-review.mjs +26 -8
  70. package/scripts/github/reconcile-draft-gate.mjs +7 -3
  71. package/scripts/github/reply-resolve-review-thread.mjs +16 -5
  72. package/scripts/github/reply-resolve-review-threads.mjs +69 -7
  73. package/scripts/github/request-copilot-review.mjs +8 -2
  74. package/scripts/github/resolve-handoff-candidates.mjs +7 -3
  75. package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
  76. package/scripts/github/stage-reviewer-draft.mjs +10 -2
  77. package/scripts/github/tick-verified-checkboxes.mjs +202 -0
  78. package/scripts/github/upsert-checkpoint-verdict.mjs +31 -10
  79. package/scripts/github/verify-fresh-review-context.mjs +150 -31
  80. package/scripts/github/view-pr.mjs +150 -0
  81. package/scripts/github/write-gate-context.mjs +248 -61
  82. package/scripts/github/write-gate-findings-log.mjs +75 -1
  83. package/scripts/lib/jq-output.mjs +18 -0
  84. package/scripts/loop/_handoff-contract.mjs +1 -0
  85. package/scripts/loop/_post-convergence-change.mjs +211 -0
  86. package/scripts/loop/_pr-runner-coordination.mjs +70 -0
  87. package/scripts/loop/_repo-root-resolver.mjs +47 -0
  88. package/scripts/loop/build-handoff-envelope.mjs +14 -4
  89. package/scripts/loop/check-retro-tooling.mjs +14 -3
  90. package/scripts/loop/checkpoint-contract.mjs +7 -4
  91. package/scripts/loop/cleanup-worktree.mjs +12 -3
  92. package/scripts/loop/conductor-monitor.mjs +12 -18
  93. package/scripts/loop/copilot-pr-handoff.mjs +135 -14
  94. package/scripts/loop/debt-remediate.mjs +24 -12
  95. package/scripts/loop/detect-change-scope.mjs +36 -7
  96. package/scripts/loop/detect-copilot-loop-state.mjs +11 -5
  97. package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
  98. package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
  99. package/scripts/loop/detect-internal-only-pr.mjs +8 -2
  100. package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
  101. package/scripts/loop/detect-pr-gate-coordination-state.mjs +179 -66
  102. package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
  103. package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
  104. package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
  105. package/scripts/loop/ensure-worktree.mjs +8 -3
  106. package/scripts/loop/info.mjs +12 -4
  107. package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
  108. package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
  109. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
  110. package/scripts/loop/inspect-run-viewer.mjs +67 -4
  111. package/scripts/loop/inspect-run.mjs +8 -2
  112. package/scripts/loop/outer-loop.mjs +8 -4
  113. package/scripts/loop/pr-runner-coordination.mjs +2 -9
  114. package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
  115. package/scripts/loop/pre-flight-gate.mjs +9 -9
  116. package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
  117. package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
  118. package/scripts/loop/provision-worktree.mjs +74 -3
  119. package/scripts/loop/resolve-dev-loop-startup.mjs +76 -7
  120. package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
  121. package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
  122. package/scripts/loop/run-conductor-cycle.mjs +13 -2
  123. package/scripts/loop/run-queue.mjs +18 -9
  124. package/scripts/loop/run-refinement-audit.mjs +8 -9
  125. package/scripts/loop/run-watch-cycle.mjs +2 -9
  126. package/scripts/loop/sanctioned-commands.mjs +104 -0
  127. package/scripts/loop/steer-loop.mjs +29 -16
  128. package/scripts/loop/validate-pr-body-spec.mjs +207 -0
  129. package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
  130. package/scripts/pages/build-site.mjs +59 -8
  131. package/scripts/pages/build-state-atlas.mjs +441 -0
  132. package/scripts/projects/_resolve-project.mjs +148 -0
  133. package/scripts/projects/add-queue-item.mjs +60 -54
  134. package/scripts/projects/archive-done-items.mjs +49 -84
  135. package/scripts/projects/ensure-queue-board.mjs +10 -36
  136. package/scripts/projects/list-queue-items.mjs +116 -65
  137. package/scripts/projects/move-queue-item.mjs +28 -49
  138. package/scripts/projects/reconcile-queue.mjs +253 -0
  139. package/scripts/projects/reorder-queue-item.mjs +41 -47
  140. package/scripts/projects/resolve-active-board-item.mjs +255 -0
  141. package/scripts/projects/sync-item-status.mjs +15 -10
  142. package/scripts/refine/_refine-helpers.mjs +21 -5
  143. package/scripts/refine/exit-spike.mjs +18 -8
  144. package/scripts/refine/promote-plan.mjs +22 -16
  145. package/scripts/refine/prose-linkage-detector.mjs +1 -1
  146. package/scripts/refine/refine-plan-file.mjs +13 -4
  147. package/scripts/refine/refinement-completeness-checker.mjs +1 -1
  148. package/scripts/refine/scaffold-spike-file.mjs +179 -0
  149. package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
  150. package/scripts/refine/tree-integrity-validator.mjs +1 -1
  151. package/scripts/refine/validate-plan-file.mjs +1 -1
  152. package/scripts/refine/validate-spike-file.mjs +1 -1
  153. package/scripts/refine/verify.mjs +11 -6
  154. package/scripts/release/assert-core-dependency-version.mjs +123 -0
  155. package/scripts/release/extract-changelog-section.mjs +125 -0
  156. package/skills/copilot-pr-followup/SKILL.md +24 -12
  157. package/skills/dev-loop/SKILL.md +22 -6
  158. package/skills/docs/acceptance-criteria-verification.md +6 -2
  159. package/skills/docs/anti-patterns.md +2 -1
  160. package/skills/docs/artifact-authority-contract.md +22 -4
  161. package/skills/docs/copilot-loop-operations.md +1 -1
  162. package/skills/docs/cross-harness-regression-contract.md +60 -0
  163. package/skills/docs/issue-intake-procedure.md +1 -0
  164. package/skills/docs/local-planning-flow.md +1 -1
  165. package/skills/docs/local-planning-worked-example.md +1 -1
  166. package/skills/docs/merge-preconditions.md +17 -1
  167. package/skills/docs/plan-file-contract.md +1 -1
  168. package/skills/docs/public-dev-loop-contract.md +1 -1
  169. package/skills/docs/release-runbook.md +45 -0
  170. package/skills/docs/retrospective-checkpoint-contract.md +90 -76
  171. package/skills/docs/ui-e2e-scoping-step.md +134 -0
  172. package/skills/docs/workflow-handoff-contract.md +39 -0
  173. package/skills/local-implementation/SKILL.md +26 -15
  174. package/skills/loop-grill/SKILL.md +165 -0
  175. package/scripts/loop/conductor.mjs +0 -233
  176. package/scripts/loop/detect-stale-runner.mjs +0 -265
  177. package/scripts/loop/pre-push-main-guard.mjs +0 -117
@@ -1,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);
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env node
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.
6
+ //
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)
14
+ //
15
+ // Downstream (the dev-loop skill) resolves authoritative state from this number;
16
+ // this helper deliberately makes no further decisions.
17
+ import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
18
+ import { parseArgs } from "node:util";
19
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
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";
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).
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;
32
+
33
+ const USAGE = `Usage: dev-loops queue resolve-active --repo <owner/name> --project <number|id>
34
+
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.
40
+
41
+ Options:
42
+ --repo <owner/name> Required. Repository to scope the project search.
43
+ --project <number|id> Required. Project number (integer) or node ID.
44
+ --help, -h Show this help.
45
+
46
+ Output (stdout):
47
+ JSON, exactly one in-progress item:
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}"):
52
+ { ok: false, reason: "..." }
53
+
54
+ ${JQ_OUTPUT_USAGE}
55
+
56
+ Exit codes (default / unfiltered output):
57
+ 0 — a single continue target resolved (in-progress or "${NEXT_UP_COLUMN}" head)
58
+ 1 — usage or argument error
59
+ 2 — GitHub API error / invalid --jq filter
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)
63
+
64
+ With --jq/--silent the result is filtered to a value/predicate, so the exit code
65
+ follows the shared jq-output contract (0 = truthy/ok, 1 = falsy/non-ok, 2 =
66
+ invalid filter) — fail closed surfaces as a falsy \`.ok\`, i.e. exit 1, not 3.
67
+ `.trim();
68
+
69
+ function parseCliArgs(argv) {
70
+ const parseError = (message) => Object.assign(new Error(message), { usage: USAGE, code: "INVALID_ARGS" });
71
+ const requireValue = (token, message) => {
72
+ const v = token.value;
73
+ if (typeof v !== "string" || v.length === 0 || v.startsWith("-")) {
74
+ throw parseError(message);
75
+ }
76
+ return v;
77
+ };
78
+
79
+ const args = {};
80
+ const { tokens } = parseArgs({
81
+ args: [...argv],
82
+ options: {
83
+ repo: { type: "string" },
84
+ project: { type: "string" },
85
+ help: { type: "boolean", short: "h" },
86
+ ...JQ_OUTPUT_PARSE_OPTIONS,
87
+ },
88
+ allowPositionals: true,
89
+ strict: false,
90
+ tokens: true,
91
+ });
92
+
93
+ for (const token of tokens) {
94
+ if (token.kind === "positional") {
95
+ throw parseError(`Unexpected argument: ${token.value}`);
96
+ }
97
+ if (token.kind !== "option") {
98
+ continue;
99
+ }
100
+ switch (token.name) {
101
+ case "help":
102
+ if (token.value !== undefined) {
103
+ throw parseError(`Unknown flag: ${token.rawName}=${token.value}`);
104
+ }
105
+ args.help = true;
106
+ break;
107
+ case "repo":
108
+ args.repo = requireValue(token, "--repo requires a value (owner/name)");
109
+ break;
110
+ case "project":
111
+ args.project = requireValue(token, "--project requires a value (number or node ID)");
112
+ break;
113
+ default: {
114
+ if (matchJqOutputToken(token, args, (t) => requireValue(t, "--jq requires a filter"))) break;
115
+ throw parseError(`Unknown flag: ${token.rawName}`);
116
+ }
117
+ }
118
+ }
119
+ return args;
120
+ }
121
+
122
+ function describeItem(item) {
123
+ const ref = item.prNumber != null ? `PR #${item.prNumber}` : `issue #${item.issueNumber}`;
124
+ return item.title ? `${ref} (${item.title})` : ref;
125
+ }
126
+
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.
139
+ function collapseToTarget(items) {
140
+ if (items.length > 1) {
141
+ const listed = items.map(describeItem).join(", ");
142
+ return {
143
+ ok: false,
144
+ reason: `${items.length} in-progress board items: ${listed}. Pass an explicit issue/PR to disambiguate, e.g. \`/loop-continue #N\`.`,
145
+ };
146
+ }
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" };
180
+ }
181
+
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];
196
+ const listed = await listQueueItems(
197
+ { repo: args.repo, project: args.project, column: inProgressColumn },
198
+ { env, runChild },
199
+ );
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);
207
+ }
208
+
209
+ function classifyExitCode(err) {
210
+ if (err.code === "INVALID_ARGS" || err.code === "INVALID_REPO" || err.code === "INVALID_PROJECT") return 1;
211
+ if (err.code === "PROJECT_NOT_FOUND" || err.code === "FIELD_NOT_FOUND" || err.code === "COLUMN_NOT_FOUND") return 3;
212
+ return 2;
213
+ }
214
+
215
+ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, runChild, cwd = process.cwd() } = {}) {
216
+ let args;
217
+ try {
218
+ args = parseCliArgs(argv);
219
+ } catch (err) {
220
+ stderr.write(`${formatCliError(err)}\n`);
221
+ process.exitCode = 1;
222
+ return;
223
+ }
224
+ if (args.help) {
225
+ stdout.write(USAGE);
226
+ return;
227
+ }
228
+ try {
229
+ const result = await main(args, { env, runChild, cwd });
230
+ // Fail closed (zero/multiple) is a clean, expected outcome — distinct exit code 3,
231
+ // not a crash; --jq/--silent still apply so callers can probe `.ok`.
232
+ process.exitCode = emitResult(result, {
233
+ jq: args.jq,
234
+ silent: args.silent,
235
+ stdout,
236
+ stderr,
237
+ ok: result.ok,
238
+ });
239
+ if (result.ok !== true && args.jq === undefined && !args.silent) {
240
+ process.exitCode = 3;
241
+ }
242
+ } catch (err) {
243
+ stderr.write(JSON.stringify({ ok: false, error: err.message, code: err.code ?? "UNKNOWN" }) + "\n");
244
+ process.exitCode = classifyExitCode(err);
245
+ }
246
+ }
247
+
248
+ if (isDirectCliRun(import.meta.url)) {
249
+ runCli(process.argv.slice(2)).catch((error) => {
250
+ process.stderr.write(JSON.stringify({ ok: false, error: error.message, code: error.code ?? "UNKNOWN" }) + "\n");
251
+ process.exitCode = 2;
252
+ });
253
+ }
254
+
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)`,