dev-loops 0.6.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +8 -7
  3. package/.claude/agents/fixer.md +1 -0
  4. package/.claude/agents/refiner.md +2 -2
  5. package/.claude/agents/review.md +4 -3
  6. package/.claude/commands/{auto.md → loop-auto.md} +1 -1
  7. package/.claude/commands/loop-continue.md +15 -0
  8. package/.claude/commands/loop-enqueue.md +22 -0
  9. package/.claude/commands/loop-grill.md +17 -0
  10. package/.claude/commands/{info.md → loop-info.md} +2 -2
  11. package/.claude/commands/loop-queue-status.md +24 -0
  12. package/.claude/commands/{start-spike.md → loop-start-spike.md} +2 -2
  13. package/.claude/commands/{start.md → loop-start.md} +1 -1
  14. package/.claude/commands/{status.md → loop-status.md} +1 -1
  15. package/.claude/hooks/_bash-command-classify.mjs +333 -29
  16. package/.claude/hooks/_hook-decisions.mjs +138 -15
  17. package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
  18. package/.claude/skills/copilot-pr-followup/SKILL.md +60 -34
  19. package/.claude/skills/dev-loop/SKILL.md +16 -12
  20. package/.claude/skills/docs/acceptance-criteria-verification.md +8 -3
  21. package/.claude/skills/docs/anti-patterns.md +5 -3
  22. package/.claude/skills/docs/artifact-authority-contract.md +30 -11
  23. package/.claude/skills/docs/confirmation-rules.md +1 -0
  24. package/.claude/skills/docs/contract-style-guide.md +37 -0
  25. package/.claude/skills/docs/copilot-loop-operations.md +19 -15
  26. package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
  27. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  28. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  29. package/.claude/skills/docs/issue-intake-procedure.md +26 -19
  30. package/.claude/skills/docs/local-planning-flow.md +1 -1
  31. package/.claude/skills/docs/local-planning-worked-example.md +1 -1
  32. package/.claude/skills/docs/merge-preconditions.md +30 -12
  33. package/.claude/skills/docs/plan-file-contract.md +1 -1
  34. package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
  35. package/.claude/skills/docs/public-dev-loop-contract.md +60 -44
  36. package/.claude/skills/docs/retrospective-checkpoint-contract.md +106 -79
  37. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  38. package/.claude/skills/docs/stop-conditions.md +30 -15
  39. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  40. package/.claude/skills/docs/ui-e2e-scoping-step.md +32 -0
  41. package/.claude/skills/docs/validation-policy.md +4 -5
  42. package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
  43. package/.claude/skills/local-implementation/SKILL.md +62 -145
  44. package/.claude/skills/loop-grill/SKILL.md +163 -0
  45. package/AGENTS.md +1 -0
  46. package/CHANGELOG.md +101 -0
  47. package/README.md +9 -9
  48. package/agents/dev-loop.agent.md +11 -10
  49. package/agents/developer.agent.md +1 -1
  50. package/agents/docs.agent.md +1 -1
  51. package/agents/fixer.agent.md +2 -1
  52. package/agents/quality.agent.md +1 -1
  53. package/agents/refiner.agent.md +3 -3
  54. package/agents/review.agent.md +5 -4
  55. package/cli/index.mjs +35 -42
  56. package/extension/README.md +4 -4
  57. package/extension/checks.ts +1 -5
  58. package/extension/harness-types.ts +1 -0
  59. package/extension/index.ts +6 -0
  60. package/extension/pi-extension-adapter.ts +2 -0
  61. package/extension/presentation.ts +7 -19
  62. package/package.json +12 -12
  63. package/scripts/_core-helpers.mjs +2 -0
  64. package/scripts/claude/headless-dev-loop.mjs +1 -1
  65. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  66. package/scripts/docs/validate-state-machine-conformance.mjs +1092 -0
  67. package/scripts/github/_review-thread-mutations.mjs +5 -2
  68. package/scripts/github/capture-review-threads.mjs +2 -9
  69. package/scripts/github/comment-issue.mjs +2 -9
  70. package/scripts/github/create-label.mjs +133 -0
  71. package/scripts/github/create-pr.mjs +119 -10
  72. package/scripts/github/detect-checkpoint-evidence.mjs +259 -18
  73. package/scripts/github/detect-linked-issue-pr.mjs +22 -6
  74. package/scripts/github/edit-pr.mjs +259 -0
  75. package/scripts/github/fetch-ci-logs.mjs +2 -9
  76. package/scripts/github/list-issues.mjs +2 -9
  77. package/scripts/github/list-review-threads.mjs +277 -0
  78. package/scripts/github/manage-sub-issues.mjs +46 -10
  79. package/scripts/github/offer-human-handoff.mjs +8 -5
  80. package/scripts/github/post-gate-findings.mjs +20 -5
  81. package/scripts/github/probe-ci-status.mjs +8 -2
  82. package/scripts/github/probe-copilot-review.mjs +21 -14
  83. package/scripts/github/ready-for-review.mjs +26 -8
  84. package/scripts/github/reconcile-draft-gate.mjs +7 -3
  85. package/scripts/github/reply-resolve-review-thread.mjs +16 -5
  86. package/scripts/github/reply-resolve-review-threads.mjs +69 -7
  87. package/scripts/github/request-copilot-review.mjs +98 -26
  88. package/scripts/github/resolve-handoff-candidates.mjs +7 -3
  89. package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
  90. package/scripts/github/stage-reviewer-draft.mjs +10 -2
  91. package/scripts/github/tick-verified-checkboxes.mjs +202 -0
  92. package/scripts/github/upsert-checkpoint-verdict.mjs +105 -15
  93. package/scripts/github/verify-briefing-prefixes.mjs +191 -0
  94. package/scripts/github/verify-fresh-review-context.mjs +226 -32
  95. package/scripts/github/view-pr.mjs +150 -0
  96. package/scripts/github/wait-pr-checks.mjs +171 -0
  97. package/scripts/github/write-gate-context.mjs +458 -65
  98. package/scripts/github/write-gate-findings-log.mjs +128 -2
  99. package/scripts/lib/jq-output.mjs +18 -0
  100. package/scripts/loop/_post-convergence-change.mjs +211 -0
  101. package/scripts/loop/_pr-runner-coordination.mjs +70 -0
  102. package/scripts/loop/_repo-root-resolver.mjs +47 -0
  103. package/scripts/loop/build-handoff-envelope.mjs +14 -4
  104. package/scripts/loop/check-retro-tooling.mjs +14 -3
  105. package/scripts/loop/checkpoint-contract.mjs +7 -4
  106. package/scripts/loop/cleanup-worktree.mjs +12 -3
  107. package/scripts/loop/conductor-monitor.mjs +12 -18
  108. package/scripts/loop/copilot-pr-handoff.mjs +162 -14
  109. package/scripts/loop/debt-remediate.mjs +24 -12
  110. package/scripts/loop/detect-change-scope.mjs +38 -9
  111. package/scripts/loop/detect-copilot-loop-state.mjs +34 -8
  112. package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
  113. package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
  114. package/scripts/loop/detect-internal-only-pr.mjs +8 -2
  115. package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
  116. package/scripts/loop/detect-pr-gate-coordination-state.mjs +184 -69
  117. package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
  118. package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
  119. package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
  120. package/scripts/loop/ensure-worktree.mjs +8 -3
  121. package/scripts/loop/info.mjs +12 -4
  122. package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
  123. package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
  124. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
  125. package/scripts/loop/inspect-run-viewer.mjs +67 -4
  126. package/scripts/loop/inspect-run.mjs +8 -2
  127. package/scripts/loop/outer-loop.mjs +8 -4
  128. package/scripts/loop/pr-runner-coordination.mjs +2 -9
  129. package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
  130. package/scripts/loop/pre-flight-gate.mjs +9 -9
  131. package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
  132. package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
  133. package/scripts/loop/provision-worktree.mjs +74 -3
  134. package/scripts/loop/resolve-dev-loop-startup.mjs +216 -10
  135. package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
  136. package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
  137. package/scripts/loop/run-conductor-cycle.mjs +8 -2
  138. package/scripts/loop/run-queue.mjs +18 -9
  139. package/scripts/loop/run-refinement-audit.mjs +8 -9
  140. package/scripts/loop/run-watch-cycle.mjs +2 -9
  141. package/scripts/loop/sanctioned-commands.mjs +106 -0
  142. package/scripts/loop/steer-loop.mjs +29 -16
  143. package/scripts/loop/validate-pr-body-spec.mjs +223 -0
  144. package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
  145. package/scripts/pages/build-site.mjs +59 -8
  146. package/scripts/pages/build-state-atlas.mjs +443 -0
  147. package/scripts/projects/_resolve-project.mjs +1 -0
  148. package/scripts/projects/add-queue-item.mjs +60 -54
  149. package/scripts/projects/archive-done-items.mjs +49 -84
  150. package/scripts/projects/ensure-queue-board.mjs +10 -36
  151. package/scripts/projects/list-queue-items.mjs +59 -382
  152. package/scripts/projects/move-queue-item.mjs +21 -449
  153. package/scripts/projects/reconcile-queue.mjs +253 -0
  154. package/scripts/projects/reorder-queue-item.mjs +43 -68
  155. package/scripts/projects/resolve-active-board-item.mjs +108 -46
  156. package/scripts/projects/sync-item-status.mjs +15 -10
  157. package/scripts/refine/_refine-helpers.mjs +21 -5
  158. package/scripts/refine/exit-spike.mjs +18 -8
  159. package/scripts/refine/promote-plan.mjs +22 -16
  160. package/scripts/refine/prose-linkage-detector.mjs +1 -1
  161. package/scripts/refine/refine-plan-file.mjs +13 -4
  162. package/scripts/refine/refinement-completeness-checker.mjs +1 -1
  163. package/scripts/refine/scaffold-spike-file.mjs +4 -8
  164. package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
  165. package/scripts/refine/tree-integrity-validator.mjs +1 -1
  166. package/scripts/refine/validate-plan-file.mjs +1 -1
  167. package/scripts/refine/validate-spike-file.mjs +1 -1
  168. package/scripts/refine/verify.mjs +11 -6
  169. package/scripts/release/assert-core-dependency-version.mjs +123 -0
  170. package/scripts/release/extract-changelog-section.mjs +16 -2
  171. package/skills/copilot-pr-followup/SKILL.md +60 -34
  172. package/skills/dev-loop/SKILL.md +11 -7
  173. package/skills/docs/acceptance-criteria-verification.md +8 -3
  174. package/skills/docs/anti-patterns.md +5 -3
  175. package/skills/docs/artifact-authority-contract.md +30 -11
  176. package/skills/docs/confirmation-rules.md +1 -0
  177. package/skills/docs/contract-style-guide.md +37 -0
  178. package/skills/docs/copilot-loop-operations.md +19 -15
  179. package/skills/docs/cross-harness-regression-contract.md +60 -0
  180. package/skills/docs/debt-remediation-contract.md +1 -1
  181. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  182. package/skills/docs/issue-intake-procedure.md +26 -19
  183. package/skills/docs/local-planning-flow.md +1 -1
  184. package/skills/docs/local-planning-worked-example.md +1 -1
  185. package/skills/docs/merge-preconditions.md +30 -12
  186. package/skills/docs/plan-file-contract.md +1 -1
  187. package/skills/docs/pr-lifecycle-contract.md +44 -35
  188. package/skills/docs/public-dev-loop-contract.md +60 -44
  189. package/skills/docs/required-rules.json +149 -0
  190. package/skills/docs/retrospective-checkpoint-contract.md +106 -79
  191. package/skills/docs/spike-mode-contract.md +14 -7
  192. package/skills/docs/stop-conditions.md +30 -15
  193. package/skills/docs/tracker-first-loop-state.md +13 -8
  194. package/skills/docs/ui-e2e-scoping-step.md +32 -0
  195. package/skills/docs/validation-policy.md +4 -5
  196. package/skills/docs/workflow-handoff-contract.md +39 -0
  197. package/skills/local-implementation/SKILL.md +62 -145
  198. package/skills/loop-grill/SKILL.md +165 -0
  199. package/.claude/commands/continue.md +0 -15
  200. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
  201. package/scripts/loop/conductor.mjs +0 -233
  202. package/scripts/loop/detect-stale-runner.mjs +0 -265
  203. package/scripts/loop/pre-push-main-guard.mjs +0 -117
@@ -1,20 +1,29 @@
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";
7
+ import { loadStateColumnMap, LOGICAL_COLUMN } from "@dev-loops/core/loop/queue-board-sync";
6
8
 
7
- const USAGE = `Usage: dev-loops queue add --repo <owner/name> --project <number|id> --item <number>
9
+ const USAGE = `Usage: dev-loops queue add --repo <owner/name> [--project <number|id>] --item <number>
8
10
  dev-loops project add … (back-compat alias for "queue add")
9
11
 
10
12
  Add an existing issue or PR to a GitHub Projects V2 board.
11
13
 
12
14
  Options:
13
15
  --repo <owner/name> Required. Repository containing the issue/PR.
14
- --project <number|id> Required. Project number (integer) or node ID.
16
+ --project <number|id> Project number (integer) or node ID. When omitted,
17
+ resolved from .devloops queue.projectNumber /
18
+ queue.boardTitle.
15
19
  --item <number> Required. Issue or PR number to add.
16
20
  --column <name> Initial Status column (default: "Backlog").
17
21
  --status <name> Back-compat alias for --column.
22
+ --next-up Land the item directly in the configured next_up
23
+ column (the normative pickup queue; "Next Up" by
24
+ default, honors queue.statusColumns.next_up). Sugar
25
+ for --column <that column>; cannot be combined with
26
+ a conflicting --column/--status.
18
27
  --help, -h Show this help.
19
28
 
20
29
  Output (stdout):
@@ -48,6 +57,7 @@ function parseCliArgs(argv) {
48
57
  item: { type: "string" },
49
58
  column: { type: "string" },
50
59
  status: { type: "string" },
60
+ "next-up": { type: "boolean" },
51
61
  help: { type: "boolean", short: "h" },
52
62
  ...JQ_OUTPUT_PARSE_OPTIONS,
53
63
  },
@@ -97,14 +107,16 @@ function parseCliArgs(argv) {
97
107
  // resolved by argv order.
98
108
  args.status = requireValue(token, "--status requires a value");
99
109
  break;
100
- case "jq":
101
- args.jq = requireValue(token, "--jq requires a filter");
102
- break;
103
- case "silent":
104
- args.silent = true;
110
+ case "next-up":
111
+ if (token.value !== undefined) {
112
+ throw Object.assign(new Error(`Unknown flag: ${token.rawName}=${token.value}`), { code: "INVALID_ARGS", usage: USAGE });
113
+ }
114
+ args.nextUp = true;
105
115
  break;
106
- default:
116
+ default: {
117
+ if (matchJqOutputToken(token, args, (t) => requireValue(t, "--jq requires a filter"))) break;
107
118
  throw Object.assign(new Error(`Unknown flag: ${token.rawName}`), { code: "INVALID_ARGS", usage: USAGE });
119
+ }
108
120
  }
109
121
  }
110
122
  return args;
@@ -114,7 +126,6 @@ function parseCliArgs(argv) {
114
126
 
115
127
  const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
116
128
  const REPO_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
117
- const GLOBAL_NODE_ID_RE = /^[A-Za-z0-9_]+$/;
118
129
 
119
130
  function validateRepo(repo) {
120
131
  if (!repo || typeof repo !== "string") {
@@ -139,30 +150,6 @@ function validateRepo(repo) {
139
150
  return repo;
140
151
  }
141
152
 
142
- function parseProjectRef(raw) {
143
- if (!raw || typeof raw !== "string" || raw.trim().length === 0) {
144
- throw Object.assign(new Error("--project is required"), { code: "INVALID_PROJECT" });
145
- }
146
- const trimmed = raw.trim();
147
- const asNum = Number(trimmed);
148
- if (Number.isInteger(asNum) && asNum > 0 && String(asNum) === trimmed) {
149
- return { kind: "number", value: asNum };
150
- }
151
- if (trimmed === "0") {
152
- throw Object.assign(
153
- new Error(`--project must be a positive integer or a node ID, got "${raw}"`),
154
- { code: "INVALID_PROJECT" },
155
- );
156
- }
157
- if (GLOBAL_NODE_ID_RE.test(trimmed)) {
158
- return { kind: "id", value: trimmed };
159
- }
160
- throw Object.assign(
161
- new Error(`--project must be a positive integer or a node ID, got "${raw}"`),
162
- { code: "INVALID_PROJECT" },
163
- );
164
- }
165
-
166
153
  // ── API helpers ──────────────────────────────────────────────────────────
167
154
 
168
155
  async function ghGraphql(query, vars, env, runChild = _runChild, { allowErrors = false } = {}) {
@@ -385,11 +372,11 @@ function classifyExitCode(err) {
385
372
 
386
373
  // ── Main logic ──────────────────────────────────────────────────────────
387
374
 
388
- async function main(args, { env = process.env, runChild } = {}) {
375
+ async function main(args, { env = process.env, runChild, cwd = process.cwd() } = {}) {
389
376
  const child = runChild ?? _runChild;
390
377
  const repo = validateRepo(args.repo);
391
378
  const [owner, repoName] = repo.split("/");
392
- const projectRef = parseProjectRef(args.project);
379
+ const selector = resolveProjectSelector(args);
393
380
  const itemNumber = args.item;
394
381
  if (!Number.isInteger(itemNumber) || itemNumber < 1) {
395
382
  throw Object.assign(new Error("--item is required and must be a positive integer"), { code: "INVALID_ITEM" });
@@ -400,28 +387,43 @@ async function main(args, { env = process.env, runChild } = {}) {
400
387
  { code: "INVALID_ARGS", usage: USAGE },
401
388
  );
402
389
  }
403
- const targetStatus = (args.column ?? args.status ?? "Backlog").trim();
390
+ // --next-up is sugar for --column <resolved next_up display name> (the
391
+ // normative pickup queue, #1091), resolved through the SAME statusColumns
392
+ // mapping board-sync uses (#1098) so a renamed Next Up column agrees with
393
+ // an explicit --column of the same configured name.
394
+ const { columnNames, error: configError } = loadStateColumnMap(cwd);
395
+ // Fail CLOSED on a malformed `.devloops` when --next-up drives the target:
396
+ // silently using the literal "Next Up" could land in the wrong column (#1098).
397
+ // A plain `--column X` add never consults statusColumns, so it is unaffected.
398
+ if (args.nextUp && configError) {
399
+ throw Object.assign(
400
+ new Error(`could not resolve next_up column (config read/parse error: ${configError})`),
401
+ { code: "CONFIG_ERROR" },
402
+ );
403
+ }
404
+ const nextUpColumn = columnNames[LOGICAL_COLUMN.NEXT_UP];
405
+ const explicitColumn = args.column ?? args.status ?? null;
406
+ if (args.nextUp && explicitColumn != null && explicitColumn.trim() !== nextUpColumn) {
407
+ throw Object.assign(
408
+ new Error(`Conflicting --next-up and --column/--status ("${explicitColumn}") — pass only one.`),
409
+ { code: "INVALID_ARGS", usage: USAGE },
410
+ );
411
+ }
412
+ const targetStatus = (args.nextUp ? nextUpColumn : (explicitColumn ?? "Backlog")).trim();
404
413
  if (!targetStatus) {
405
414
  throw Object.assign(new Error("--column must not be empty"), { code: "INVALID_STATUS" });
406
415
  }
407
416
 
408
417
  // 1. Resolve owner
409
- const { id: ownerId, kind: ownerKind } = await resolveOwner(owner, env, child);
418
+ // URI refs encode owner+kind directly; skip the API round-trip for owner resolution.
419
+ const projectOwner = selector.projectRef?.kind === "uri" ? selector.projectRef.owner : owner;
420
+ const ownerKind = selector.projectRef?.kind === "uri"
421
+ ? selector.projectRef.ownerKind
422
+ : (await resolveOwner(owner, env, child)).kind;
410
423
 
411
424
  // 2. Resolve project
412
- const projects = await listAllProjects(owner, ownerKind, env, child);
413
- let project;
414
- if (projectRef.kind === "id") {
415
- project = projects.find((p) => p.id === projectRef.value);
416
- } else {
417
- project = projects.find((p) => p.number === projectRef.value);
418
- }
419
- if (!project) {
420
- throw Object.assign(
421
- new Error(`Project ${projectRef.kind === "id" ? `"${projectRef.value}"` : `number ${projectRef.value}`} not found under owner "${owner}"`),
422
- { code: "PROJECT_NOT_FOUND" },
423
- );
424
- }
425
+ const projects = await listAllProjects(projectOwner, ownerKind, env, child);
426
+ const project = findProject(projects, selector, projectOwner);
425
427
 
426
428
  // 3. Resolve Status field and target column
427
429
  const fieldNodes = await listAllFields(project.id, env, child);
@@ -538,7 +540,7 @@ async function main(args, { env = process.env, runChild } = {}) {
538
540
 
539
541
  // ── CLI entrypoint ──────────────────────────────────────────────────────
540
542
 
541
- async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env } = {}) {
543
+ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, cwd = process.cwd(), runChild } = {}) {
542
544
  let args;
543
545
  try {
544
546
  args = parseCliArgs(argv);
@@ -551,8 +553,12 @@ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr,
551
553
  stdout.write(USAGE);
552
554
  return;
553
555
  }
556
+
557
+ // Resolve the board from .devloops when --project is absent.
558
+ applyDevloopsBoard(args, cwd);
559
+
554
560
  try {
555
- const result = await main(args, { env });
561
+ const result = await main(args, { env, runChild, cwd });
556
562
  process.exitCode = emitResult(result, { jq: args.jq, silent: args.silent, stdout, stderr });
557
563
  } catch (err) {
558
564
  stderr.write(JSON.stringify({ ok: false, error: err.message, code: err.code ?? "UNKNOWN" }) + "\n");
@@ -567,4 +573,4 @@ if (isDirectCliRun(import.meta.url)) {
567
573
  });
568
574
  }
569
575
 
570
- export { main, parseCliArgs };
576
+ export { main, parseCliArgs, runCli };
@@ -1,37 +1,40 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from "node:fs";
3
- import path from "node:path";
4
- import { parse as parseYaml } from "yaml";
5
2
  import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
6
3
  import { runChild as _runChild } from "../_cli-primitives.mjs";
4
+ import { resolveSettings, parseProjectRef, findProject } from "./_resolve-project.mjs";
7
5
  import { parseArgs } from "node:util";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
7
+ import { loadStateColumnMap, LOGICAL_COLUMN } from "@dev-loops/core/loop/queue-board-sync";
8
8
 
9
- const USAGE = `Usage: dev-loops queue archive-done --repo <owner/name> [--project <number|id>] [--older-than <duration>] [--dry-run]
9
+ const USAGE = `Usage: dev-loops queue archive-done --repo <owner/name> [--project <number|id|board-uri>] [--older-than <duration>] [--dry-run]
10
10
  (dev-loops project archive-done … is a back-compat alias)
11
11
 
12
12
  Archive GitHub Projects V2 items whose issue/PR has been closed for at least the
13
13
  given duration. Operator-triggered (no webhooks). Uses archiveProjectV2Item.
14
14
 
15
15
  Options:
16
- --repo <owner/name> Required. Repository to scope the project search.
17
- --project <number|id> Project number (integer) or node ID. When omitted,
18
- resolved from .devloops queue.projectNumber /
19
- queue.boardTitle.
20
- --older-than <duration> Closed-for threshold. Format: <n><unit> where unit is
21
- h (hours), d (days), or w (weeks). Default resolves
22
- from .devloops queue.archiveOlderThanDays, else 7d.
23
- --dry-run Print the intended archive mutation(s) without executing.
24
- --help, -h Show this help.
16
+ --repo <owner/name> Required. Repository to scope the project search.
17
+ --project <number|id|board-uri> Project number, node ID, or board URI
18
+ (e.g. https://github.com/users/me/projects/3).
19
+ When omitted, resolved from .devloops
20
+ queue.projectNumber / queue.boardTitle.
21
+ --older-than <duration> Closed-for threshold. Format: <n><unit> where unit is
22
+ h (hours), d (days), or w (weeks). Default resolves
23
+ from .devloops queue.archiveOlderThanDays, else 7d.
24
+ --dry-run Print the intended archive mutation(s) without executing.
25
+ --help, -h Show this help.
25
26
 
26
27
  Output (stdout):
27
28
  JSON: { ok: true, olderThan, scanned, archivable, archived: [{ itemId, issueNumber, prNumber, closedAt }] }
28
29
  dry-run: { ok: true, dryRun: true, olderThan, scanned, archivable, mutations: [{ query, variables }] }
29
30
  (scanned = all repo board items; archivable = items selected for archival)
30
31
 
32
+ ${JQ_OUTPUT_USAGE}
33
+
31
34
  Exit codes:
32
35
  0 — success
33
36
  1 — usage or argument error
34
- 2 — GitHub API error
37
+ 2 — GitHub API error / invalid --jq filter
35
38
  3 — project not found
36
39
  `.trim();
37
40
 
@@ -53,6 +56,7 @@ function parseCliArgs(argv) {
53
56
  "older-than": { type: "string" },
54
57
  "dry-run": { type: "boolean" },
55
58
  help: { type: "boolean", short: "h" },
59
+ ...JQ_OUTPUT_PARSE_OPTIONS,
56
60
  },
57
61
  allowPositionals: true,
58
62
  strict: false,
@@ -88,8 +92,10 @@ function parseCliArgs(argv) {
88
92
  }
89
93
  args.dryRun = true;
90
94
  break;
91
- default:
95
+ default: {
96
+ if (matchJqOutputToken(token, args, (t) => requireValue(t, "--jq requires a filter", "INVALID_ARGS"))) break;
92
97
  throw Object.assign(new Error(`Unknown flag: ${token.rawName}`), { code: "INVALID_ARGS", usage: USAGE });
98
+ }
93
99
  }
94
100
  }
95
101
  return args;
@@ -99,7 +105,6 @@ function parseCliArgs(argv) {
99
105
 
100
106
  const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
101
107
  const REPO_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
102
- const GLOBAL_NODE_ID_RE = /^[A-Za-z0-9_]+$/;
103
108
 
104
109
  function validateRepo(repo) {
105
110
  if (!repo || typeof repo !== "string") {
@@ -117,21 +122,6 @@ function validateRepo(repo) {
117
122
  return repo;
118
123
  }
119
124
 
120
- function parseProjectRef(raw) {
121
- if (!raw || typeof raw !== "string" || raw.trim().length === 0) {
122
- throw Object.assign(new Error("--project is required"), { code: "INVALID_PROJECT" });
123
- }
124
- const trimmed = raw.trim();
125
- const asNum = Number(trimmed);
126
- if (Number.isInteger(asNum) && asNum > 0 && String(asNum) === trimmed) {
127
- return { kind: "number", value: asNum };
128
- }
129
- if (GLOBAL_NODE_ID_RE.test(trimmed) && trimmed !== "0") {
130
- return { kind: "id", value: trimmed };
131
- }
132
- throw Object.assign(new Error(`--project must be a positive integer or a node ID, got "${raw}"`), { code: "INVALID_PROJECT" });
133
- }
134
-
135
125
  const DURATION_RE = /^(\d+)(h|d|w)$/;
136
126
  const UNIT_MS = {
137
127
  h: 60 * 60 * 1000,
@@ -154,37 +144,6 @@ function parseDuration(raw) {
154
144
  return n * UNIT_MS[m[2]];
155
145
  }
156
146
 
157
- // ── Settings fallback ──────────────────────────────────────────────────────
158
-
159
- // Read .devloops (and extension variants) queue settings, mirroring the
160
- // resolution used by ensure-queue-board.mjs. Returns { project }, { title },
161
- // and/or { olderThanDays } when configured; never throws on a missing/bad file.
162
- function resolveSettings(cwd) {
163
- const basePath = path.join(cwd, ".devloops");
164
- const extensions = ["", ".yaml", ".yml", ".json"];
165
- for (const ext of extensions) {
166
- try {
167
- const raw = readFileSync(basePath + ext, "utf-8");
168
- const settings = ext === ".json" ? JSON.parse(raw) : parseYaml(raw);
169
- const queue = settings?.queue;
170
- if (!queue) return null;
171
- const out = {};
172
- if (typeof queue.projectNumber === "number" && Number.isInteger(queue.projectNumber) && queue.projectNumber > 0) {
173
- out.project = queue.projectNumber;
174
- } else if (typeof queue.boardTitle === "string" && queue.boardTitle.trim().length > 0) {
175
- out.title = queue.boardTitle.trim();
176
- }
177
- if (typeof queue.archiveOlderThanDays === "number" && Number.isInteger(queue.archiveOlderThanDays) && queue.archiveOlderThanDays > 0) {
178
- out.olderThanDays = queue.archiveOlderThanDays;
179
- }
180
- return out;
181
- } catch {
182
- // extension not present or unparseable — try next
183
- }
184
- }
185
- return null;
186
- }
187
-
188
147
  // ── API helpers ──────────────────────────────────────────────────────────
189
148
 
190
149
  async function ghGraphql(query, vars, env, runChild = _runChild) {
@@ -350,12 +309,14 @@ function normalizeItem(node) {
350
309
  // ── Selection logic (pure) ────────────────────────────────────────────────
351
310
 
352
311
  // Select items whose issue/PR is closed and has been closed for >= olderThanMs.
353
- function selectArchivable(items, { now, olderThanMs }) {
312
+ // `doneColumn` defaults to the literal "Done" for direct/pure-unit callers;
313
+ // main() always passes the configured column name (#1098, #1143).
314
+ function selectArchivable(items, { now, olderThanMs, doneColumn = "Done" }) {
354
315
  return items.filter((it) => {
355
316
  if (it.isArchived) return false;
356
317
  // Only archive items in the Done column — a closed issue/PR parked in
357
318
  // another column (Backlog/Next Up/In Progress) must be left untouched.
358
- if (it.status !== "Done") return false;
319
+ if (it.status !== doneColumn) return false;
359
320
  const c = it.content;
360
321
  if (!c || !c.closed || !c.closedAt) return false;
361
322
  const closedAtMs = Date.parse(c.closedAt);
@@ -375,9 +336,22 @@ function classifyExitCode(err) {
375
336
 
376
337
  // ── Main logic ──────────────────────────────────────────────────────────
377
338
 
378
- async function main(args, { env = process.env, runChild } = {}) {
339
+ async function main(args, { env = process.env, runChild, cwd = process.cwd() } = {}) {
379
340
  const child = runChild ?? _runChild;
380
341
  const repo = validateRepo(args.repo);
342
+ // Resolve the done column name through the SAME statusColumns mapping
343
+ // board-sync uses (#1098, #1143): a repo that renamed Done gets its
344
+ // configured column matched here, not the literal default. Fail CLOSED on a
345
+ // malformed `.devloops` — never silently archive against the literal "Done"
346
+ // and risk archiving nothing on a renamed/stale column.
347
+ const { columnNames, error: configError } = loadStateColumnMap(cwd);
348
+ if (configError) {
349
+ throw Object.assign(
350
+ new Error(`could not resolve done column (config read/parse error: ${configError})`),
351
+ { code: "CONFIG_ERROR" },
352
+ );
353
+ }
354
+ const doneColumn = columnNames[LOGICAL_COLUMN.DONE];
381
355
  const [owner] = repo.split("/");
382
356
  // Board: explicit --project ref wins; otherwise resolve by board title from
383
357
  // .devloops (passed in as args.projectTitle by runCli). Fail closed if neither.
@@ -396,22 +370,13 @@ async function main(args, { env = process.env, runChild } = {}) {
396
370
  const olderThanMs = parseDuration(olderThanRaw);
397
371
  const now = args.now ?? Date.now();
398
372
 
399
- const { kind: ownerKind } = await resolveOwner(owner, env, child);
400
- const projects = await listAllProjects(owner, ownerKind, env, child);
401
- const project = projectRef
402
- ? (projectRef.kind === "id"
403
- ? projects.find((p) => p.id === projectRef.value)
404
- : projects.find((p) => p.number === projectRef.value))
405
- : projects.find((p) => p.title === projectTitle);
406
- if (!project) {
407
- const desc = projectRef
408
- ? (projectRef.kind === "id" ? `"${projectRef.value}"` : `number ${projectRef.value}`)
409
- : `title "${projectTitle}"`;
410
- throw Object.assign(
411
- new Error(`Project ${desc} not found under owner "${owner}"`),
412
- { code: "PROJECT_NOT_FOUND" },
413
- );
414
- }
373
+ // URI refs encode owner+kind directly; skip the API round-trip for owner resolution.
374
+ const projectOwner = projectRef?.kind === "uri" ? projectRef.owner : owner;
375
+ const ownerKind = projectRef?.kind === "uri"
376
+ ? projectRef.ownerKind
377
+ : (await resolveOwner(owner, env, child)).kind;
378
+ const projects = await listAllProjects(projectOwner, ownerKind, env, child);
379
+ const project = findProject(projects, { projectRef, projectTitle }, projectOwner);
415
380
 
416
381
  const rawItems = await fetchAllItems(project.id, env, child);
417
382
  // Only consider items whose content belongs to the target repo (single-repo scope).
@@ -419,7 +384,7 @@ async function main(args, { env = process.env, runChild } = {}) {
419
384
  .filter((n) => n.content && n.content.repository?.nameWithOwner === repo)
420
385
  .map(normalizeItem);
421
386
 
422
- const archivable = selectArchivable(repoItems, { now, olderThanMs });
387
+ const archivable = selectArchivable(repoItems, { now, olderThanMs, doneColumn });
423
388
 
424
389
  const mutations = archivable.map((it) => ({
425
390
  query: ARCHIVE_ITEM,
@@ -489,8 +454,8 @@ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr,
489
454
  }
490
455
 
491
456
  try {
492
- const result = await main(args, { env });
493
- stdout.write(JSON.stringify(result) + "\n");
457
+ const result = await main(args, { env, cwd });
458
+ process.exitCode = emitResult(result, { jq: args.jq, silent: args.silent, stdout, stderr });
494
459
  } catch (err) {
495
460
  stderr.write(JSON.stringify({ ok: false, error: err.message, code: err.code ?? "UNKNOWN" }) + "\n");
496
461
  process.exitCode = classifyExitCode(err);
@@ -1,10 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from "node:fs";
3
- import path from "node:path";
4
2
  import { parseArgs } from "node:util";
5
- import { parse as parseYaml } from "yaml";
6
3
  import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
7
4
  import { runChild as _runChild } from "../_cli-primitives.mjs";
5
+ import { resolveSettings } from "./_resolve-project.mjs";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
8
7
 
9
8
  const USAGE = `Usage: dev-loops queue ensure --repo <owner/name> [--project <number>] [--title <title>] [--link-repo <owner/name>] [--repair-rename]
10
9
  (dev-loops project ensure … is a back-compat alias)
@@ -27,10 +26,12 @@ queue.projectNumber or queue.boardTitle.
27
26
  Output (stdout):
28
27
  JSON: { ok: true, project: { id, number, title, url, statusFieldId, linkedRepo } }
29
28
 
29
+ ${JQ_OUTPUT_USAGE}
30
+
30
31
  Exit codes:
31
32
  0 — board exists or was created successfully (idempotent)
32
33
  1 — usage or argument error
33
- 2 — GitHub API error
34
+ 2 — GitHub API error / invalid --jq filter
34
35
  3 — board schema/config mismatch (manual reconciliation needed)
35
36
  `;
36
37
 
@@ -54,6 +55,7 @@ function parseCliArgs(argv) {
54
55
  "link-repo": { type: "string" },
55
56
  "repair-rename": { type: "boolean" },
56
57
  help: { type: "boolean", short: "h" },
58
+ ...JQ_OUTPUT_PARSE_OPTIONS,
57
59
  },
58
60
  allowPositionals: true,
59
61
  strict: false,
@@ -98,8 +100,10 @@ function parseCliArgs(argv) {
98
100
  }
99
101
  args.repairRename = true;
100
102
  break;
101
- default:
103
+ default: {
104
+ if (matchJqOutputToken(token, args, (t) => requireValue(t, "--jq requires a filter"))) break;
102
105
  throw parseError(`Unknown flag: ${token.rawName}`);
106
+ }
103
107
  }
104
108
  }
105
109
  return args;
@@ -146,36 +150,6 @@ function validateRepo(repo) {
146
150
  return repo;
147
151
  }
148
152
 
149
- // ── Settings fallback ────────────────────────────────────────────────────
150
-
151
- function resolveSettings(cwd) {
152
- // Try bare .devloops and extension variants (.yaml, .yml, .json)
153
- // to match the config loader's consumer override detection.
154
- // .json uses strict JSON.parse; all others use the YAML parser.
155
- const basePath = path.join(cwd, ".devloops");
156
- const extensions = ["", ".yaml", ".yml", ".json"];
157
- for (const ext of extensions) {
158
- try {
159
- const settingsPath = basePath + ext;
160
- const raw = readFileSync(settingsPath, "utf-8");
161
- const settings = ext === ".json" ? JSON.parse(raw) : parseYaml(raw);
162
- const queue = settings?.queue;
163
- if (queue) {
164
- if (typeof queue.projectNumber === "number" && Number.isInteger(queue.projectNumber) && queue.projectNumber > 0) {
165
- return { project: queue.projectNumber };
166
- }
167
- if (typeof queue.boardTitle === "string" && queue.boardTitle.trim().length > 0) {
168
- return { title: queue.boardTitle.trim() };
169
- }
170
- }
171
- return null;
172
- } catch {
173
- // extension not present or unparseable — try next
174
- }
175
- }
176
- return null;
177
- }
178
-
179
153
  // ── API helpers ──────────────────────────────────────────────────────────
180
154
 
181
155
  async function ghGraphql(query, vars, env, runChild = _runChild) {
@@ -822,7 +796,7 @@ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr,
822
796
 
823
797
  try {
824
798
  const result = await main(args, { env });
825
- stdout.write(JSON.stringify(result) + "\n");
799
+ process.exitCode = emitResult(result, { jq: args.jq, silent: args.silent, stdout, stderr });
826
800
  } catch (err) {
827
801
  stderr.write(`${formatCliError(err)}\n`);
828
802
  process.exitCode = classifyExitCode(err);