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
@@ -1,17 +1,25 @@
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";
2
5
  import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
3
6
  import { runChild as _runChild } from "../_cli-primitives.mjs";
7
+ import { parseArgs } from "node:util";
4
8
 
5
- const USAGE = `Usage: dev-loops project 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>] [--older-than <duration>] [--dry-run]
10
+ (dev-loops project archive-done … is a back-compat alias)
6
11
 
7
12
  Archive GitHub Projects V2 items whose issue/PR has been closed for at least the
8
13
  given duration. Operator-triggered (no webhooks). Uses archiveProjectV2Item.
9
14
 
10
15
  Options:
11
16
  --repo <owner/name> Required. Repository to scope the project search.
12
- --project <number|id> Required. Project number (integer) or node ID.
17
+ --project <number|id> Project number (integer) or node ID. When omitted,
18
+ resolved from .devloops queue.projectNumber /
19
+ queue.boardTitle.
13
20
  --older-than <duration> Closed-for threshold. Format: <n><unit> where unit is
14
- h (hours), d (days), or w (weeks). Default: 30d.
21
+ h (hours), d (days), or w (weeks). Default resolves
22
+ from .devloops queue.archiveOlderThanDays, else 7d.
15
23
  --dry-run Print the intended archive mutation(s) without executing.
16
24
  --help, -h Show this help.
17
25
 
@@ -27,36 +35,61 @@ Exit codes:
27
35
  3 — project not found
28
36
  `.trim();
29
37
 
30
- const VALID_ARGS = new Set(["--repo", "--project", "--older-than", "--dry-run", "--help", "-h"]);
38
+ function parseCliArgs(argv) {
39
+ const requireValue = (token, message, code) => {
40
+ const v = token.value;
41
+ if (typeof v !== "string" || v.length === 0 || v.startsWith("-")) {
42
+ throw Object.assign(new Error(message), { code });
43
+ }
44
+ return v;
45
+ };
31
46
 
32
- function parseArgs(argv) {
33
47
  const args = {};
34
- for (let i = 0; i < argv.length; i++) {
35
- const arg = argv[i];
36
- if (!VALID_ARGS.has(arg) && arg.startsWith("-")) {
37
- throw Object.assign(new Error(`Unknown flag: ${arg}`), { code: "INVALID_ARGS", usage: USAGE });
48
+ const { tokens } = parseArgs({
49
+ args: [...argv],
50
+ options: {
51
+ repo: { type: "string" },
52
+ project: { type: "string" },
53
+ "older-than": { type: "string" },
54
+ "dry-run": { type: "boolean" },
55
+ help: { type: "boolean", short: "h" },
56
+ },
57
+ allowPositionals: true,
58
+ strict: false,
59
+ tokens: true,
60
+ });
61
+
62
+ for (const token of tokens) {
63
+ if (token.kind === "positional") {
64
+ throw Object.assign(new Error(`Unexpected argument: ${token.value}`), { code: "INVALID_ARGS", usage: USAGE });
38
65
  }
39
- if (arg === "--repo") {
40
- if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
41
- throw Object.assign(new Error("--repo requires a value (owner/name)"), { code: "INVALID_REPO" });
42
- }
43
- args.repo = argv[++i];
44
- } else if (arg === "--project") {
45
- if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
46
- throw Object.assign(new Error("--project requires a value (number or node ID)"), { code: "INVALID_PROJECT" });
47
- }
48
- args.project = argv[++i];
49
- } else if (arg === "--older-than") {
50
- if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
51
- throw Object.assign(new Error("--older-than requires a value (e.g. 30d)"), { code: "INVALID_DURATION" });
52
- }
53
- args.olderThan = argv[++i];
54
- } else if (arg === "--dry-run") {
55
- args.dryRun = true;
56
- } else if (arg === "--help" || arg === "-h") {
57
- args.help = true;
58
- } else {
59
- throw Object.assign(new Error(`Unexpected argument: ${arg}`), { code: "INVALID_ARGS", usage: USAGE });
66
+ if (token.kind !== "option") {
67
+ continue;
68
+ }
69
+ switch (token.name) {
70
+ case "help":
71
+ if (token.value !== undefined) {
72
+ throw Object.assign(new Error(`Unknown flag: ${token.rawName}=${token.value}`), { code: "INVALID_ARGS", usage: USAGE });
73
+ }
74
+ args.help = true;
75
+ break;
76
+ case "repo":
77
+ args.repo = requireValue(token, "--repo requires a value (owner/name)", "INVALID_REPO");
78
+ break;
79
+ case "project":
80
+ args.project = requireValue(token, "--project requires a value (number or node ID)", "INVALID_PROJECT");
81
+ break;
82
+ case "older-than":
83
+ args.olderThan = requireValue(token, "--older-than requires a value (e.g. 30d)", "INVALID_DURATION");
84
+ break;
85
+ case "dry-run":
86
+ if (token.value !== undefined) {
87
+ throw Object.assign(new Error(`Unknown flag: ${token.rawName}=${token.value}`), { code: "INVALID_ARGS", usage: USAGE });
88
+ }
89
+ args.dryRun = true;
90
+ break;
91
+ default:
92
+ throw Object.assign(new Error(`Unknown flag: ${token.rawName}`), { code: "INVALID_ARGS", usage: USAGE });
60
93
  }
61
94
  }
62
95
  return args;
@@ -121,6 +154,37 @@ function parseDuration(raw) {
121
154
  return n * UNIT_MS[m[2]];
122
155
  }
123
156
 
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
+
124
188
  // ── API helpers ──────────────────────────────────────────────────────────
125
189
 
126
190
  async function ghGraphql(query, vars, env, runChild = _runChild) {
@@ -289,6 +353,9 @@ function normalizeItem(node) {
289
353
  function selectArchivable(items, { now, olderThanMs }) {
290
354
  return items.filter((it) => {
291
355
  if (it.isArchived) return false;
356
+ // Only archive items in the Done column — a closed issue/PR parked in
357
+ // another column (Backlog/Next Up/In Progress) must be left untouched.
358
+ if (it.status !== "Done") return false;
292
359
  const c = it.content;
293
360
  if (!c || !c.closed || !c.closedAt) return false;
294
361
  const closedAtMs = Date.parse(c.closedAt);
@@ -312,19 +379,36 @@ async function main(args, { env = process.env, runChild } = {}) {
312
379
  const child = runChild ?? _runChild;
313
380
  const repo = validateRepo(args.repo);
314
381
  const [owner] = repo.split("/");
315
- const projectRef = parseProjectRef(args.project);
316
- const olderThanRaw = args.olderThan ?? "30d";
382
+ // Board: explicit --project ref wins; otherwise resolve by board title from
383
+ // .devloops (passed in as args.projectTitle by runCli). Fail closed if neither.
384
+ const hasProjectRef = typeof args.project === "string" && args.project.trim().length > 0;
385
+ const projectRef = hasProjectRef ? parseProjectRef(args.project) : null;
386
+ const projectTitle = !hasProjectRef && typeof args.projectTitle === "string" && args.projectTitle.trim().length > 0
387
+ ? args.projectTitle.trim()
388
+ : null;
389
+ if (!projectRef && !projectTitle) {
390
+ throw Object.assign(
391
+ new Error("--project is required (or configure queue.projectNumber / queue.boardTitle in .devloops)"),
392
+ { code: "INVALID_PROJECT" },
393
+ );
394
+ }
395
+ const olderThanRaw = args.olderThan ?? args.olderThanDefault ?? "7d";
317
396
  const olderThanMs = parseDuration(olderThanRaw);
318
397
  const now = args.now ?? Date.now();
319
398
 
320
399
  const { kind: ownerKind } = await resolveOwner(owner, env, child);
321
400
  const projects = await listAllProjects(owner, ownerKind, env, child);
322
- const project = projectRef.kind === "id"
323
- ? projects.find((p) => p.id === projectRef.value)
324
- : projects.find((p) => p.number === projectRef.value);
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);
325
406
  if (!project) {
407
+ const desc = projectRef
408
+ ? (projectRef.kind === "id" ? `"${projectRef.value}"` : `number ${projectRef.value}`)
409
+ : `title "${projectTitle}"`;
326
410
  throw Object.assign(
327
- new Error(`Project ${projectRef.kind === "id" ? `"${projectRef.value}"` : `number ${projectRef.value}`} not found under owner "${owner}"`),
411
+ new Error(`Project ${desc} not found under owner "${owner}"`),
328
412
  { code: "PROJECT_NOT_FOUND" },
329
413
  );
330
414
  }
@@ -378,10 +462,10 @@ async function main(args, { env = process.env, runChild } = {}) {
378
462
 
379
463
  // ── CLI entrypoint ──────────────────────────────────────────────────────
380
464
 
381
- async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env } = {}) {
465
+ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, cwd = process.cwd() } = {}) {
382
466
  let args;
383
467
  try {
384
- args = parseArgs(argv);
468
+ args = parseCliArgs(argv);
385
469
  } catch (err) {
386
470
  stderr.write(`${formatCliError(err)}\n`);
387
471
  process.exitCode = 1;
@@ -391,6 +475,19 @@ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr,
391
475
  stdout.write(USAGE);
392
476
  return;
393
477
  }
478
+
479
+ // Resolve board + threshold defaults from .devloops when the flags are absent.
480
+ // Precedence: explicit --project flag > queue.projectNumber/boardTitle.
481
+ // explicit --older-than flag > queue.archiveOlderThanDays > 7d.
482
+ const settings = resolveSettings(cwd);
483
+ if (args.project === undefined && settings) {
484
+ if (settings.project) args.project = String(settings.project);
485
+ else if (settings.title) args.projectTitle = settings.title;
486
+ }
487
+ if (args.olderThan === undefined && settings?.olderThanDays) {
488
+ args.olderThanDefault = `${settings.olderThanDays}d`;
489
+ }
490
+
394
491
  try {
395
492
  const result = await main(args, { env });
396
493
  stdout.write(JSON.stringify(result) + "\n");
@@ -407,4 +504,4 @@ if (isDirectCliRun(import.meta.url)) {
407
504
  });
408
505
  }
409
506
 
410
- export { main, parseDuration, selectArchivable };
507
+ export { main, parseCliArgs, parseDuration, selectArchivable, resolveSettings, runCli };
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
+ import { parseArgs } from "node:util";
4
5
  import { parse as parseYaml } from "yaml";
5
6
  import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
6
7
  import { runChild as _runChild } from "../_cli-primitives.mjs";
7
8
 
8
- const USAGE = `Usage: dev-loops project ensure --repo <owner/name> [--project <number>] [--title <title>] [--link-repo <owner/name>] [--repair-rename]
9
+ const USAGE = `Usage: dev-loops queue ensure --repo <owner/name> [--project <number>] [--title <title>] [--link-repo <owner/name>] [--repair-rename]
10
+ (dev-loops project ensure … is a back-compat alias)
9
11
 
10
12
  --repair-rename Rename semantically equivalent Status columns to the standard names
11
13
  (e.g. "Ready" -> "Next Up"). Without this flag the helper only
@@ -32,76 +34,76 @@ Exit codes:
32
34
  3 — board schema/config mismatch (manual reconciliation needed)
33
35
  `;
34
36
 
35
- const VALID_ARGS = new Set(["--repo", "--project", "--title", "--link-repo", "--repair-rename", "--help", "-h"]);
37
+ function parseCliArgs(argv) {
38
+ const parseError = (message) => Object.assign(new Error(message), { usage: USAGE });
39
+ const requireValue = (token, message) => {
40
+ const v = token.value;
41
+ if (typeof v !== "string" || v.length === 0 || v.startsWith("-")) {
42
+ throw parseError(message);
43
+ }
44
+ return v;
45
+ };
36
46
 
37
- function parseArgs(argv) {
38
- const args = {}; // title default applied in runCli after settings fallback
39
- const consumed = new Set();
40
- for (let i = 0; i < argv.length; i++) {
41
- if (consumed.has(i)) continue;
42
- const arg = argv[i];
43
- if (!VALID_ARGS.has(arg) && arg.startsWith("-")) {
44
- throw Object.assign(
45
- new Error(`Unknown flag: ${arg}`),
46
- { code: "INVALID_REPO", usage: USAGE },
47
- );
47
+ const args = {};
48
+ const { tokens } = parseArgs({
49
+ args: [...argv],
50
+ options: {
51
+ repo: { type: "string" },
52
+ project: { type: "string" },
53
+ title: { type: "string" },
54
+ "link-repo": { type: "string" },
55
+ "repair-rename": { type: "boolean" },
56
+ help: { type: "boolean", short: "h" },
57
+ },
58
+ allowPositionals: true,
59
+ strict: false,
60
+ tokens: true,
61
+ });
62
+
63
+ for (const token of tokens) {
64
+ if (token.kind === "positional") {
65
+ throw parseError(`Unexpected argument: ${token.value}`);
48
66
  }
49
- if (arg === "--repo") {
50
- if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
51
- throw Object.assign(
52
- new Error("--repo requires a value (owner/name)"),
53
- { code: "INVALID_REPO", usage: USAGE },
54
- );
55
- }
56
- args.repo = argv[++i];
57
- consumed.add(i);
58
- } else if (arg === "--project") {
59
- if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
60
- throw Object.assign(
61
- new Error("--project requires a numeric value"),
62
- { code: "INVALID_PROJECT", usage: USAGE },
63
- );
64
- }
65
- const num = Number(argv[++i]);
66
- if (!Number.isInteger(num) || num <= 0) {
67
- throw Object.assign(
68
- new Error(`--project must be a positive integer, got "${argv[i]}"`),
69
- { code: "INVALID_PROJECT", usage: USAGE },
70
- );
71
- }
72
- args.project = num;
73
- } else if (arg === "--title") {
74
- if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
75
- throw Object.assign(
76
- new Error("--title requires a value"),
77
- { code: "INVALID_REPO", usage: USAGE },
78
- );
79
- }
80
- args.title = argv[++i];
81
- consumed.add(i);
82
- } else if (arg === "--link-repo") {
83
- if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
84
- throw Object.assign(
85
- new Error("--link-repo requires a value (owner/name)"),
86
- { code: "INVALID_REPO", usage: USAGE },
87
- );
67
+ if (token.kind !== "option") {
68
+ continue;
69
+ }
70
+ switch (token.name) {
71
+ case "help":
72
+ if (token.value !== undefined) {
73
+ throw parseError(`Unknown flag: ${token.rawName}=${token.value}`);
74
+ }
75
+ args.help = true;
76
+ break;
77
+ case "repo":
78
+ args.repo = requireValue(token, "--repo requires a value (owner/name)");
79
+ break;
80
+ case "project": {
81
+ const raw = requireValue(token, "--project requires a numeric value");
82
+ const num = Number(raw);
83
+ if (!Number.isInteger(num) || num <= 0) {
84
+ throw parseError(`--project must be a positive integer, got "${raw}"`);
85
+ }
86
+ args.project = num;
87
+ break;
88
88
  }
89
- args.linkRepo = argv[++i];
90
- consumed.add(i);
91
- } else if (arg === "--repair-rename") {
92
- args.repairRename = true;
93
- } else if (arg === "--help" || arg === "-h") {
94
- args.help = true;
95
- } else {
96
- throw Object.assign(
97
- new Error(`Unexpected argument: ${arg}`),
98
- { code: "INVALID_REPO", usage: USAGE },
99
- );
89
+ case "title":
90
+ args.title = requireValue(token, "--title requires a value");
91
+ break;
92
+ case "link-repo":
93
+ args.linkRepo = requireValue(token, "--link-repo requires a value (owner/name)");
94
+ break;
95
+ case "repair-rename":
96
+ if (token.value !== undefined) {
97
+ throw parseError(`Unknown flag: ${token.rawName}=${token.value}`);
98
+ }
99
+ args.repairRename = true;
100
+ break;
101
+ default:
102
+ throw parseError(`Unknown flag: ${token.rawName}`);
100
103
  }
101
104
  }
102
105
  return args;
103
106
  }
104
-
105
107
  // ── Validation ───────────────────────────────────────────────────────────
106
108
 
107
109
  // GitHub slug rules: owner 1-39 chars (alnum/dash, no leading/trailing dash,
@@ -798,7 +800,7 @@ async function main(args, { env = process.env, runChild } = {}) {
798
800
  async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, cwd = process.cwd() } = {}) {
799
801
  let args;
800
802
  try {
801
- args = parseArgs(argv);
803
+ args = parseCliArgs(argv);
802
804
  } catch (err) {
803
805
  stderr.write(`${formatCliError(err)}\n`);
804
806
  process.exitCode = 1;
@@ -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 list --repo <owner/name> --project <number|id> [--column <name>] [--limit <n>]
6
+ const USAGE = `Usage: dev-loops queue list --repo <owner/name> --project <number|id> [--column <name>] [--limit <n>]
7
+ (dev-loops project list … is a back-compat alias)
6
8
 
7
9
  List GitHub Projects V2 items filtered by Status column, ordered by position
8
10
  ascending. Returns machine-readable JSON.
@@ -24,69 +26,69 @@ Exit codes:
24
26
  3 — project, field, or column not found
25
27
  `.trim();
26
28
 
27
- const VALID_ARGS = new Set(["--repo", "--project", "--column", "--limit", "--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
+ };
28
38
 
29
- function parseArgs(argv) {
30
39
  const args = {};
31
- for (let i = 0; i < argv.length; i++) {
32
- const arg = argv[i];
33
- if (!VALID_ARGS.has(arg) && arg.startsWith("-")) {
34
- throw Object.assign(
35
- new Error(`Unknown flag: ${arg}`),
36
- { code: "INVALID_ARGS", usage: USAGE },
37
- );
40
+ const { tokens } = parseArgs({
41
+ args: [...argv],
42
+ options: {
43
+ repo: { type: "string" },
44
+ project: { type: "string" },
45
+ column: { type: "string" },
46
+ limit: { type: "string" },
47
+ help: { type: "boolean", short: "h" },
48
+ },
49
+ allowPositionals: true,
50
+ strict: false,
51
+ tokens: true,
52
+ });
53
+
54
+ for (const token of tokens) {
55
+ if (token.kind === "positional") {
56
+ throw parseError(`Unexpected argument: ${token.value}`);
38
57
  }
39
- if (arg === "--repo") {
40
- if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
41
- throw Object.assign(
42
- new Error("--repo requires a value (owner/name)"),
43
- { code: "INVALID_ARGS", usage: USAGE },
44
- );
45
- }
46
- args.repo = argv[++i];
47
- } else if (arg === "--project") {
48
- if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
49
- throw Object.assign(
50
- new Error("--project requires a value (number or node ID)"),
51
- { code: "INVALID_ARGS", usage: USAGE },
52
- );
53
- }
54
- args.project = argv[++i];
55
- } else if (arg === "--column") {
56
- if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
57
- throw Object.assign(
58
- new Error("--column requires a value"),
59
- { code: "INVALID_ARGS", usage: USAGE },
60
- );
61
- }
62
- args.column = argv[++i];
63
- } else if (arg === "--limit") {
64
- if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
65
- throw Object.assign(
66
- new Error("--limit requires a positive integer"),
67
- { code: "INVALID_ARGS", usage: USAGE },
68
- );
69
- }
70
- const val = Number(argv[++i]);
71
- if (!Number.isInteger(val) || val < 1) {
72
- throw Object.assign(
73
- new Error(`--limit must be a positive integer, got "${argv[i]}"`),
74
- { code: "INVALID_ARGS", usage: USAGE },
75
- );
58
+ if (token.kind !== "option") {
59
+ continue;
60
+ }
61
+ switch (token.name) {
62
+ case "help":
63
+ if (token.value !== undefined) {
64
+ throw parseError(`Unknown flag: ${token.rawName}=${token.value}`);
65
+ }
66
+ args.help = true;
67
+ break;
68
+ case "repo":
69
+ args.repo = requireValue(token, "--repo requires a value (owner/name)");
70
+ break;
71
+ case "project":
72
+ args.project = requireValue(token, "--project requires a value (number or node ID)");
73
+ break;
74
+ case "column":
75
+ args.column = requireValue(token, "--column requires a value");
76
+ break;
77
+ case "limit": {
78
+ const raw = requireValue(token, "--limit requires a positive integer");
79
+ const val = Number(raw);
80
+ if (!Number.isInteger(val) || val < 1) {
81
+ throw parseError(`--limit must be a positive integer, got "${raw}"`);
82
+ }
83
+ args.limit = val;
84
+ break;
76
85
  }
77
- args.limit = val;
78
- } else if (arg === "--help" || arg === "-h") {
79
- args.help = true;
80
- } else {
81
- throw Object.assign(
82
- new Error(`Unexpected argument: ${arg}`),
83
- { code: "INVALID_ARGS", usage: USAGE },
84
- );
86
+ default:
87
+ throw parseError(`Unknown flag: ${token.rawName}`);
85
88
  }
86
89
  }
87
90
  return args;
88
91
  }
89
-
90
92
  // ── Validation ───────────────────────────────────────────────────────────
91
93
 
92
94
  const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
@@ -460,7 +462,7 @@ async function main(args, { env = process.env, runChild } = {}) {
460
462
  async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env } = {}) {
461
463
  let args;
462
464
  try {
463
- args = parseArgs(argv);
465
+ args = parseCliArgs(argv);
464
466
  } catch (err) {
465
467
  stderr.write(`${formatCliError(err)}\n`);
466
468
  process.exitCode = 1;