dev-loops 0.2.7 → 0.4.0

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 (76) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +1 -1
  3. package/.claude/agents/developer.md +1 -0
  4. package/.claude/agents/fixer.md +1 -0
  5. package/.claude/agents/review.md +30 -0
  6. package/.claude/hooks/_run-context.mjs +9 -16
  7. package/.claude/skills/copilot-pr-followup/SKILL.md +62 -6
  8. package/.claude/skills/dev-loop/SKILL.md +6 -6
  9. package/.claude/skills/docs/anti-patterns.md +2 -0
  10. package/.claude/skills/docs/copilot-loop-operations.md +3 -3
  11. package/.claude/skills/docs/issue-intake-procedure.md +4 -0
  12. package/.claude/skills/docs/merge-preconditions.md +65 -1
  13. package/.claude/skills/docs/stop-conditions.md +1 -0
  14. package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
  15. package/.claude/skills/local-implementation/SKILL.md +25 -5
  16. package/AGENTS.md +1 -1
  17. package/CHANGELOG.md +69 -0
  18. package/README.md +8 -2
  19. package/agents/developer.agent.md +1 -0
  20. package/agents/fixer.agent.md +1 -0
  21. package/agents/review.agent.md +30 -0
  22. package/cli/index.mjs +60 -8
  23. package/extension/README.md +1 -1
  24. package/package.json +3 -3
  25. package/scripts/README.md +8 -7
  26. package/scripts/_core-helpers.mjs +1 -0
  27. package/scripts/claude/headless-dev-loop.mjs +53 -13
  28. package/scripts/claude/headless-info-smoke.mjs +45 -11
  29. package/scripts/github/build-adjacent-bundle.mjs +448 -0
  30. package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
  31. package/scripts/github/detect-checkpoint-evidence.mjs +95 -4
  32. package/scripts/github/offer-human-handoff.mjs +147 -0
  33. package/scripts/github/post-gate-findings.mjs +392 -0
  34. package/scripts/github/probe-ci-status.mjs +468 -0
  35. package/scripts/github/reconcile-draft-gate.mjs +2 -2
  36. package/scripts/github/request-copilot-review.mjs +72 -11
  37. package/scripts/github/resolve-handoff-candidates.mjs +412 -0
  38. package/scripts/github/upsert-checkpoint-verdict.mjs +599 -17
  39. package/scripts/github/verify-fresh-review-context.mjs +12 -1
  40. package/scripts/github/write-gate-context.mjs +634 -0
  41. package/scripts/github/write-gate-findings-log.mjs +1 -1
  42. package/scripts/loop/_stale-runner-detection.mjs +1 -1
  43. package/scripts/loop/_worktree-path.mjs +27 -0
  44. package/scripts/loop/cleanup-worktree.mjs +175 -0
  45. package/scripts/loop/copilot-pr-handoff.mjs +1 -1
  46. package/scripts/loop/detect-change-scope.mjs +36 -11
  47. package/scripts/loop/detect-pr-gate-coordination-state.mjs +30 -18
  48. package/scripts/loop/detect-stale-runner.mjs +3 -4
  49. package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
  50. package/scripts/loop/ensure-worktree.mjs +219 -0
  51. package/scripts/loop/outer-loop.mjs +1 -1
  52. package/scripts/loop/pr-runner-coordination.mjs +1 -1
  53. package/scripts/loop/pre-flight-gate.mjs +10 -7
  54. package/scripts/loop/pre-push-main-guard.mjs +4 -4
  55. package/scripts/loop/provision-worktree.mjs +243 -0
  56. package/scripts/loop/resolve-dev-loop-startup.mjs +5 -5
  57. package/scripts/loop/run-queue.mjs +112 -16
  58. package/scripts/loop/run-watch-cycle.mjs +75 -22
  59. package/scripts/projects/add-queue-item.mjs +80 -48
  60. package/scripts/projects/archive-done-items.mjs +136 -39
  61. package/scripts/projects/ensure-queue-board.mjs +67 -65
  62. package/scripts/projects/list-queue-items.mjs +59 -57
  63. package/scripts/projects/move-queue-item.mjs +125 -125
  64. package/scripts/projects/reorder-queue-item.mjs +67 -48
  65. package/scripts/projects/sync-item-status.mjs +199 -0
  66. package/skills/copilot-pr-followup/SKILL.md +62 -6
  67. package/skills/dev-loop/SKILL.md +2 -2
  68. package/skills/dev-loop/scripts/log-bash-exit-1.mjs +2 -2
  69. package/skills/dev-loop/scripts/phase-files.mjs +2 -2
  70. package/skills/docs/anti-patterns.md +2 -0
  71. package/skills/docs/copilot-loop-operations.md +3 -3
  72. package/skills/docs/issue-intake-procedure.md +4 -0
  73. package/skills/docs/merge-preconditions.md +65 -1
  74. package/skills/docs/stop-conditions.md +1 -0
  75. package/skills/docs/tracker-first-loop-state.md +6 -6
  76. package/skills/local-implementation/SKILL.md +25 -5
@@ -14,10 +14,13 @@
14
14
  */
15
15
 
16
16
  import { fileURLToPath } from "node:url";
17
+ import { parseArgs } from "node:util";
17
18
  import { runQueue, DEFAULT_QUEUE_DRIVER_OPTIONS } from "@dev-loops/core/loop/queue-driver";
18
19
  import { computeParallelSchedule } from "@dev-loops/core/loop/queue-parallel";
19
20
  import { readQueue } from "@dev-loops/core/loop/queue-state";
21
+ import { reconcileBoardMembership } from "@dev-loops/core/loop/queue-membership";
20
22
  import { parsePositiveInteger } from "@dev-loops/core/cli/primitives";
23
+ import { loadDevLoopConfig, resolveEffectiveMergeAuthorizedFromLoad } from "@dev-loops/core/config";
21
24
 
22
25
  const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
23
26
 
@@ -27,7 +30,7 @@ const USAGE = `Usage:
27
30
  Run the dev-loop queue driver over entries in .pi/dev-loop-queue.json.
28
31
  Exit codes: 0 success, 1 error`.trim();
29
32
 
30
- function parseArgs(argv) {
33
+ function parseCliArgs(argv) {
31
34
  const args = {
32
35
  repo: null,
33
36
  mergeAuthorized: false,
@@ -37,27 +40,58 @@ function parseArgs(argv) {
37
40
  help: false,
38
41
  };
39
42
 
40
- for (let i = 0; i < argv.length; i++) {
41
- switch (argv[i]) {
42
- case "--repo":
43
- args.repo = argv[++i];
43
+ const { tokens } = parseArgs({
44
+ args: [...argv],
45
+ options: {
46
+ repo: { type: "string" },
47
+ "merge-authorized": { type: "boolean" },
48
+ parallel: { type: "boolean" },
49
+ "redispatch-max-retries": { type: "string" },
50
+ "max-parallel": { type: "string" },
51
+ help: { type: "boolean", short: "h" },
52
+ },
53
+ allowPositionals: true,
54
+ strict: false,
55
+ tokens: true,
56
+ });
57
+
58
+ for (const token of tokens) {
59
+ if (token.kind === "positional") {
60
+ throw new Error(`unknown argument: ${token.value}`);
61
+ }
62
+ if (token.kind !== "option") {
63
+ continue;
64
+ }
65
+ switch (token.name) {
66
+ case "repo":
67
+ args.repo = token.value;
44
68
  break;
45
- case "--merge-authorized":
69
+ case "merge-authorized":
70
+ if (token.value !== undefined) {
71
+ throw new Error(`unknown argument: ${token.rawName}=${token.value}`);
72
+ }
46
73
  args.mergeAuthorized = true;
47
74
  break;
48
- case "--parallel":
75
+ case "parallel":
76
+ if (token.value !== undefined) {
77
+ throw new Error(`unknown argument: ${token.rawName}=${token.value}`);
78
+ }
49
79
  args.parallel = true;
50
80
  break;
51
- case "--redispatch-max-retries":
52
- args.reDispatchMaxRetries = parsePositiveInteger(argv[++i], "--redispatch-max-retries");
81
+ case "redispatch-max-retries":
82
+ args.reDispatchMaxRetries = parsePositiveInteger(token.value, "--redispatch-max-retries");
53
83
  break;
54
- case "--max-parallel":
55
- args.maxParallel = parsePositiveInteger(argv[++i], "--max-parallel");
84
+ case "max-parallel":
85
+ args.maxParallel = parsePositiveInteger(token.value, "--max-parallel");
56
86
  break;
57
- case "--help":
58
- case "-h":
87
+ case "help":
88
+ if (token.value !== undefined) {
89
+ throw new Error(`unknown argument: ${token.rawName}=${token.value}`);
90
+ }
59
91
  args.help = true;
60
92
  break;
93
+ default:
94
+ throw new Error(`unknown argument: ${token.rawName}`);
61
95
  }
62
96
  }
63
97
 
@@ -65,7 +99,7 @@ function parseArgs(argv) {
65
99
  }
66
100
 
67
101
  async function main() {
68
- const args = parseArgs(process.argv.slice(2));
102
+ const args = parseCliArgs(process.argv.slice(2));
69
103
 
70
104
  if (args.help) {
71
105
  console.log(USAGE);
@@ -79,7 +113,46 @@ async function main() {
79
113
 
80
114
  const queue = await readQueue(REPO_ROOT);
81
115
 
82
- if (queue.entries.length === 0) {
116
+ // A configured GitHub Projects board is the authoritative queue MEMBERSHIP
117
+ // source (issue #864): fold its "Next Up" items into the queue before judging
118
+ // emptiness so a populated board with an empty local queue is no longer a
119
+ // silent no-op. Fail-open — a board hiccup falls back to the local queue.
120
+ // reconcileBoardMembership already logs an "added N ... from board Next Up"
121
+ // line to stderr (single source of truth); we deliberately do not duplicate
122
+ // it here to avoid noise for JSON consumers of stdout.
123
+ const membership = await reconcileBoardMembership(REPO_ROOT, args.repo, queue);
124
+
125
+ if (membership.emptiness === "board_empty") {
126
+ // Distinct from the legacy generic "Queue is empty": the board is the
127
+ // membership source and it currently has nothing in Next Up. This branch is
128
+ // only reached for a genuinely empty Next Up (reason == null), never for a
129
+ // resolution failure (which falls through to the local queue below).
130
+ console.log(JSON.stringify({
131
+ ok: true,
132
+ message: "Board configured but Next Up is empty; nothing to run",
133
+ boardConfigured: true,
134
+ reason: null,
135
+ results: [],
136
+ }));
137
+ return;
138
+ }
139
+
140
+ if (membership.emptiness === "board_unavailable") {
141
+ // The board IS configured but Next Up resolution failed (fail-open) and the
142
+ // local queue had nothing to fall back to. Do NOT claim "Next Up is empty";
143
+ // surface the real reason so consumers can distinguish an outage from an
144
+ // intentionally empty board.
145
+ console.log(JSON.stringify({
146
+ ok: true,
147
+ message: `Board configured but unavailable (${membership.reason}); nothing to run`,
148
+ boardConfigured: true,
149
+ reason: membership.reason ?? null,
150
+ results: [],
151
+ }));
152
+ return;
153
+ }
154
+
155
+ if (membership.emptiness === "queue_empty") {
83
156
  console.log(JSON.stringify({ ok: true, message: "Queue is empty", results: [] }));
84
157
  return;
85
158
  }
@@ -109,9 +182,32 @@ async function main() {
109
182
  console.error("Parallel dispatch via async subagents not yet wired; falling back to sequential.");
110
183
  }
111
184
 
185
+ // Authoritative merge-authorization gate: when the repo enforces
186
+ // humanMergeOnly, --merge-authorized is ignored (fails closed) so the queue
187
+ // driver never auto-merges. `loadDevLoopConfig` never throws — it returns an
188
+ // `errors` array — so a try/catch would not catch an unreadable/invalid
189
+ // `.devloops` (which may be the very file declaring humanMergeOnly). FAIL
190
+ // CLOSED on any config load/validation error: if the config cannot be
191
+ // confirmed, do NOT grant merge authorization. A compliance invariant must
192
+ // never be silently dropped because its config could not be read.
193
+ let effectiveMergeAuthorized = args.mergeAuthorized;
194
+ if (args.mergeAuthorized) {
195
+ const load = await loadDevLoopConfig({ repoRoot: REPO_ROOT });
196
+ effectiveMergeAuthorized = resolveEffectiveMergeAuthorizedFromLoad(args.mergeAuthorized, load);
197
+ if ((load.errors?.length ?? 0) > 0) {
198
+ console.error(
199
+ JSON.stringify({
200
+ ok: true,
201
+ warning: "dev-loop config could not be loaded/validated; failing closed on merge authorization (not auto-merging).",
202
+ errors: load.errors.map((e) => (e && e.message) || String(e)),
203
+ }),
204
+ );
205
+ }
206
+ }
207
+
112
208
  const result = await runQueue(REPO_ROOT, args.repo, {
113
209
  ...DEFAULT_QUEUE_DRIVER_OPTIONS,
114
- mergeAuthorized: args.mergeAuthorized,
210
+ mergeAuthorized: effectiveMergeAuthorized,
115
211
  reDispatchMaxRetries: args.reDispatchMaxRetries,
116
212
  });
117
213
 
@@ -5,7 +5,10 @@ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helper
5
5
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
6
  import { DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION } from "@dev-loops/core/loop/public-dev-loop-routing";
7
7
  import { watchCopilotReview } from "../github/probe-copilot-review.mjs";
8
+ import { watchCiStatus } from "../github/probe-ci-status.mjs";
8
9
  import { runHandoff } from "./copilot-pr-handoff.mjs";
10
+ import { STATE } from "@dev-loops/core/loop/copilot-loop-state";
11
+ import { DEFAULT_POLL_INTERVAL_MS } from "@dev-loops/core/loop/policy-constants";
9
12
  import { detectCopilotSessionActivity } from "./detect-copilot-session-activity.mjs";
10
13
  import { parseArgs } from "node:util";
11
14
  import {
@@ -124,16 +127,33 @@ function buildWatchCycleContractTrace({
124
127
  cycleDisposition,
125
128
  sessionActivity = null,
126
129
  workflowRunWatch = null,
130
+ ciWatchArgs = null,
131
+ ciWatchStatus = null,
127
132
  }) {
128
- const boundaryClassification = handoff.action !== "watch"
129
- ? (handoff.loopDisposition === "blocked"
133
+ // The CI-watch branch (waiting_for_ci) routes through probe-ci-status.mjs even
134
+ // though handoff.action !== "watch". It is an observational wait, so it mirrors
135
+ // the Copilot watch classification: a quiet timeout is a HEALTHY_WAIT, while
136
+ // success/failure/changed route a follow-up. Without this the boundary-action
137
+ // path below would misclassify a CI timeout as routed_followup.
138
+ const isCiWatch = ciWatchArgs !== null;
139
+ const isWatchBoundary = handoff.action === "watch" || isCiWatch;
140
+ const observedStatus = isCiWatch ? ciWatchStatus : watchStatus;
141
+ const boundaryClassification = isWatchBoundary
142
+ ? (observedStatus === "timeout" || observedStatus === "idle"
143
+ ? DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.HEALTHY_WAIT
144
+ : DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.ROUTED_FOLLOWUP)
145
+ : handoff.loopDisposition === "blocked"
130
146
  ? DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.BLOCKED
131
147
  : handoff.terminal
132
148
  ? DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.TERMINAL
133
- : DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.ROUTED_FOLLOWUP)
134
- : watchStatus === "changed"
135
- ? DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.ROUTED_FOLLOWUP
136
- : DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.HEALTHY_WAIT;
149
+ : DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.ROUTED_FOLLOWUP;
150
+ const healthyWait = boundaryClassification === DEV_LOOP_CONTRACT_TRACE_CLASSIFICATION.HEALTHY_WAIT;
151
+ const helper = handoff.action === "watch"
152
+ ? "scripts/github/probe-copilot-review.mjs"
153
+ : isCiWatch
154
+ ? "scripts/github/probe-ci-status.mjs"
155
+ : null;
156
+ const effectiveArgs = isCiWatch ? ciWatchArgs : watchArgs;
137
157
  return {
138
158
  handoff: {
139
159
  action: handoff.action,
@@ -142,38 +162,37 @@ function buildWatchCycleContractTrace({
142
162
  terminal: Boolean(handoff.terminal),
143
163
  },
144
164
  waitStrategy: {
145
- helper: handoff.action === "watch" ? "scripts/github/probe-copilot-review.mjs" : null,
146
- mode: handoff.action === "watch"
147
- ? "persistent_watch"
148
- : "not_applicable",
149
- effectiveTimeoutMs: watchArgs?.timeoutMs ?? null,
150
- effectivePollIntervalMs: watchArgs?.pollIntervalMs ?? null,
165
+ helper,
166
+ mode: isWatchBoundary ? "persistent_watch" : "not_applicable",
167
+ effectiveTimeoutMs: effectiveArgs?.timeoutMs ?? null,
168
+ effectivePollIntervalMs: effectiveArgs?.pollIntervalMs ?? null,
151
169
  timeoutPolicyClassification: watchTimeoutPolicy?.classification ?? null,
152
170
  },
153
171
  orchestration: {
154
172
  emittedWatchArgs: handoff.watchArgs ?? null,
155
- effectiveWatchArgs: watchArgs,
173
+ effectiveWatchArgs: effectiveArgs,
174
+ ciWatchArgs,
156
175
  sessionActivity,
157
176
  workflowRunWatch,
158
177
  },
159
- stateRefresh: handoff.action === "watch"
178
+ stateRefresh: isWatchBoundary
160
179
  ? {
161
180
  boundaryKind: "post_watch_or_probe",
162
- observedStatus: watchStatus,
181
+ observedStatus,
163
182
  refreshRequired: true,
164
- refreshReason: watchStatus === "changed"
165
- ? "Watch boundaries with fresh activity require an authoritative state refresh before routing the follow-up path."
166
- : "Healthy watch boundaries are observational only; refresh authoritative state before treating timeout/idle as stop or completion.",
183
+ refreshReason: healthyWait
184
+ ? "Healthy watch boundaries are observational only; refresh authoritative state before treating timeout/idle as stop or completion."
185
+ : "Watch boundaries with fresh activity require an authoritative state refresh before routing the follow-up path.",
167
186
  }
168
187
  : null,
169
188
  stopReason: {
170
189
  classification: boundaryClassification,
171
190
  terminal: Boolean(handoff.terminal),
172
191
  cycleDisposition,
173
- reason: handoff.action === "watch"
174
- ? (watchStatus === "changed"
175
- ? "Fresh watcher activity requires follow-up instead of staying in a healthy wait boundary."
176
- : "Quiet watcher boundaries remain healthy waits and must not be treated as terminal completion by themselves.")
192
+ reason: isWatchBoundary
193
+ ? (healthyWait
194
+ ? "Quiet watcher boundaries remain healthy waits and must not be treated as terminal completion by themselves."
195
+ : "Fresh watcher activity requires follow-up instead of staying in a healthy wait boundary.")
177
196
  : handoff.nextAction,
178
197
  },
179
198
  };
@@ -236,6 +255,7 @@ export async function runWatchCycle(
236
255
  ghCommand = "gh",
237
256
  runHandoffImpl = runHandoff,
238
257
  watchCopilotReviewImpl = watchCopilotReview,
258
+ watchCiStatusImpl = watchCiStatus,
239
259
  detectCopilotSessionActivityImpl = detectCopilotSessionActivity,
240
260
  fetchPrHeadBranchImpl = fetchPrHeadBranch,
241
261
  watchWorkflowRunImpl = watchWorkflowRun,
@@ -267,6 +287,39 @@ export async function runWatchCycle(
267
287
  if (handoff.watchTimeoutPolicy !== undefined) {
268
288
  result.watchTimeoutPolicy = handoff.watchTimeoutPolicy;
269
289
  }
290
+ // Provider-agnostic CI wait (#917): a waiting_for_ci boundary would otherwise
291
+ // dead-end at action:"stop". Route it to the helper-owned CI watcher
292
+ // (CircleCI / GH Actions / external commit-status), not gh run watch.
293
+ if (handoff.action !== "watch" && handoff.state === STATE.WAITING_FOR_CI) {
294
+ // Mirror determineWatchTimeout but with a CI-specific context label so
295
+ // timeout diagnostics read "CI wait" instead of "Copilot review wait".
296
+ const ciTimeoutMs = enforceExternalHealthyWaitTimeout({
297
+ timeoutMs: EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY.defaultTimeoutMs,
298
+ contextLabel: "CI wait",
299
+ });
300
+ const ciWatchArgs = {
301
+ repo: options.repo,
302
+ pr: options.pr,
303
+ pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
304
+ timeoutMs: ciTimeoutMs,
305
+ };
306
+ const ciWatch = await watchCiStatusImpl(ciWatchArgs, { env, ghCommand });
307
+ result.ciWatchArgs = ciWatchArgs;
308
+ result.ciWatch = ciWatch;
309
+ result.watchStatus = ciWatch.status;
310
+ // success/failure/changed all need authoritative re-detection (follow-up);
311
+ // a quiet timeout stays a healthy pending wait.
312
+ result.cycleDisposition = ciWatch.status === "timeout" ? "pending" : "needs_followup";
313
+ result.terminal = false;
314
+ result.contractTrace = buildWatchCycleContractTrace({
315
+ handoff,
316
+ watchTimeoutPolicy: result.watchTimeoutPolicy ?? null,
317
+ cycleDisposition: result.cycleDisposition,
318
+ ciWatchArgs,
319
+ ciWatchStatus: ciWatch.status,
320
+ });
321
+ return result;
322
+ }
270
323
  if (handoff.action !== "watch") {
271
324
  result.contractTrace = buildWatchCycleContractTrace({
272
325
  handoff,
@@ -1,8 +1,10 @@
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 { parseArgs } from "node:util";
4
5
 
5
- const USAGE = `Usage: dev-loops project add --repo <owner/name> --project <number|id> --item <number>
6
+ const USAGE = `Usage: dev-loops queue add --repo <owner/name> --project <number|id> --item <number>
7
+ dev-loops project add … (back-compat alias for "queue add")
6
8
 
7
9
  Add an existing issue or PR to a GitHub Projects V2 board.
8
10
 
@@ -10,7 +12,8 @@ Options:
10
12
  --repo <owner/name> Required. Repository containing the issue/PR.
11
13
  --project <number|id> Required. Project number (integer) or node ID.
12
14
  --item <number> Required. Issue or PR number to add.
13
- --status <name> Initial Status column (default: "Backlog").
15
+ --column <name> Initial Status column (default: "Backlog").
16
+ --status <name> Back-compat alias for --column.
14
17
  --help, -h Show this help.
15
18
 
16
19
  Output (stdout):
@@ -23,52 +26,75 @@ Exit codes:
23
26
  3 — project, field, column, or issue/PR not found
24
27
  `.trim();
25
28
 
26
- const VALID_ARGS = new Set(["--repo", "--project", "--item", "--status", "--help", "-h"]);
29
+ function parseCliArgs(argv) {
30
+ const parseError = (message) => Object.assign(new Error(message), { usage: USAGE });
31
+ const requireValue = (token, message) => {
32
+ const v = token.value;
33
+ if (typeof v !== "string" || v.length === 0 || v.startsWith("-")) {
34
+ throw parseError(message);
35
+ }
36
+ return v;
37
+ };
27
38
 
28
- function parseArgs(argv) {
29
39
  const args = {};
30
- for (let i = 0; i < argv.length; i++) {
31
- const arg = argv[i];
32
- if (!VALID_ARGS.has(arg) && arg.startsWith("-")) {
33
- throw Object.assign(
34
- new Error(`Unknown flag: ${arg}`),
35
- { code: "INVALID_ARGS", usage: USAGE },
36
- );
40
+ const { tokens } = parseArgs({
41
+ args: [...argv],
42
+ options: {
43
+ repo: { type: "string" },
44
+ project: { type: "string" },
45
+ item: { type: "string" },
46
+ column: { type: "string" },
47
+ status: { type: "string" },
48
+ help: { type: "boolean", short: "h" },
49
+ },
50
+ allowPositionals: true,
51
+ strict: false,
52
+ tokens: true,
53
+ });
54
+
55
+ for (const token of tokens) {
56
+ if (token.kind === "positional") {
57
+ throw Object.assign(new Error(`Unexpected argument: ${token.value}`), { code: "INVALID_ARGS", usage: USAGE });
37
58
  }
38
- if (arg === "--repo") {
39
- if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
40
- throw Object.assign(new Error("--repo requires a value (owner/name)"), { code: "INVALID_REPO" });
41
- }
42
- args.repo = argv[++i];
43
- } else if (arg === "--project") {
44
- if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
45
- throw Object.assign(new Error("--project requires a value (number or node ID)"), { code: "INVALID_PROJECT" });
46
- }
47
- args.project = argv[++i];
48
- } else if (arg === "--item") {
49
- if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
50
- throw Object.assign(new Error("--item requires a value (number)"), { code: "INVALID_ITEM" });
51
- }
52
- const val = Number(argv[++i]);
53
- if (!Number.isInteger(val) || val < 1) {
54
- throw Object.assign(
55
- new Error(`--item must be a positive integer, got "${argv[i]}"`),
56
- { code: "INVALID_ITEM" },
57
- );
58
- }
59
- args.item = val;
60
- } else if (arg === "--status") {
61
- if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
62
- throw Object.assign(new Error("--status requires a value"), { code: "INVALID_STATUS" });
59
+ if (token.kind !== "option") {
60
+ continue;
61
+ }
62
+ switch (token.name) {
63
+ case "help":
64
+ if (token.value !== undefined) {
65
+ throw Object.assign(new Error(`Unknown flag: ${token.rawName}=${token.value}`), { code: "INVALID_ARGS", usage: USAGE });
66
+ }
67
+ args.help = true;
68
+ break;
69
+ case "repo":
70
+ args.repo = requireValue(token, "--repo requires a value (owner/name)");
71
+ break;
72
+ case "project":
73
+ args.project = requireValue(token, "--project requires a value (number or node ID)");
74
+ break;
75
+ case "item": {
76
+ const raw = requireValue(token, "--item requires a value (number)");
77
+ const val = Number(raw);
78
+ if (!Number.isInteger(val) || val < 1) {
79
+ throw Object.assign(
80
+ new Error(`--item must be a positive integer, got "${raw}"`),
81
+ { code: "INVALID_ITEM" },
82
+ );
83
+ }
84
+ args.item = val;
85
+ break;
63
86
  }
64
- args.status = argv[++i];
65
- } else if (arg === "--help" || arg === "-h") {
66
- args.help = true;
67
- } else {
68
- throw Object.assign(
69
- new Error(`Unexpected argument: ${arg}`),
70
- { code: "INVALID_ARGS", usage: USAGE },
71
- );
87
+ case "column":
88
+ args.column = requireValue(token, "--column requires a value");
89
+ break;
90
+ case "status":
91
+ // Back-compat alias for --column (issue #912). Kept separate so a
92
+ // conflicting `--column X --status Y` is rejected rather than silently
93
+ // resolved by argv order.
94
+ args.status = requireValue(token, "--status requires a value");
95
+ break;
96
+ default:
97
+ throw Object.assign(new Error(`Unknown flag: ${token.rawName}`), { code: "INVALID_ARGS", usage: USAGE });
72
98
  }
73
99
  }
74
100
  return args;
@@ -358,9 +384,15 @@ async function main(args, { env = process.env, runChild } = {}) {
358
384
  if (!Number.isInteger(itemNumber) || itemNumber < 1) {
359
385
  throw Object.assign(new Error("--item is required and must be a positive integer"), { code: "INVALID_ITEM" });
360
386
  }
361
- const targetStatus = (args.status ?? "Backlog").trim();
387
+ if (args.column != null && args.status != null && args.column.trim() !== args.status.trim()) {
388
+ throw Object.assign(
389
+ new Error(`Conflicting --column ("${args.column}") and --status ("${args.status}") — pass only one (prefer --column).`),
390
+ { code: "INVALID_ARGS", usage: USAGE },
391
+ );
392
+ }
393
+ const targetStatus = (args.column ?? args.status ?? "Backlog").trim();
362
394
  if (!targetStatus) {
363
- throw Object.assign(new Error("--status must not be empty"), { code: "INVALID_STATUS" });
395
+ throw Object.assign(new Error("--column must not be empty"), { code: "INVALID_STATUS" });
364
396
  }
365
397
 
366
398
  // 1. Resolve owner
@@ -499,7 +531,7 @@ async function main(args, { env = process.env, runChild } = {}) {
499
531
  async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env } = {}) {
500
532
  let args;
501
533
  try {
502
- args = parseArgs(argv);
534
+ args = parseCliArgs(argv);
503
535
  } catch (err) {
504
536
  stderr.write(`${formatCliError(err)}\n`);
505
537
  process.exitCode = 1;
@@ -525,4 +557,4 @@ if (isDirectCliRun(import.meta.url)) {
525
557
  });
526
558
  }
527
559
 
528
- export { main };
560
+ export { main, parseCliArgs };