dev-loops 0.2.7 → 0.3.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 (48) 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/skills/copilot-pr-followup/SKILL.md +57 -3
  7. package/.claude/skills/dev-loop/SKILL.md +5 -5
  8. package/.claude/skills/docs/anti-patterns.md +2 -0
  9. package/.claude/skills/docs/copilot-loop-operations.md +2 -2
  10. package/.claude/skills/local-implementation/SKILL.md +17 -3
  11. package/AGENTS.md +1 -1
  12. package/CHANGELOG.md +43 -0
  13. package/agents/developer.agent.md +1 -0
  14. package/agents/fixer.agent.md +1 -0
  15. package/agents/review.agent.md +30 -0
  16. package/cli/index.mjs +39 -6
  17. package/package.json +2 -2
  18. package/scripts/README.md +6 -5
  19. package/scripts/_core-helpers.mjs +1 -0
  20. package/scripts/claude/headless-dev-loop.mjs +53 -13
  21. package/scripts/claude/headless-info-smoke.mjs +45 -11
  22. package/scripts/github/build-adjacent-bundle.mjs +448 -0
  23. package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
  24. package/scripts/github/detect-checkpoint-evidence.mjs +95 -4
  25. package/scripts/github/post-gate-findings.mjs +392 -0
  26. package/scripts/github/reconcile-draft-gate.mjs +2 -2
  27. package/scripts/github/request-copilot-review.mjs +69 -8
  28. package/scripts/github/upsert-checkpoint-verdict.mjs +597 -15
  29. package/scripts/github/verify-fresh-review-context.mjs +12 -1
  30. package/scripts/github/write-gate-context.mjs +634 -0
  31. package/scripts/github/write-gate-findings-log.mjs +1 -1
  32. package/scripts/loop/detect-change-scope.mjs +36 -11
  33. package/scripts/loop/detect-pr-gate-coordination-state.mjs +30 -18
  34. package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
  35. package/scripts/loop/run-queue.mjs +87 -15
  36. package/scripts/projects/add-queue-item.mjs +60 -43
  37. package/scripts/projects/archive-done-items.mjs +135 -39
  38. package/scripts/projects/ensure-queue-board.mjs +65 -64
  39. package/scripts/projects/list-queue-items.mjs +57 -56
  40. package/scripts/projects/move-queue-item.mjs +123 -124
  41. package/scripts/projects/reorder-queue-item.mjs +62 -44
  42. package/scripts/projects/sync-item-status.mjs +198 -0
  43. package/skills/copilot-pr-followup/SKILL.md +57 -3
  44. package/skills/dev-loop/scripts/log-bash-exit-1.mjs +2 -2
  45. package/skills/dev-loop/scripts/phase-files.mjs +2 -2
  46. package/skills/docs/anti-patterns.md +2 -0
  47. package/skills/docs/copilot-loop-operations.md +2 -2
  48. package/skills/local-implementation/SKILL.md +17 -3
@@ -1,12 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync } from "node:child_process";
3
3
  import process from "node:process";
4
- function parseArgs() {
5
- const args = process.argv.slice(2);
6
- const opts = { base: null, head: null };
7
- for (let i = 0; i < args.length; i++) {
8
- if (args[i] === "--help" || args[i] === "-h") {
9
- process.stdout.write(`Usage: detect-change-scope.mjs [--base <ref>] [--head <ref>]
4
+ import { parseArgs } from "node:util";
5
+
6
+ const USAGE = `Usage: detect-change-scope.mjs [--base <ref>] [--head <ref>]
10
7
  Detect change scope from git diff for light-mode eligibility.
11
8
  Options:
12
9
  --base <ref> Override base ref (default: HEAD~1)
@@ -15,11 +12,39 @@ Options:
15
12
  Exit codes:
16
13
  0 Success
17
14
  1 Error
18
- `);
19
- process.exit(0);
15
+ `;
16
+
17
+ function parseCliArgs(argv) {
18
+ const { tokens } = parseArgs({
19
+ args: [...argv],
20
+ options: {
21
+ base: { type: "string" },
22
+ head: { type: "string" },
23
+ help: { type: "boolean", short: "h" },
24
+ },
25
+ allowPositionals: true,
26
+ strict: false,
27
+ tokens: true,
28
+ });
29
+
30
+ const opts = { base: null, head: null };
31
+ for (const token of tokens) {
32
+ if (token.kind === "option") {
33
+ if (token.name === "help") {
34
+ if (token.value !== undefined) {
35
+ throw new Error(`unknown argument: ${token.rawName}=${token.value}`);
36
+ }
37
+ process.stdout.write(USAGE);
38
+ process.exit(0);
39
+ }
40
+ if (token.name === "base") {
41
+ opts.base = token.value ?? null;
42
+ continue;
43
+ }
44
+ if (token.name === "head") {
45
+ opts.head = token.value ?? null;
46
+ }
20
47
  }
21
- if (args[i] === "--base" && i + 1 < args.length) opts.base = args[++i];
22
- else if (args[i] === "--head" && i + 1 < args.length) opts.head = args[++i];
23
48
  }
24
49
  return opts;
25
50
  }
@@ -64,7 +89,7 @@ function isEligibleForLightMode(scope, threshold) {
64
89
  return scope.filesChanged <= threshold.maxFiles && scope.linesChanged <= threshold.maxLines;
65
90
  }
66
91
  async function main() {
67
- const opts = parseArgs();
92
+ const opts = parseCliArgs(process.argv.slice(2));
68
93
  const scope = detectScope(opts);
69
94
  let threshold = { maxFiles: 3, maxLines: 200 };
70
95
  let eligible = false;
@@ -6,13 +6,13 @@ import {
6
6
  formatCliError,
7
7
  isCopilotLogin,
8
8
  isDirectCliRun,
9
- normalizeTimestamp,
10
9
  parseJsonText,
11
10
  parseReviewThreads,
11
+ resolveDraftGateRoundResetMs,
12
12
  summarizeCopilotReviews,
13
13
  } from "../_core-helpers.mjs";
14
14
  import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
15
- import { loadDevLoopConfig, resolveGateConfig, resolveRefinementConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
15
+ import { loadDevLoopConfig, resolveGateConfig, resolveRefinement, resolveRefinementConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
16
16
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
17
17
  import { buildSnapshotFromPrFacts, interpretLoopState, summarizeLoopInterpretation } from "@dev-loops/core/loop/copilot-loop-state";
18
18
  import { evaluatePrGateCoordination, PR_CHECKPOINT, PR_CHECKPOINT_ACTION } from "@dev-loops/core/loop/pr-gate-coordination";
@@ -303,18 +303,13 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
303
303
  const gateEvidence = await detectCheckpointEvidence(options, runtime);
304
304
  // When draft gate was re-passed on a different head, use its timestamp
305
305
  // to reset the Copilot round count — only reviews after the re-pass count.
306
- // Use prefix matching for the head SHA comparison so shortened SHAs (7+)
307
- // from gate comments match the full headRefOid.
308
- const draftGateHeadSha = gateEvidence.draftGate?.headSha;
309
- const draftGateOnCurrentHead = typeof draftGateHeadSha === "string"
310
- && typeof currentHeadSha === "string"
311
- && currentHeadSha.startsWith(draftGateHeadSha);
312
- const draftGateResetAtMs = gateEvidence.draftGate?.verdict === "clean"
313
- && typeof draftGateHeadSha === "string"
314
- && !draftGateOnCurrentHead
315
- && typeof gateEvidence.draftGate?.updatedAt === "string"
316
- ? normalizeTimestamp(gateEvidence.draftGate.updatedAt)
317
- : null;
306
+ // Shared with request-copilot-review so both scripts compute the same
307
+ // completed round count / cap (#896). Prefix matching for the head SHA lets
308
+ // shortened SHAs (7+) from gate comments match the full headRefOid.
309
+ const draftGateResetAtMs = resolveDraftGateRoundResetMs({
310
+ draftGate: gateEvidence.draftGate,
311
+ currentHeadSha,
312
+ });
318
313
  const reviewSummary = summarizeCopilotReviews(prData?.reviews, { headSha: currentHeadSha, draftGateResetAtMs });
319
314
  const reviewRequestStatus = requestedReviewers.requested
320
315
  ? "requested"
@@ -338,8 +333,19 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
338
333
  if (gateEvidence.currentHeadSha !== currentHeadSha) {
339
334
  throw new Error(`PR head changed while loading gate coordination facts for ${options.repo}#${options.pr}; refuse to evaluate mixed-head gate state.`);
340
335
  }
341
- const interpretation = interpretLoopState(snapshot);
342
- const disposition = summarizeLoopInterpretation(interpretation);
336
+ // Resolve the refinement config (round cap, low-signal heuristic) and feed it to
337
+ // the interpreter. Without it, the interpreter cannot see maxCopilotRounds and so
338
+ // never resolves ROUND_CAP_CLEAN_FALLBACK — a post-cap clean head would fall to
339
+ // READY_TO_REREQUEST_REVIEW, dead-ending the loop at the round cap (#896). This
340
+ // keeps the gate-coordination interpretation consistent with the standalone
341
+ // detect-copilot-loop-state path and with request-copilot-review's cap logic.
342
+ const interpreterRepoRoot = runtime.repoRoot ?? process.cwd();
343
+ const interpreterConfigResult = await loadDevLoopConfig({ repoRoot: interpreterRepoRoot });
344
+ const interpreterRefinementConfig = (Array.isArray(interpreterConfigResult.errors) && interpreterConfigResult.errors.length > 0)
345
+ ? resolveRefinement({ version: 1 })
346
+ : resolveRefinement(interpreterConfigResult.config ?? { version: 1 });
347
+ const interpretation = interpretLoopState(snapshot, interpreterRefinementConfig);
348
+ const disposition = summarizeLoopInterpretation(interpretation, interpreterRefinementConfig);
343
349
  const mergeStateStatus = typeof prData?.mergeStateStatus === "string" && prData.mergeStateStatus.trim().length > 0
344
350
  ? prData.mergeStateStatus.trim().toUpperCase()
345
351
  : null;
@@ -362,6 +368,7 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
362
368
  interpretation,
363
369
  disposition,
364
370
  refinementArtifact,
371
+ refinementConfig: interpreterRefinementConfig,
365
372
  };
366
373
  }
367
374
 
@@ -429,9 +436,13 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
429
436
  && typeof (context.snapshot?.copilotReviewRoundCount) === "number"
430
437
  && context.snapshot?.copilotReviewRoundCount >= maxCopilotRounds;
431
438
  const sameHeadCleanConverged = context.interpretation?.sameHeadCleanConverged ?? false;
439
+ // Round-cap clean fallback (#896): the interpreter resolved a clean post-cap head
440
+ // (zero unresolved threads + green CI) that Copilot will not re-review. The formal
441
+ // request guard must not fire here — pre_approval_gate reviews the post-cap head.
442
+ const roundCapCleanFallback = context.interpretation?.roundCapCleanEligible ?? false;
432
443
  const copilotReviewEverFormallyRequested = copilotReviewRequestStatus === "none"
433
444
  && guardBoundaries.has(result.gateBoundary)
434
- && !(roundCapReached && sameHeadCleanConverged)
445
+ && !(roundCapReached && (sameHeadCleanConverged || roundCapCleanFallback))
435
446
  ? await fetchCopilotEverFormallyRequested(
436
447
  { repo: context.repo, pr: context.pr },
437
448
  runtime,
@@ -442,7 +453,8 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
442
453
  copilotReviewRoundCount: context.snapshot?.copilotReviewRoundCount ?? 0,
443
454
  copilotReviewEverFormallyRequested,
444
455
  maxCopilotRounds,
445
- sameHeadCleanConverged: context.interpretation?.sameHeadCleanConverged ?? false,
456
+ sameHeadCleanConverged,
457
+ roundCapCleanFallback,
446
458
  gateBoundary: result.gateBoundary,
447
459
  })) {
448
460
  result.gateBoundary = PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW;
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import process from "node:process";
3
3
  import { execFileSync } from "node:child_process";
4
+ import { parseArgs } from "node:util";
4
5
  import { interpretTrackerLoopState } from "@dev-loops/core/loop/tracker-first-loop-state";
6
+
5
7
  function showHelp() {
6
8
  process.stdout.write(`Usage: detect-tracker-first-loop-state.mjs --repo <owner/name> --issue <number>
7
9
  Detect tracker-first loop state for a GitHub issue.
@@ -15,40 +17,65 @@ Exit codes:
15
17
  `);
16
18
  process.exit(0);
17
19
  }
18
- function parseArgs() {
19
- const args = process.argv.slice(2);
20
+
21
+ function parseCliArgs(argv) {
22
+ const { tokens } = parseArgs({
23
+ args: [...argv],
24
+ options: {
25
+ repo: { type: "string" },
26
+ issue: { type: "string" },
27
+ help: { type: "boolean", short: "h" },
28
+ },
29
+ allowPositionals: true,
30
+ strict: false,
31
+ tokens: true,
32
+ });
33
+
20
34
  const opts = { repo: null, issue: null };
21
- for (let i = 0; i < args.length; i++) {
22
- if (args[i] === "--help" || args[i] === "-h") {
23
- showHelp();
35
+ for (const token of tokens) {
36
+ if (token.kind === "option") {
37
+ if (token.name === "help") {
38
+ if (token.value !== undefined) {
39
+ throw new Error(`unknown argument: ${token.rawName}=${token.value}`);
40
+ }
41
+ showHelp();
42
+ }
43
+ if (token.name === "repo") {
44
+ opts.repo = token.value ?? null;
45
+ continue;
46
+ }
47
+ if (token.name === "issue") {
48
+ opts.issue = token.value ?? null;
49
+ }
24
50
  }
25
- if (args[i] === "--repo" && i + 1 < args.length) opts.repo = args[++i];
26
- else if (args[i] === "--issue" && i + 1 < args.length) opts.issue = args[++i];
27
51
  }
28
52
  return opts;
29
53
  }
54
+
30
55
  async function main() {
31
- const opts = parseArgs();
32
- if (!opts.repo || !opts.issue) {
56
+ const rawOpts = parseCliArgs(process.argv.slice(2));
57
+ if (!rawOpts.repo || !rawOpts.issue) {
33
58
  process.stderr.write(
34
59
  JSON.stringify({ ok: false, error: "--repo and --issue required" }) + "\n"
35
60
  );
36
61
  process.exitCode = 1;
37
62
  return;
38
63
  }
64
+ const repo = rawOpts.repo;
65
+ const issue = rawOpts.issue;
39
66
  let rawState = "";
40
67
  let prContext = null;
41
68
  try {
42
69
  const issueJson = execFileSync(
43
70
  "gh",
44
- ["issue", "view", String(opts.issue), "--repo", opts.repo, "--json", "state,title", "--jq", ".state"],
71
+ ["issue", "view", String(issue), "--repo", repo, "--json", "state,title", "--jq", ".state"],
45
72
  { encoding: "utf8" }
46
73
  ).trim();
47
74
  rawState = issueJson;
48
75
  try {
49
76
  const prJson = execFileSync(
50
77
  "gh",
51
- ["pr", "list", "--repo", opts.repo, "--search", `${opts.issue} in:body`, "--state", "open", "--json", "number,state,headRefName", "--jq", ".[0]"],
78
+ ["pr", "list", "--repo", repo, "--search", `${issue} in:body`, "--state", "open", "--json", "number,state,headRefName", "--jq", ".[0]"],
52
79
  { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }
53
80
  ).trim();
54
81
  if (prJson) prContext = JSON.parse(prJson);
@@ -14,9 +14,11 @@
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";
21
23
 
22
24
  const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
@@ -27,7 +29,7 @@ const USAGE = `Usage:
27
29
  Run the dev-loop queue driver over entries in .pi/dev-loop-queue.json.
28
30
  Exit codes: 0 success, 1 error`.trim();
29
31
 
30
- function parseArgs(argv) {
32
+ function parseCliArgs(argv) {
31
33
  const args = {
32
34
  repo: null,
33
35
  mergeAuthorized: false,
@@ -37,27 +39,58 @@ function parseArgs(argv) {
37
39
  help: false,
38
40
  };
39
41
 
40
- for (let i = 0; i < argv.length; i++) {
41
- switch (argv[i]) {
42
- case "--repo":
43
- args.repo = argv[++i];
42
+ const { tokens } = parseArgs({
43
+ args: [...argv],
44
+ options: {
45
+ repo: { type: "string" },
46
+ "merge-authorized": { type: "boolean" },
47
+ parallel: { type: "boolean" },
48
+ "redispatch-max-retries": { type: "string" },
49
+ "max-parallel": { type: "string" },
50
+ help: { type: "boolean", short: "h" },
51
+ },
52
+ allowPositionals: true,
53
+ strict: false,
54
+ tokens: true,
55
+ });
56
+
57
+ for (const token of tokens) {
58
+ if (token.kind === "positional") {
59
+ throw new Error(`unknown argument: ${token.value}`);
60
+ }
61
+ if (token.kind !== "option") {
62
+ continue;
63
+ }
64
+ switch (token.name) {
65
+ case "repo":
66
+ args.repo = token.value;
44
67
  break;
45
- case "--merge-authorized":
68
+ case "merge-authorized":
69
+ if (token.value !== undefined) {
70
+ throw new Error(`unknown argument: ${token.rawName}=${token.value}`);
71
+ }
46
72
  args.mergeAuthorized = true;
47
73
  break;
48
- case "--parallel":
74
+ case "parallel":
75
+ if (token.value !== undefined) {
76
+ throw new Error(`unknown argument: ${token.rawName}=${token.value}`);
77
+ }
49
78
  args.parallel = true;
50
79
  break;
51
- case "--redispatch-max-retries":
52
- args.reDispatchMaxRetries = parsePositiveInteger(argv[++i], "--redispatch-max-retries");
80
+ case "redispatch-max-retries":
81
+ args.reDispatchMaxRetries = parsePositiveInteger(token.value, "--redispatch-max-retries");
53
82
  break;
54
- case "--max-parallel":
55
- args.maxParallel = parsePositiveInteger(argv[++i], "--max-parallel");
83
+ case "max-parallel":
84
+ args.maxParallel = parsePositiveInteger(token.value, "--max-parallel");
56
85
  break;
57
- case "--help":
58
- case "-h":
86
+ case "help":
87
+ if (token.value !== undefined) {
88
+ throw new Error(`unknown argument: ${token.rawName}=${token.value}`);
89
+ }
59
90
  args.help = true;
60
91
  break;
92
+ default:
93
+ throw new Error(`unknown argument: ${token.rawName}`);
61
94
  }
62
95
  }
63
96
 
@@ -65,7 +98,7 @@ function parseArgs(argv) {
65
98
  }
66
99
 
67
100
  async function main() {
68
- const args = parseArgs(process.argv.slice(2));
101
+ const args = parseCliArgs(process.argv.slice(2));
69
102
 
70
103
  if (args.help) {
71
104
  console.log(USAGE);
@@ -79,7 +112,46 @@ async function main() {
79
112
 
80
113
  const queue = await readQueue(REPO_ROOT);
81
114
 
82
- if (queue.entries.length === 0) {
115
+ // A configured GitHub Projects board is the authoritative queue MEMBERSHIP
116
+ // source (issue #864): fold its "Next Up" items into the queue before judging
117
+ // emptiness so a populated board with an empty local queue is no longer a
118
+ // silent no-op. Fail-open — a board hiccup falls back to the local queue.
119
+ // reconcileBoardMembership already logs an "added N ... from board Next Up"
120
+ // line to stderr (single source of truth); we deliberately do not duplicate
121
+ // it here to avoid noise for JSON consumers of stdout.
122
+ const membership = await reconcileBoardMembership(REPO_ROOT, args.repo, queue);
123
+
124
+ if (membership.emptiness === "board_empty") {
125
+ // Distinct from the legacy generic "Queue is empty": the board is the
126
+ // membership source and it currently has nothing in Next Up. This branch is
127
+ // only reached for a genuinely empty Next Up (reason == null), never for a
128
+ // resolution failure (which falls through to the local queue below).
129
+ console.log(JSON.stringify({
130
+ ok: true,
131
+ message: "Board configured but Next Up is empty; nothing to run",
132
+ boardConfigured: true,
133
+ reason: null,
134
+ results: [],
135
+ }));
136
+ return;
137
+ }
138
+
139
+ if (membership.emptiness === "board_unavailable") {
140
+ // The board IS configured but Next Up resolution failed (fail-open) and the
141
+ // local queue had nothing to fall back to. Do NOT claim "Next Up is empty";
142
+ // surface the real reason so consumers can distinguish an outage from an
143
+ // intentionally empty board.
144
+ console.log(JSON.stringify({
145
+ ok: true,
146
+ message: `Board configured but unavailable (${membership.reason}); nothing to run`,
147
+ boardConfigured: true,
148
+ reason: membership.reason ?? null,
149
+ results: [],
150
+ }));
151
+ return;
152
+ }
153
+
154
+ if (membership.emptiness === "queue_empty") {
83
155
  console.log(JSON.stringify({ ok: true, message: "Queue is empty", results: [] }));
84
156
  return;
85
157
  }
@@ -1,6 +1,7 @@
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
6
  const USAGE = `Usage: dev-loops project add --repo <owner/name> --project <number|id> --item <number>
6
7
 
@@ -23,52 +24,68 @@ Exit codes:
23
24
  3 — project, field, column, or issue/PR not found
24
25
  `.trim();
25
26
 
26
- const VALID_ARGS = new Set(["--repo", "--project", "--item", "--status", "--help", "-h"]);
27
+ function parseCliArgs(argv) {
28
+ const parseError = (message) => Object.assign(new Error(message), { usage: USAGE });
29
+ const requireValue = (token, message) => {
30
+ const v = token.value;
31
+ if (typeof v !== "string" || v.length === 0 || v.startsWith("-")) {
32
+ throw parseError(message);
33
+ }
34
+ return v;
35
+ };
27
36
 
28
- function parseArgs(argv) {
29
37
  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
- );
38
+ const { tokens } = parseArgs({
39
+ args: [...argv],
40
+ options: {
41
+ repo: { type: "string" },
42
+ project: { type: "string" },
43
+ item: { type: "string" },
44
+ status: { type: "string" },
45
+ help: { type: "boolean", short: "h" },
46
+ },
47
+ allowPositionals: true,
48
+ strict: false,
49
+ tokens: true,
50
+ });
51
+
52
+ for (const token of tokens) {
53
+ if (token.kind === "positional") {
54
+ throw Object.assign(new Error(`Unexpected argument: ${token.value}`), { code: "INVALID_ARGS", usage: USAGE });
37
55
  }
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" });
56
+ if (token.kind !== "option") {
57
+ continue;
58
+ }
59
+ switch (token.name) {
60
+ case "help":
61
+ if (token.value !== undefined) {
62
+ throw Object.assign(new Error(`Unknown flag: ${token.rawName}=${token.value}`), { code: "INVALID_ARGS", usage: USAGE });
63
+ }
64
+ args.help = true;
65
+ break;
66
+ case "repo":
67
+ args.repo = requireValue(token, "--repo requires a value (owner/name)");
68
+ break;
69
+ case "project":
70
+ args.project = requireValue(token, "--project requires a value (number or node ID)");
71
+ break;
72
+ case "item": {
73
+ const raw = requireValue(token, "--item requires a value (number)");
74
+ const val = Number(raw);
75
+ if (!Number.isInteger(val) || val < 1) {
76
+ throw Object.assign(
77
+ new Error(`--item must be a positive integer, got "${raw}"`),
78
+ { code: "INVALID_ITEM" },
79
+ );
80
+ }
81
+ args.item = val;
82
+ break;
63
83
  }
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
- );
84
+ case "status":
85
+ args.status = requireValue(token, "--status requires a value");
86
+ break;
87
+ default:
88
+ throw Object.assign(new Error(`Unknown flag: ${token.rawName}`), { code: "INVALID_ARGS", usage: USAGE });
72
89
  }
73
90
  }
74
91
  return args;
@@ -499,7 +516,7 @@ async function main(args, { env = process.env, runChild } = {}) {
499
516
  async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env } = {}) {
500
517
  let args;
501
518
  try {
502
- args = parseArgs(argv);
519
+ args = parseCliArgs(argv);
503
520
  } catch (err) {
504
521
  stderr.write(`${formatCliError(err)}\n`);
505
522
  process.exitCode = 1;