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
@@ -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 };
@@ -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, parseItemRef } 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;
@@ -119,7 +129,6 @@ function parseCliArgs(argv) {
119
129
 
120
130
  const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
121
131
  const REPO_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
122
- const GLOBAL_NODE_ID_RE = /^[A-Za-z0-9_]+$/;
123
132
 
124
133
  function validateRepo(repo) {
125
134
  if (!repo || typeof repo !== "string") {
@@ -144,42 +153,6 @@ function validateRepo(repo) {
144
153
  return repo;
145
154
  }
146
155
 
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
- function parseItemRef(raw) {
166
- if (!raw || typeof raw !== "string" || raw.trim().length === 0) {
167
- throw Object.assign(new Error("--item is required"), { code: "INVALID_ITEM" });
168
- }
169
- const trimmed = raw.trim();
170
- const asNum = Number(trimmed);
171
- if (Number.isInteger(asNum) && asNum > 0 && String(asNum) === trimmed) {
172
- return { kind: "number", value: asNum };
173
- }
174
- if (trimmed === "0") {
175
- throw Object.assign(new Error(`--item must be a positive integer or an item node ID, got "${raw}"`), { code: "INVALID_ITEM" });
176
- }
177
- if (GLOBAL_NODE_ID_RE.test(trimmed)) {
178
- return { kind: "id", value: trimmed };
179
- }
180
- throw Object.assign(new Error(`--item must be a positive integer or an item node ID, got "${raw}"`), { code: "INVALID_ITEM" });
181
- }
182
-
183
156
  // ── API helpers ──────────────────────────────────────────────────────────
184
157
 
185
158
  async function ghGraphql(query, vars, env, runChild = _runChild) {
@@ -522,19 +495,17 @@ function classifyExitCode(err) {
522
495
 
523
496
  // ── Resolve owner + project (shared) ──────────────────────────────────────
524
497
 
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;
498
+ // Resolve the project based on a selector (from resolveProjectSelector).
499
+ // When the selector contains a URI ref, the URI-encoded owner overrides the
500
+ // repo-derived owner so cross-scope boards (user vs org) resolve unambiguously.
501
+ async function resolveProject(repoOwner, selector, env, child) {
502
+ const projectRef = selector.projectRef;
503
+ const effectiveOwner = projectRef?.kind === "uri" ? projectRef.owner : repoOwner;
504
+ const ownerKind = projectRef?.kind === "uri"
505
+ ? projectRef.ownerKind
506
+ : (await resolveOwner(repoOwner, env, child)).kind;
507
+ const projects = await listAllProjects(effectiveOwner, ownerKind, env, child);
508
+ return findProject(projects, selector, effectiveOwner);
538
509
  }
539
510
 
540
511
  function executePosition(projectId, itemId, afterId) {
@@ -551,7 +522,7 @@ function executePosition(projectId, itemId, afterId) {
551
522
  async function mainFlagForm(args, { env, child, repo, owner, repoName, project }) {
552
523
  const itemRef = parseItemRef(args.item);
553
524
  let afterRef = null;
554
- if (args.after !== undefined) afterRef = parseItemRef(args.after);
525
+ if (args.after !== undefined) afterRef = parseItemRef(args.after, "--after");
555
526
 
556
527
  const item = await resolveProjectItem(project.id, itemRef, owner, repoName, repo, env, child);
557
528
 
@@ -625,7 +596,7 @@ async function mainSubcommand(args, { env, child, repo, project }) {
625
596
  const items = await fetchAllItems(project.id, env, child);
626
597
 
627
598
  // Resolve all referenced items up-front (fail closed before any mutation).
628
- const refs = positional.map((p) => parseItemRef(p));
599
+ const refs = positional.map((p) => parseItemRef(p, "<ref>"));
629
600
  const resolved = refs.map((ref) => resolveFromItems(items, ref, repo));
630
601
 
631
602
  // Reordering positions within a single Status column. The before/after snapshot is scoped
@@ -742,7 +713,7 @@ async function main(args, { env = process.env, runChild } = {}) {
742
713
  const child = runChild ?? _runChild;
743
714
  const repo = validateRepo(args.repo);
744
715
  const [owner, repoName] = repo.split("/");
745
- const projectRef = parseProjectRef(args.project);
716
+ const selector = resolveProjectSelector(args);
746
717
 
747
718
  // Fail closed: the legacy flag form takes no positional arguments. A stray
748
719
  // token (e.g. `reorder 630 --item ...`) must not be silently ignored.
@@ -753,7 +724,7 @@ async function main(args, { env = process.env, runChild } = {}) {
753
724
  );
754
725
  }
755
726
 
756
- const project = await resolveProject(owner, projectRef, env, child);
727
+ const project = await resolveProject(owner, selector, env, child);
757
728
 
758
729
  if (args._subcommand) {
759
730
  return mainSubcommand(args, { env, child, repo, project });
@@ -763,7 +734,7 @@ async function main(args, { env = process.env, runChild } = {}) {
763
734
 
764
735
  // ── CLI entrypoint ──────────────────────────────────────────────────────
765
736
 
766
- async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env } = {}) {
737
+ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, cwd = process.cwd() } = {}) {
767
738
  let args;
768
739
  try {
769
740
  args = parseCliArgs(argv);
@@ -776,9 +747,13 @@ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr,
776
747
  stdout.write(USAGE);
777
748
  return;
778
749
  }
750
+
751
+ // Resolve the board from .devloops when --project is absent.
752
+ applyDevloopsBoard(args, cwd);
753
+
779
754
  try {
780
755
  const result = await main(args, { env });
781
- stdout.write(JSON.stringify(result) + "\n");
756
+ process.exitCode = emitResult(result, { jq: args.jq, silent: args.silent, stdout, stderr });
782
757
  } catch (err) {
783
758
  stderr.write(JSON.stringify({ ok: false, error: err.message, code: err.code ?? "UNKNOWN" }) + "\n");
784
759
  process.exitCode = classifyExitCode(err);