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,10 +1,12 @@
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";
5
- import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
6
7
 
7
- const USAGE = `Usage: dev-loops queue list --repo <owner/name> --project <number|id> [--column <name>] [--limit <n>]
8
+ const USAGE = `Usage: dev-loops queue list --repo <owner/name> [--project <number|id>] [--column <name>] [--limit <n>]
9
+ dev-loops queue list --repo <owner/name> [--project <number|id>] --summary [--done-limit <n>]
8
10
  (dev-loops project list … is a back-compat alias)
9
11
 
10
12
  List GitHub Projects V2 items filtered by Status column, ordered by position
@@ -12,13 +14,29 @@ ascending. Returns machine-readable JSON.
12
14
 
13
15
  Options:
14
16
  --repo <owner/name> Required. Repository to scope the project search.
15
- --project <number|id> Required. Project number (integer) or node ID.
17
+ --project <number|id> Project number (integer) or node ID. When omitted,
18
+ resolved from .devloops queue.projectNumber /
19
+ queue.boardTitle.
16
20
  --column <name> Filter items by Status column value (e.g. "Next Up").
17
- --limit <n> Return at most <n> items.
21
+ --limit <n> Return at most <n> items (flat mode only).
22
+ --summary Whole-board digest grouped by Status column, in board
23
+ column order. Emits { ok, groups: { <status>: { count, items } } }.
24
+ --group-by status Alias for --summary. Only "status" is supported.
25
+ --done-limit <n> With --summary: cap the "Done" group's items array to
26
+ <n> (or the last/terminal board column if no column is
27
+ named "Done"). Count stays the true total; use 0 for
28
+ counts only.
18
29
  --help, -h Show this help.
19
30
 
31
+ Grouping / aggregation is done via --summary (this mode). Do NOT pipe flat
32
+ output through inline parsers (e.g. \`| python3\`) or reduce/group_by jq filters
33
+ to build a per-status digest — the summary mode is the sanctioned one-call path.
34
+
35
+ --summary is mutually exclusive with --column and --limit (both exit 1).
36
+
20
37
  Output (stdout):
21
- JSON: { ok: true, items: [{ issueNumber, prNumber, title, url, itemId, contentId, status }, ...] }
38
+ flat: { ok: true, items: [{ issueNumber, prNumber, title, url, itemId, contentId, status }, ...] }
39
+ summary: { ok: true, groups: { "<Status>": { count, items: [ <item>, ... ] }, ... } }
22
40
 
23
41
  ${JQ_OUTPUT_USAGE}
24
42
 
@@ -47,6 +65,9 @@ function parseCliArgs(argv) {
47
65
  project: { type: "string" },
48
66
  column: { type: "string" },
49
67
  limit: { type: "string" },
68
+ summary: { type: "boolean" },
69
+ "group-by": { type: "string" },
70
+ "done-limit": { type: "string" },
50
71
  help: { type: "boolean", short: "h" },
51
72
  ...JQ_OUTPUT_PARSE_OPTIONS,
52
73
  },
@@ -87,14 +108,33 @@ function parseCliArgs(argv) {
87
108
  args.limit = val;
88
109
  break;
89
110
  }
90
- case "jq":
91
- args.jq = requireValue(token, "--jq requires a filter");
111
+ case "summary":
112
+ if (token.value !== undefined) {
113
+ throw parseError(`Unknown flag: ${token.rawName}=${token.value}`);
114
+ }
115
+ args.summary = true;
116
+ break;
117
+ case "group-by": {
118
+ const val = requireValue(token, "--group-by requires a value (only \"status\" is supported)");
119
+ if (val !== "status") {
120
+ throw parseError(`--group-by only supports "status", got "${val}"`);
121
+ }
122
+ args.summary = true;
92
123
  break;
93
- case "silent":
94
- args.silent = true;
124
+ }
125
+ case "done-limit": {
126
+ const raw = requireValue(token, "--done-limit requires a non-negative integer");
127
+ const val = Number(raw);
128
+ if (!Number.isInteger(val) || val < 0) {
129
+ throw parseError(`--done-limit must be a non-negative integer, got "${raw}"`);
130
+ }
131
+ args.doneLimit = val;
95
132
  break;
96
- default:
133
+ }
134
+ default: {
135
+ if (matchJqOutputToken(token, args, (t) => requireValue(t, "--jq requires a filter"))) break;
97
136
  throw parseError(`Unknown flag: ${token.rawName}`);
137
+ }
98
138
  }
99
139
  }
100
140
  return args;
@@ -103,7 +143,6 @@ function parseCliArgs(argv) {
103
143
 
104
144
  const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
105
145
  const REPO_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
106
- const GLOBAL_NODE_ID_RE = /^[A-Za-z0-9_]+$/;
107
146
 
108
147
  function validateRepo(repo) {
109
148
  if (!repo || typeof repo !== "string") {
@@ -128,31 +167,6 @@ function validateRepo(repo) {
128
167
  return repo;
129
168
  }
130
169
 
131
- function parseProjectRef(raw) {
132
- if (!raw || typeof raw !== "string" || raw.trim().length === 0) {
133
- throw Object.assign(new Error("--project is required"), { code: "INVALID_PROJECT" });
134
- }
135
- const trimmed = raw.trim();
136
- const asNum = Number(trimmed);
137
- if (Number.isInteger(asNum) && asNum > 0 && String(asNum) === trimmed) {
138
- return { kind: "number", value: asNum };
139
- }
140
- // Reject bare "0" — valid node ID character but not a meaningful project reference
141
- if (trimmed === "0") {
142
- throw Object.assign(
143
- new Error(`--project must be a positive integer or a node ID, got "${raw}"`),
144
- { code: "INVALID_PROJECT" },
145
- );
146
- }
147
- if (GLOBAL_NODE_ID_RE.test(trimmed)) {
148
- return { kind: "id", value: trimmed };
149
- }
150
- throw Object.assign(
151
- new Error(`--project must be a positive integer or a node ID, got "${raw}"`),
152
- { code: "INVALID_PROJECT" },
153
- );
154
- }
155
-
156
170
  // ── API helpers ──────────────────────────────────────────────────────────
157
171
 
158
172
  async function ghGraphql(query, vars, env, runChild = _runChild) {
@@ -369,34 +383,39 @@ async function main(args, { env = process.env, runChild } = {}) {
369
383
  const child = runChild ?? _runChild;
370
384
  const repo = validateRepo(args.repo);
371
385
  const [owner] = repo.split("/");
372
- const projectRef = parseProjectRef(args.project);
386
+ const selector = resolveProjectSelector(args);
373
387
 
374
- // 1. Resolve owner (user or org)
375
- const { id: ownerId, kind: ownerKind } = await resolveOwner(owner, env, child);
388
+ // Mutual exclusion: --summary is the whole-board grouped view; --column/--limit
389
+ // are flat-mode knobs. Combining them is ambiguous.
390
+ if (args.summary && args.column) {
391
+ throw Object.assign(
392
+ new Error("--summary and --column are mutually exclusive (--column filters to one status; --summary groups the whole board)"),
393
+ { code: "INVALID_ARGS" },
394
+ );
395
+ }
396
+ if (args.summary && args.limit) {
397
+ throw Object.assign(
398
+ new Error("--summary and --limit are mutually exclusive; use --done-limit to cap the Done group (or terminal column if no Done column exists)"),
399
+ { code: "INVALID_ARGS" },
400
+ );
401
+ }
402
+ if (args.doneLimit !== undefined && !args.summary) {
403
+ throw Object.assign(
404
+ new Error("--done-limit only applies with --summary"),
405
+ { code: "INVALID_ARGS" },
406
+ );
407
+ }
408
+
409
+ // 1. Resolve owner (user or org).
410
+ // URI refs encode owner+kind directly; skip the API round-trip for owner resolution.
411
+ const projectOwner = selector.projectRef?.kind === "uri" ? selector.projectRef.owner : owner;
412
+ const ownerKind = selector.projectRef?.kind === "uri"
413
+ ? selector.projectRef.ownerKind
414
+ : (await resolveOwner(owner, env, child)).kind;
376
415
 
377
416
  // 2. Resolve project
378
- let project;
379
- if (projectRef.kind === "id") {
380
- // Direct ID: use it directly (verify it belongs to owner via projects list)
381
- const projects = await listAllProjects(owner, ownerKind, env, child);
382
- project = projects.find((p) => p.id === projectRef.value);
383
- if (!project) {
384
- throw Object.assign(
385
- new Error(`Project with ID "${projectRef.value}" not found under owner "${owner}"`),
386
- { code: "PROJECT_NOT_FOUND" },
387
- );
388
- }
389
- } else {
390
- // By number
391
- const projects = await listAllProjects(owner, ownerKind, env, child);
392
- project = projects.find((p) => p.number === projectRef.value);
393
- if (!project) {
394
- throw Object.assign(
395
- new Error(`Project number ${projectRef.value} not found under owner "${owner}"`),
396
- { code: "PROJECT_NOT_FOUND" },
397
- );
398
- }
399
- }
417
+ const projects = await listAllProjects(projectOwner, ownerKind, env, child);
418
+ const project = findProject(projects, selector, projectOwner);
400
419
 
401
420
  // 3. Resolve Status field and target column
402
421
  const fieldNodes = await listAllFields(project.id, env, child);
@@ -458,7 +477,35 @@ async function main(args, { env = process.env, runChild } = {}) {
458
477
  });
459
478
  }
460
479
 
461
- // 5. Items are returned in position order from GraphQL. Apply limit.
480
+ // 5a. Summary mode: group by Status column in board option order.
481
+ if (args.summary) {
482
+ // Object.create(null): board option names are free text, so a column named
483
+ // "__proto__"/"constructor" must be an own key, not touch Object.prototype.
484
+ const groups = Object.create(null);
485
+ for (const option of statusField.options) {
486
+ groups[option.name] = { count: 0, items: [] };
487
+ }
488
+ for (const r of results) {
489
+ // Items with null status belong to no Status option, so they are excluded here — matches --column filtering behavior.
490
+ if (r.status === null) continue;
491
+ const group = groups[r.status];
492
+ if (!group) continue; // status value not among current board options
493
+ group.count += 1;
494
+ group.items.push(r);
495
+ }
496
+ if (args.doneLimit !== undefined) {
497
+ // Cap "Done" per the issue AC; if no column is literally named "Done",
498
+ // fall back to the last board option (conventionally the terminal column)
499
+ // so --done-limit is honest instead of a silent no-op.
500
+ const doneGroup = groups.Done ?? groups[statusField.options.at(-1)?.name];
501
+ if (doneGroup) {
502
+ doneGroup.items = doneGroup.items.slice(0, args.doneLimit);
503
+ }
504
+ }
505
+ return { ok: true, groups };
506
+ }
507
+
508
+ // 5b. Flat mode: items are returned in position order from GraphQL. Apply limit.
462
509
  const limited = args.limit ? results.slice(0, args.limit) : results;
463
510
 
464
511
  return {
@@ -469,7 +516,7 @@ async function main(args, { env = process.env, runChild } = {}) {
469
516
 
470
517
  // ── CLI entrypoint ──────────────────────────────────────────────────────
471
518
 
472
- async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env } = {}) {
519
+ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, cwd = process.cwd(), runChild } = {}) {
473
520
  let args;
474
521
  try {
475
522
  args = parseCliArgs(argv);
@@ -482,8 +529,12 @@ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr,
482
529
  stdout.write(USAGE);
483
530
  return;
484
531
  }
532
+
533
+ // Resolve the board from .devloops when --project is absent.
534
+ applyDevloopsBoard(args, cwd);
535
+
485
536
  try {
486
- const result = await main(args, { env });
537
+ const result = await main(args, { env, runChild });
487
538
  process.exitCode = emitResult(result, { jq: args.jq, silent: args.silent, stdout, stderr });
488
539
  } catch (err) {
489
540
  stderr.write(JSON.stringify({ ok: false, error: err.message, code: err.code ?? "UNKNOWN" }) + "\n");
@@ -498,4 +549,4 @@ if (isDirectCliRun(import.meta.url)) {
498
549
  });
499
550
  }
500
551
 
501
- export { main };
552
+ export { main, parseCliArgs, runCli };
@@ -1,20 +1,24 @@
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";
5
- import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
6
7
 
7
- const USAGE = `Usage: dev-loops queue move --repo <owner/name> --project <number|id> --item <number|node-id> --to-column <name>
8
+ const USAGE = `Usage: dev-loops queue move --repo <owner/name> --project <number|id|board-uri> --item <number|node-id> --to-column <name>
8
9
  (dev-loops project move … is a back-compat alias)
9
10
 
10
11
  Move a GitHub Projects V2 item between Status columns.
11
12
 
12
13
  Options:
13
- --repo <owner/name> Required. Repository to scope the project search.
14
- --project <number|id> Required. Project number (integer) or node ID.
15
- --item <number|node-id> Required. Item to move: issue/PR number, or project item node ID.
16
- --to-column <name> Required. Target Status column (e.g. "Next Up", "In Progress").
17
- --help, -h Show this help.
14
+ --repo <owner/name> Required. Repository to scope the project search.
15
+ --project <number|id|board-uri> Project number, node ID, or board URI
16
+ (e.g. https://github.com/users/me/projects/3).
17
+ When omitted, resolved from .devloops
18
+ queue.projectNumber / queue.boardTitle.
19
+ --item <number|node-id> Required. Item to move: issue/PR number, or project item node ID.
20
+ --to-column <name> Required. Target Status column (e.g. "Next Up", "In Progress").
21
+ --help, -h Show this help.
18
22
 
19
23
  Output (stdout):
20
24
  JSON: { ok: true, item: { itemId, issueNumber, prNumber, previousColumn, newColumn } }
@@ -80,14 +84,10 @@ function parseCliArgs(argv) {
80
84
  case "to-column":
81
85
  args.toColumn = requireValue(token, "--to-column requires a value");
82
86
  break;
83
- case "jq":
84
- args.jq = requireValue(token, "--jq requires a filter");
85
- break;
86
- case "silent":
87
- args.silent = true;
88
- break;
89
- default:
87
+ default: {
88
+ if (matchJqOutputToken(token, args, (t) => requireValue(t, "--jq requires a filter"))) break;
90
89
  throw parseError(`Unknown flag: ${token.rawName}`);
90
+ }
91
91
  }
92
92
  }
93
93
  return args;
@@ -118,24 +118,6 @@ function validateRepo(repo) {
118
118
  return repo;
119
119
  }
120
120
 
121
- function parseProjectRef(raw) {
122
- if (!raw || typeof raw !== "string" || raw.trim().length === 0) {
123
- throw Object.assign(new Error("--project is required"), { code: "INVALID_PROJECT" });
124
- }
125
- const trimmed = raw.trim();
126
- const asNum = Number(trimmed);
127
- if (Number.isInteger(asNum) && asNum > 0 && String(asNum) === trimmed) {
128
- return { kind: "number", value: asNum };
129
- }
130
- if (trimmed === "0") {
131
- throw Object.assign(new Error(`--project must be a positive integer or a node ID, got "${raw}"`), { code: "INVALID_PROJECT" });
132
- }
133
- if (GLOBAL_NODE_ID_RE.test(trimmed)) {
134
- return { kind: "id", value: trimmed };
135
- }
136
- throw Object.assign(new Error(`--project must be a positive integer or a node ID, got "${raw}"`), { code: "INVALID_PROJECT" });
137
- }
138
-
139
121
  function parseItemRef(raw) {
140
122
  if (!raw || typeof raw !== "string" || raw.trim().length === 0) {
141
123
  throw Object.assign(new Error("--item is required"), { code: "INVALID_ITEM" });
@@ -390,30 +372,23 @@ async function main(args, { env = process.env, runChild } = {}) {
390
372
  const child = runChild ?? _runChild;
391
373
  const repo = validateRepo(args.repo);
392
374
  const [owner, repoName] = repo.split("/");
393
- const projectRef = parseProjectRef(args.project);
375
+ const selector = resolveProjectSelector(args);
394
376
  const itemRef = parseItemRef(args.item);
395
377
  const toColumn = (args.toColumn ?? "").trim();
396
378
  if (!toColumn) {
397
379
  throw Object.assign(new Error("--to-column is required"), { code: "INVALID_COLUMN" });
398
380
  }
399
381
 
400
- // 1. Resolve owner
401
- const { id: ownerId, kind: ownerKind } = await resolveOwner(owner, env, child);
382
+ // 1. Resolve owner.
383
+ // URI refs encode owner+kind directly; skip the API round-trip for owner resolution.
384
+ const projectOwner = selector.projectRef?.kind === "uri" ? selector.projectRef.owner : owner;
385
+ const ownerKind = selector.projectRef?.kind === "uri"
386
+ ? selector.projectRef.ownerKind
387
+ : (await resolveOwner(owner, env, child)).kind;
402
388
 
403
389
  // 2. Resolve project
404
- const projects = await listAllProjects(owner, ownerKind, env, child);
405
- let project;
406
- if (projectRef.kind === "id") {
407
- project = projects.find((p) => p.id === projectRef.value);
408
- } else {
409
- project = projects.find((p) => p.number === projectRef.value);
410
- }
411
- if (!project) {
412
- throw Object.assign(
413
- new Error(`Project ${projectRef.kind === "id" ? `"${projectRef.value}"` : `number ${projectRef.value}`} not found under owner "${owner}"`),
414
- { code: "PROJECT_NOT_FOUND" },
415
- );
416
- }
390
+ const projects = await listAllProjects(projectOwner, ownerKind, env, child);
391
+ const project = findProject(projects, selector, projectOwner);
417
392
 
418
393
  // 3. Resolve Status field and target column
419
394
  const fieldNodes = await listAllFields(project.id, env, child);
@@ -527,7 +502,7 @@ async function main(args, { env = process.env, runChild } = {}) {
527
502
 
528
503
  // ── CLI entrypoint ──────────────────────────────────────────────────────
529
504
 
530
- async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env } = {}) {
505
+ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, cwd = process.cwd() } = {}) {
531
506
  let args;
532
507
  try {
533
508
  args = parseCliArgs(argv);
@@ -540,6 +515,10 @@ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr,
540
515
  stdout.write(USAGE);
541
516
  return;
542
517
  }
518
+
519
+ // Resolve the board from .devloops when --project is absent.
520
+ applyDevloopsBoard(args, cwd);
521
+
543
522
  try {
544
523
  const result = await main(args, { env });
545
524
  process.exitCode = emitResult(result, { jq: args.jq, silent: args.silent, stdout, stderr });
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env node
2
+ // Reconcile board Status columns from live GitHub state (#1069). For each queue
3
+ // item it derives the target column from live facts (merged PR / closed issue →
4
+ // Done; open ready non-draft PR → In Progress) and moves ONLY the items whose
5
+ // derived column differs from their current Status. Backlog/Next Up ordering is
6
+ // left untouched (items that derive null are skipped). Idempotent: a second run
7
+ // over a converged board performs no moves.
8
+ import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
9
+ import { parseArgs } from "node:util";
10
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
11
+ import { applyDevloopsBoard } from "./_resolve-project.mjs";
12
+ import { main as listQueueItems } from "./list-queue-items.mjs";
13
+ import { main as moveQueueItem } from "./move-queue-item.mjs";
14
+ import { detectLinkedIssuePr } from "../github/detect-linked-issue-pr.mjs";
15
+ import { planReconcile, loadStateColumnMap, LOGICAL_COLUMN } from "@dev-loops/core/loop/queue-board-sync";
16
+ import { runChild as _runChild } from "../_cli-primitives.mjs";
17
+
18
+ const USAGE = `Usage: dev-loops queue reconcile --repo <owner/name> [--project <number|id|board-uri>]
19
+ (dev-loops project reconcile … is a back-compat alias)
20
+
21
+ Reconcile board Status columns from live GitHub state. Derives each item's
22
+ target column from live GitHub facts (merged PR or closed issue → Done; an open,
23
+ ready non-draft PR → In Progress) and moves ONLY the items whose derived column
24
+ differs from their current Status. Backlog/Next Up ordering is left untouched.
25
+ Idempotent: a second run over a converged board performs no moves. Best-effort:
26
+ individual per-item failures are recorded but do not fail the run.
27
+
28
+ Options:
29
+ --repo <owner/name> Required. Repository to scope the project search.
30
+ --project <number|id|board-uri>
31
+ Project number (integer), node ID, or board URI. When
32
+ omitted, resolved from .devloops queue.projectNumber /
33
+ queue.boardTitle.
34
+ --help, -h Show this help.
35
+
36
+ Output (stdout):
37
+ JSON: { ok: true, moved, unchanged, reconciled: [{ number, from, to, ok }, ...] }
38
+
39
+ ${JQ_OUTPUT_USAGE}
40
+
41
+ Exit codes:
42
+ 0 — success (reconcile is best-effort; per-item failures do not fail the run)
43
+ 1 — usage or argument error
44
+ 2 — GitHub API error / invalid --jq filter
45
+ 3 — project, field, or column not found
46
+ `.trim();
47
+
48
+ function parseCliArgs(argv) {
49
+ const parseError = (message) => Object.assign(new Error(message), { usage: USAGE, code: "INVALID_ARGS" });
50
+ const requireValue = (token, message) => {
51
+ const v = token.value;
52
+ if (typeof v !== "string" || v.length === 0 || v.startsWith("-")) {
53
+ throw parseError(message);
54
+ }
55
+ return v;
56
+ };
57
+
58
+ const args = {};
59
+ const { tokens } = parseArgs({
60
+ args: [...argv],
61
+ options: {
62
+ repo: { type: "string" },
63
+ project: { type: "string" },
64
+ help: { type: "boolean", short: "h" },
65
+ ...JQ_OUTPUT_PARSE_OPTIONS,
66
+ },
67
+ allowPositionals: true,
68
+ strict: false,
69
+ tokens: true,
70
+ });
71
+
72
+ for (const token of tokens) {
73
+ if (token.kind === "positional") {
74
+ throw parseError(`Unexpected argument: ${token.value}`);
75
+ }
76
+ if (token.kind !== "option") {
77
+ continue;
78
+ }
79
+ switch (token.name) {
80
+ case "help":
81
+ if (token.value !== undefined) {
82
+ throw parseError(`Unknown flag: ${token.rawName}=${token.value}`);
83
+ }
84
+ args.help = true;
85
+ break;
86
+ case "repo":
87
+ args.repo = requireValue(token, "--repo requires a value (owner/name)");
88
+ break;
89
+ case "project":
90
+ args.project = requireValue(token, "--project requires a value (number or node ID)");
91
+ break;
92
+ default: {
93
+ if (matchJqOutputToken(token, args, (t) => requireValue(t, "--jq requires a filter"))) break;
94
+ throw parseError(`Unknown flag: ${token.rawName}`);
95
+ }
96
+ }
97
+ }
98
+ return args;
99
+ }
100
+
101
+ async function ghJson(argv, { env, runChild }) {
102
+ const child = runChild ?? _runChild;
103
+ const result = await child("gh", argv, env);
104
+ if (result.code !== 0) {
105
+ const detail = result.stderr?.trim() || `exit code ${result.code}`;
106
+ throw Object.assign(new Error(`gh command failed: ${detail}`), { code: "GH_API_ERROR" });
107
+ }
108
+ return parseJsonText(result.stdout);
109
+ }
110
+
111
+ // Real live-facts gatherer. For each item, resolve GitHub state into the fact
112
+ // shape consumed by deriveReconcileColumn. Keyed by the stable item node id
113
+ // (item.itemId), not the bare number, so a multi-repo board where two items
114
+ // share a number (repo-A PR #5 vs repo-B issue #5) cannot collide. Per-item gh
115
+ // failures are swallowed (best-effort): the item records all-null PR fields →
116
+ // derives null → untouched.
117
+ export async function gatherLiveFacts(items, repo, { env, runChild, doneColumn } = {}) {
118
+ const byItemId = new Map();
119
+ for (const item of items) {
120
+ const number = item.prNumber != null ? item.prNumber : item.issueNumber;
121
+ if (number == null || item.itemId == null) continue;
122
+ // Done is terminal for reconcile; planReconcile treats a missing fact as
123
+ // unchanged. At loop startup (doneColumn set) we skip the gh calls for
124
+ // Done items entirely — terminal + perf. An explicit `dev-loops queue
125
+ // reconcile` run passes no doneColumn, so Done items ARE gathered and a
126
+ // reopened/re-linked artifact can be moved back out of Done (recovery).
127
+ if (doneColumn != null && item.status === doneColumn) continue;
128
+ try {
129
+ if (item.prNumber != null) {
130
+ const pr = await ghJson(["pr", "view", String(item.prNumber), "--repo", repo, "--json", "state,isDraft,mergedAt"], { env, runChild });
131
+ byItemId.set(item.itemId, {
132
+ itemKind: "pr",
133
+ issueState: null,
134
+ prState: pr?.mergedAt ? "MERGED" : String(pr?.state ?? "").toUpperCase(),
135
+ prIsDraft: pr?.isDraft === true,
136
+ });
137
+ } else {
138
+ const issue = await ghJson(["issue", "view", String(item.issueNumber), "--repo", repo, "--json", "state"], { env, runChild });
139
+ const issueState = String(issue?.state ?? "").toUpperCase();
140
+ if (issueState === "CLOSED") {
141
+ byItemId.set(item.itemId, { itemKind: "issue", issueState: "CLOSED", prState: null, prIsDraft: null });
142
+ continue;
143
+ }
144
+ let prState = null;
145
+ let prIsDraft = null;
146
+ const linkage = await detectLinkedIssuePr({ repo, issue: item.issueNumber }, { env, runChild });
147
+ if (linkage?.hasOpenLinkedPr) {
148
+ const pr = await ghJson(["pr", "view", String(linkage.prNumber), "--repo", repo, "--json", "state,isDraft,mergedAt"], { env, runChild });
149
+ prState = pr?.mergedAt ? "MERGED" : String(pr?.state ?? "").toUpperCase();
150
+ prIsDraft = pr?.isDraft === true;
151
+ }
152
+ byItemId.set(item.itemId, { itemKind: "issue", issueState: "OPEN", prState, prIsDraft });
153
+ }
154
+ } catch {
155
+ // Best-effort: record inert facts so the item derives null (untouched).
156
+ byItemId.set(item.itemId, {
157
+ itemKind: item.prNumber != null ? "pr" : "issue",
158
+ issueState: item.prNumber != null ? null : "OPEN",
159
+ prState: null,
160
+ prIsDraft: null,
161
+ });
162
+ }
163
+ }
164
+ return byItemId;
165
+ }
166
+
167
+ async function main(args, { env = process.env, runChild, cwd = process.cwd(), listItems, gatherFacts, moveItem, skipTerminalColumn = false } = {}) {
168
+ // Resolve the board from .devloops when --project is absent. Idempotent (only
169
+ // mutates when args.project === undefined), so callers that reach main()
170
+ // directly (e.g. the loop-startup self-heal) still resolve the board.
171
+ applyDevloopsBoard(args, cwd);
172
+
173
+ const list = await (listItems ?? ((a, o) => listQueueItems(a, o)))(
174
+ { repo: args.repo, project: args.project, projectTitle: args.projectTitle },
175
+ { env, runChild },
176
+ );
177
+ const items = list.items ?? [];
178
+
179
+ const { columnNames } = loadStateColumnMap(cwd);
180
+
181
+ // Loop startup (skipTerminalColumn: true) skips Done items for speed. An
182
+ // explicit CLI run (default false) leaves doneColumn null so Done items are
183
+ // gathered and a reopened/re-linked artifact can be recovered out of Done.
184
+ const doneColumn = skipTerminalColumn ? columnNames[LOGICAL_COLUMN.DONE] : null;
185
+
186
+ const factsByItemId = await (gatherFacts ?? ((its, repo, o) => gatherLiveFacts(its, repo, o)))(
187
+ items,
188
+ args.repo,
189
+ { env, runChild, doneColumn },
190
+ );
191
+
192
+ const { moves, unchanged } = planReconcile(items, factsByItemId, columnNames);
193
+
194
+ const move = moveItem ?? ((a, o) => moveQueueItem(a, o));
195
+ const reconciled = [];
196
+ let moved = 0;
197
+ for (const m of moves) {
198
+ try {
199
+ // Move by the stable item node id (move-queue-item accepts a node id per
200
+ // its `--item <number|node-id>` contract) so a multi-repo number collision
201
+ // can never target the wrong item.
202
+ await move(
203
+ { repo: args.repo, project: args.project, projectTitle: args.projectTitle, item: m.itemId, toColumn: m.to },
204
+ { env, runChild },
205
+ );
206
+ reconciled.push({ number: m.number, from: m.from, to: m.to, ok: true });
207
+ moved += 1;
208
+ } catch (err) {
209
+ // Best-effort: a single failed move does not abort the reconcile loop.
210
+ reconciled.push({ number: m.number, from: m.from, to: m.to, ok: false, error: err?.message ?? "move failed" });
211
+ }
212
+ }
213
+
214
+ return { ok: true, moved, unchanged, reconciled };
215
+ }
216
+
217
+ function classifyExitCode(err) {
218
+ if (err.code === "INVALID_ARGS" || err.code === "INVALID_REPO" || err.code === "INVALID_PROJECT") return 1;
219
+ if (err.code === "PROJECT_NOT_FOUND" || err.code === "FIELD_NOT_FOUND" || err.code === "COLUMN_NOT_FOUND") return 3;
220
+ return 2;
221
+ }
222
+
223
+ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, cwd = process.cwd(), runChild } = {}) {
224
+ let args;
225
+ try {
226
+ args = parseCliArgs(argv);
227
+ } catch (err) {
228
+ stderr.write(`${formatCliError(err)}\n`);
229
+ process.exitCode = 1;
230
+ return;
231
+ }
232
+ if (args.help) {
233
+ stdout.write(USAGE);
234
+ return;
235
+ }
236
+
237
+ try {
238
+ const result = await main(args, { env, runChild, cwd });
239
+ process.exitCode = emitResult(result, { jq: args.jq, silent: args.silent, stdout, stderr });
240
+ } catch (err) {
241
+ stderr.write(JSON.stringify({ ok: false, error: err.message, code: err.code ?? "UNKNOWN" }) + "\n");
242
+ process.exitCode = classifyExitCode(err);
243
+ }
244
+ }
245
+
246
+ if (isDirectCliRun(import.meta.url)) {
247
+ runCli(process.argv.slice(2)).catch((error) => {
248
+ process.stderr.write(JSON.stringify({ ok: false, error: error.message, code: error.code ?? "UNKNOWN" }) + "\n");
249
+ process.exitCode = 2;
250
+ });
251
+ }
252
+
253
+ export { main, parseCliArgs, runCli };