dev-loops 0.2.6 → 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 (92) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +2 -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 +60 -0
  13. package/agents/dev-loop.agent.md +5 -1
  14. package/agents/developer.agent.md +1 -0
  15. package/agents/fixer.agent.md +1 -0
  16. package/agents/review.agent.md +30 -0
  17. package/cli/index.mjs +42 -7
  18. package/package.json +2 -2
  19. package/scripts/README.md +6 -5
  20. package/scripts/_cli-primitives.mjs +2 -0
  21. package/scripts/_core-helpers.mjs +1 -0
  22. package/scripts/claude/generate-claude-assets.mjs +12 -2
  23. package/scripts/claude/headless-dev-loop.mjs +53 -13
  24. package/scripts/claude/headless-info-smoke.mjs +45 -11
  25. package/scripts/docs/validate-links.mjs +20 -11
  26. package/scripts/github/build-adjacent-bundle.mjs +448 -0
  27. package/scripts/github/capture-review-threads.mjs +32 -14
  28. package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
  29. package/scripts/github/detect-checkpoint-evidence.mjs +123 -15
  30. package/scripts/github/detect-linked-issue-pr.mjs +22 -10
  31. package/scripts/github/manage-sub-issues.mjs +37 -16
  32. package/scripts/github/post-gate-findings.mjs +392 -0
  33. package/scripts/github/probe-copilot-review.mjs +24 -12
  34. package/scripts/github/ready-for-review.mjs +17 -8
  35. package/scripts/github/reconcile-draft-gate.mjs +24 -12
  36. package/scripts/github/reply-resolve-review-threads.mjs +34 -15
  37. package/scripts/github/request-copilot-review.mjs +97 -19
  38. package/scripts/github/resolve-tracker-local-spec.mjs +29 -12
  39. package/scripts/github/stage-reviewer-draft.mjs +32 -14
  40. package/scripts/github/upsert-checkpoint-verdict.mjs +646 -41
  41. package/scripts/github/verify-fresh-review-context.mjs +12 -1
  42. package/scripts/github/write-gate-context.mjs +634 -0
  43. package/scripts/github/write-gate-findings-log.mjs +42 -21
  44. package/scripts/loop/build-handoff-envelope.mjs +32 -14
  45. package/scripts/loop/conductor-monitor.mjs +25 -9
  46. package/scripts/loop/conductor.mjs +31 -12
  47. package/scripts/loop/copilot-pr-handoff.mjs +31 -14
  48. package/scripts/loop/debt-remediate.mjs +28 -11
  49. package/scripts/loop/detect-change-scope.mjs +36 -11
  50. package/scripts/loop/detect-copilot-loop-state.mjs +29 -12
  51. package/scripts/loop/detect-copilot-session-activity.mjs +29 -12
  52. package/scripts/loop/detect-initial-copilot-pr-state.mjs +26 -10
  53. package/scripts/loop/detect-internal-only-pr.mjs +31 -13
  54. package/scripts/loop/detect-issue-refinement-artifact.mjs +29 -12
  55. package/scripts/loop/detect-pr-gate-coordination-state.mjs +56 -28
  56. package/scripts/loop/detect-reviewer-loop-state.mjs +35 -16
  57. package/scripts/loop/detect-stale-runner.mjs +32 -14
  58. package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
  59. package/scripts/loop/detect-tracker-pr-state.mjs +23 -8
  60. package/scripts/loop/info.mjs +28 -10
  61. package/scripts/loop/inspect-run-viewer/cli.mjs +44 -21
  62. package/scripts/loop/inspect-run.mjs +35 -16
  63. package/scripts/loop/outer-loop.mjs +35 -16
  64. package/scripts/loop/pr-runner-coordination.mjs +31 -12
  65. package/scripts/loop/pre-commit-branch-guard.mjs +26 -9
  66. package/scripts/loop/pre-flight-gate.mjs +25 -9
  67. package/scripts/loop/pre-pr-ready-gate.mjs +24 -8
  68. package/scripts/loop/pre-push-main-guard.mjs +19 -5
  69. package/scripts/loop/pre-write-remote-freshness-guard.mjs +23 -7
  70. package/scripts/loop/resolve-dev-loop-startup.mjs +29 -12
  71. package/scripts/loop/run-conductor-cycle.mjs +23 -8
  72. package/scripts/loop/run-queue.mjs +87 -15
  73. package/scripts/loop/run-refinement-audit.mjs +44 -22
  74. package/scripts/loop/run-watch-cycle.mjs +28 -12
  75. package/scripts/loop/steer-loop.mjs +122 -62
  76. package/scripts/loop/watch-initial-copilot-pr.mjs +28 -12
  77. package/scripts/projects/add-queue-item.mjs +60 -43
  78. package/scripts/projects/archive-done-items.mjs +506 -0
  79. package/scripts/projects/ensure-queue-board.mjs +65 -64
  80. package/scripts/projects/list-queue-items.mjs +57 -56
  81. package/scripts/projects/move-queue-item.mjs +123 -124
  82. package/scripts/projects/reorder-queue-item.mjs +384 -108
  83. package/scripts/projects/sync-item-status.mjs +198 -0
  84. package/scripts/refine/_refine-helpers.mjs +21 -9
  85. package/scripts/refine/verify.mjs +31 -13
  86. package/skills/copilot-pr-followup/SKILL.md +57 -3
  87. package/skills/dev-loop/SKILL.md +9 -5
  88. package/skills/dev-loop/scripts/log-bash-exit-1.mjs +2 -2
  89. package/skills/dev-loop/scripts/phase-files.mjs +2 -2
  90. package/skills/docs/anti-patterns.md +2 -0
  91. package/skills/docs/copilot-loop-operations.md +2 -2
  92. package/skills/local-implementation/SKILL.md +17 -3
@@ -1,21 +1,37 @@
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 reorder --repo <owner/name> --project <number|id> --item <number|node-id> [--after <number|node-id>]
6
+ const USAGE = `Usage:
7
+ dev-loops project reorder --repo <owner/name> --project <number|id> --item <number|node-id> [--after <number|node-id>]
8
+ dev-loops project reorder move-to-top <ref> --repo <owner/name> --project <number|id>
9
+ dev-loops project reorder move-after <ref> <after-ref> --repo <owner/name> --project <number|id>
10
+ dev-loops project reorder order <ref1> <ref2> ... --repo <owner/name> --project <number|id>
6
11
 
7
- Reorder a GitHub Projects V2 item by board position via updateProjectV2ItemPosition.
8
- Moves item to top when no --after is provided, or after the specified item.
12
+ Reorder GitHub Projects V2 items by board position via updateProjectV2ItemPosition.
13
+
14
+ Forms:
15
+ (no subcommand) Flag form. Moves --item to top, or after --after when provided.
16
+ move-to-top <ref> Move <ref> to the first position in its current Status column.
17
+ move-after <ref> <after-ref>
18
+ Move <ref> immediately after <after-ref>.
19
+ order <ref1> ... Set explicit ordering: ref1 first, ref2 after ref1, and so on.
20
+
21
+ A <ref> is an issue/PR number OR a project item node ID. Works for both issues and PRs.
9
22
 
10
23
  Options:
11
24
  --repo <owner/name> Required. Repository to scope the project search.
12
25
  --project <number|id> Required. Project number (integer) or node ID.
13
- --item <number|node-id> Required. Item to reorder: issue/PR number, or project item node ID.
14
- --after <number|node-id> Position after this item. When omitted, move to top.
26
+ --item <number|node-id> Flag form: item to reorder.
27
+ --after <number|node-id> Flag form: position after this item. When omitted, move to top.
28
+ --dry-run Print the intended GraphQL mutation(s) without executing.
15
29
  --help, -h Show this help.
16
30
 
17
31
  Output (stdout):
18
- JSON: { ok: true, item: { itemId, issueNumber, prNumber, status, position }, after: { itemId, issueNumber, prNumber } | null }
32
+ JSON. Move/move-to-top: { ok, item, after_ref|null, before, after }.
33
+ order: { ok, moves: [...], before, after }.
34
+ dry-run: { ok, dryRun: true, mutations: [{ query, variables }], before }.
19
35
 
20
36
  Exit codes:
21
37
  0 — success
@@ -24,47 +40,80 @@ Exit codes:
24
40
  3 — project, item, or after-item not found
25
41
  `.trim();
26
42
 
27
- const VALID_ARGS = new Set(["--repo", "--project", "--item", "--after", "--help", "-h"]);
43
+ const SUBCOMMANDS = new Set(["move-to-top", "move-after", "order"]);
28
44
 
29
- function parseArgs(argv) {
30
- 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
- );
45
+ function parseCliArgs(argv) {
46
+ const parseError = (message) => Object.assign(new Error(message), { usage: USAGE });
47
+ const requireValue = (token, message) => {
48
+ const v = token.value;
49
+ if (typeof v !== "string" || v.length === 0 || v.startsWith("-")) {
50
+ throw parseError(message);
38
51
  }
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 === "--item") {
50
- if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
51
- throw Object.assign(new Error("--item requires a value (number or node ID)"), { code: "INVALID_ITEM" });
52
- }
53
- args.item = argv[++i];
54
- } else if (arg === "--after") {
55
- if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
56
- throw Object.assign(new Error("--after requires a value (number or node ID)"), { code: "INVALID_AFTER" });
57
- }
58
- args.after = argv[++i];
59
- } else if (arg === "--help" || arg === "-h") {
60
- args.help = true;
61
- } else {
62
- throw Object.assign(new Error(`Unexpected argument: ${arg}`), { code: "INVALID_ARGS", usage: USAGE });
52
+ return v;
53
+ };
54
+
55
+ const args = { _positional: [] };
56
+ let rest = argv;
57
+
58
+ if (argv.length > 0 && SUBCOMMANDS.has(argv[0])) {
59
+ args._subcommand = argv[0];
60
+ rest = argv.slice(1);
61
+ }
62
+
63
+ const { tokens } = parseArgs({
64
+ args: [...rest],
65
+ options: {
66
+ repo: { type: "string" },
67
+ project: { type: "string" },
68
+ item: { type: "string" },
69
+ after: { type: "string" },
70
+ "dry-run": { type: "boolean" },
71
+ help: { type: "boolean", short: "h" },
72
+ },
73
+ allowPositionals: true,
74
+ strict: false,
75
+ tokens: true,
76
+ });
77
+
78
+ for (const token of tokens) {
79
+ if (token.kind === "positional") {
80
+ args._positional.push(token.value);
81
+ continue;
82
+ }
83
+ if (token.kind !== "option") {
84
+ continue;
85
+ }
86
+ switch (token.name) {
87
+ case "help":
88
+ if (token.value !== undefined) {
89
+ throw parseError(`Unknown flag: ${token.rawName}=${token.value}`);
90
+ }
91
+ args.help = true;
92
+ break;
93
+ case "repo":
94
+ args.repo = requireValue(token, "--repo requires a value (owner/name)");
95
+ break;
96
+ case "project":
97
+ args.project = requireValue(token, "--project requires a value (number or node ID)");
98
+ break;
99
+ case "item":
100
+ args.item = requireValue(token, "--item requires a value (number or node ID)");
101
+ break;
102
+ case "after":
103
+ args.after = requireValue(token, "--after requires a value (number or node ID)");
104
+ break;
105
+ case "dry-run":
106
+ if (token.value !== undefined) {
107
+ throw parseError(`Unknown flag: ${token.rawName}=${token.value}`);
108
+ }
109
+ args.dryRun = true;
110
+ break;
111
+ default:
112
+ throw parseError(`Unknown flag: ${token.rawName}`);
63
113
  }
64
114
  }
65
115
  return args;
66
116
  }
67
-
68
117
  // ── Validation ───────────────────────────────────────────────────────────
69
118
 
70
119
  const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
@@ -296,6 +345,94 @@ async function listAllProjects(login, kind, env, runChild) {
296
345
  return projects;
297
346
  }
298
347
 
348
+ // ── Fetch all project items (paginated, position order) ────────────────
349
+
350
+ async function fetchAllItems(projectId, env, runChild) {
351
+ const allItems = [];
352
+ let after = null;
353
+ while (true) {
354
+ const vars = { projectId };
355
+ if (after) vars.after = after;
356
+ const itemsPayload = await ghGraphql(GET_PROJECT_ITEMS_BY_CONTENT, vars, env, runChild);
357
+ const connection = itemsPayload?.data?.node?.items;
358
+ const nodes = connection?.nodes ?? [];
359
+ allItems.push(...nodes);
360
+ const pageInfo = connection?.pageInfo ?? {};
361
+ if (!pageInfo.hasNextPage) break;
362
+ if (!pageInfo.endCursor) {
363
+ throw Object.assign(
364
+ new Error("Invalid items payload: hasNextPage is true but endCursor is missing"),
365
+ { code: "GH_API_ERROR" },
366
+ );
367
+ }
368
+ after = pageInfo.endCursor;
369
+ }
370
+ return allItems;
371
+ }
372
+
373
+ function statusOf(node) {
374
+ const fvs = node?.fieldValues?.nodes ?? [];
375
+ for (const fv of fvs) {
376
+ if (fv && fv.field && fv.field.name === "Status") return fv.name;
377
+ }
378
+ return null;
379
+ }
380
+
381
+ function describeItem(node) {
382
+ return {
383
+ itemId: node.id,
384
+ issueNumber: node.content?.__typename === "Issue" ? node.content.number : null,
385
+ prNumber: node.content?.__typename === "PullRequest" ? node.content.number : null,
386
+ status: statusOf(node),
387
+ };
388
+ }
389
+
390
+ // Build a diff-friendly snapshot of items in `repo` (optionally a single Status
391
+ // column), in board position order, from a pre-fetched item list.
392
+ function snapshotFromItems(items, repo, statusFilter) {
393
+ return items
394
+ .filter((it) => it.content && it.content.repository?.nameWithOwner === repo)
395
+ .filter((it) => (statusFilter == null ? true : statusOf(it) === statusFilter))
396
+ .map(describeItem);
397
+ }
398
+
399
+ async function snapshotOrder(projectId, repo, statusFilter, env, runChild) {
400
+ const items = await fetchAllItems(projectId, env, runChild);
401
+ return snapshotFromItems(items, repo, statusFilter);
402
+ }
403
+
404
+ // Resolve a ref (number or item node ID) against a pre-fetched item list,
405
+ // enforcing the same repo scope for BOTH number and id refs so a cross-project
406
+ // ref fails closed with ITEM_NOT_FOUND.
407
+ function resolveFromItems(items, itemRef, repo) {
408
+ let match;
409
+ if (itemRef.kind === "id") {
410
+ match = items.find(
411
+ (it) => it.id === itemRef.value && it.content?.repository?.nameWithOwner === repo,
412
+ );
413
+ if (!match) {
414
+ throw Object.assign(
415
+ new Error(`Item "${itemRef.value}" not found in project for repo "${repo}"`),
416
+ { code: "ITEM_NOT_FOUND" },
417
+ );
418
+ }
419
+ } else {
420
+ match = items.find(
421
+ (it) =>
422
+ it.content &&
423
+ it.content.repository?.nameWithOwner === repo &&
424
+ it.content.number === itemRef.value,
425
+ );
426
+ if (!match) {
427
+ throw Object.assign(
428
+ new Error(`Item #${itemRef.value} not found in project for repo "${repo}"`),
429
+ { code: "ITEM_NOT_FOUND" },
430
+ );
431
+ }
432
+ }
433
+ return describeItem(match);
434
+ }
435
+
299
436
  // ── Resolve an item in a project by reference (number or node ID) ──────
300
437
 
301
438
  async function resolveProjectItem(projectId, itemRef, owner, repoName, repo, env, runChild) {
@@ -335,25 +472,7 @@ async function resolveProjectItem(projectId, itemRef, owner, repoName, repo, env
335
472
  } else {
336
473
  // Look up by issue/PR number in the project (paginated)
337
474
  const targetNumber = itemRef.value;
338
- let allItems = [];
339
- let after = null;
340
- while (true) {
341
- const vars = { projectId };
342
- if (after) vars.after = after;
343
- const itemsPayload = await ghGraphql(GET_PROJECT_ITEMS_BY_CONTENT, vars, env, runChild);
344
- const connection = itemsPayload?.data?.node?.items;
345
- const nodes = connection?.nodes ?? [];
346
- allItems.push(...nodes);
347
- const pageInfo = connection?.pageInfo ?? {};
348
- if (!pageInfo.hasNextPage) break;
349
- if (!pageInfo.endCursor) {
350
- throw Object.assign(
351
- new Error("Invalid items payload: hasNextPage is true but endCursor is missing"),
352
- { code: "GH_API_ERROR" },
353
- );
354
- }
355
- after = pageInfo.endCursor;
356
- }
475
+ const allItems = await fetchAllItems(projectId, env, runChild);
357
476
 
358
477
  // Filter by matching repo AND number exactly
359
478
  const matchingItems = allItems.filter((it) => {
@@ -400,70 +519,68 @@ function classifyExitCode(err) {
400
519
  return 2;
401
520
  }
402
521
 
403
- // ── Main logic ──────────────────────────────────────────────────────────
404
-
405
- async function main(args, { env = process.env, runChild } = {}) {
406
- const child = runChild ?? _runChild;
407
- const repo = validateRepo(args.repo);
408
- const [owner, repoName] = repo.split("/");
409
- const projectRef = parseProjectRef(args.project);
410
- const itemRef = parseItemRef(args.item);
411
-
412
- // --after is optional
413
- let afterRef = null;
414
- if (args.after !== undefined) {
415
- afterRef = parseItemRef(args.after);
416
- }
417
-
418
- // 1. Resolve owner
419
- const { id: ownerId, kind: ownerKind } = await resolveOwner(owner, env, child);
522
+ // ── Resolve owner + project (shared) ──────────────────────────────────────
420
523
 
421
- // 2. Resolve project
524
+ async function resolveProject(owner, projectRef, env, child) {
525
+ const { kind: ownerKind } = await resolveOwner(owner, env, child);
422
526
  const projects = await listAllProjects(owner, ownerKind, env, child);
423
- let project;
424
- if (projectRef.kind === "id") {
425
- project = projects.find((p) => p.id === projectRef.value);
426
- } else {
427
- project = projects.find((p) => p.number === projectRef.value);
428
- }
527
+ const project = projectRef.kind === "id"
528
+ ? projects.find((p) => p.id === projectRef.value)
529
+ : projects.find((p) => p.number === projectRef.value);
429
530
  if (!project) {
430
531
  throw Object.assign(
431
532
  new Error(`Project ${projectRef.kind === "id" ? `"${projectRef.value}"` : `number ${projectRef.value}`} not found under owner "${owner}"`),
432
533
  { code: "PROJECT_NOT_FOUND" },
433
534
  );
434
535
  }
536
+ return project;
537
+ }
538
+
539
+ function executePosition(projectId, itemId, afterId) {
540
+ return {
541
+ query: UPDATE_ITEM_POSITION,
542
+ variables: afterId
543
+ ? { projectId, itemId, afterId }
544
+ : { projectId, itemId },
545
+ };
546
+ }
547
+
548
+ // ── Legacy flag form: --item [--after] ────────────────────────────────────
549
+
550
+ async function mainFlagForm(args, { env, child, repo, owner, repoName, project }) {
551
+ const itemRef = parseItemRef(args.item);
552
+ let afterRef = null;
553
+ if (args.after !== undefined) afterRef = parseItemRef(args.after);
435
554
 
436
- // 3. Resolve the item to move
437
555
  const item = await resolveProjectItem(project.id, itemRef, owner, repoName, repo, env, child);
438
556
 
439
- // 4. Resolve the after-item (if provided)
440
557
  let afterItem = null;
441
558
  if (afterRef) {
442
559
  afterItem = await resolveProjectItem(project.id, afterRef, owner, repoName, repo, env, child);
443
-
444
- // Fail closed: cannot reorder after itself
445
560
  if (afterItem.itemId === item.itemId) {
446
- throw Object.assign(
447
- new Error("Cannot reorder an item after itself"),
448
- { code: "INVALID_AFTER" },
449
- );
561
+ throw Object.assign(new Error("Cannot reorder an item after itself"), { code: "INVALID_AFTER" });
450
562
  }
451
563
  }
452
564
 
453
- // 5. Execute reorder mutation
454
- const mutationVars = {
455
- projectId: project.id,
456
- itemId: item.itemId,
457
- afterId: afterItem ? afterItem.itemId : null,
458
- };
459
-
460
- const mutationPayload = await ghGraphql(UPDATE_ITEM_POSITION, mutationVars, env, child);
565
+ const mutation = executePosition(project.id, item.itemId, afterItem ? afterItem.itemId : null);
566
+
567
+ if (args.dryRun) {
568
+ // Include the before snapshot for parity with the subcommand dry-run form.
569
+ const before = await snapshotOrder(project.id, repo, item.status ?? null, env, child);
570
+ return {
571
+ ok: true,
572
+ dryRun: true,
573
+ mutations: [mutation],
574
+ before,
575
+ };
576
+ }
461
577
 
578
+ const mutationPayload = await ghGraphql(mutation.query, mutation.variables, env, child);
462
579
  if (!mutationPayload?.data?.updateProjectV2ItemPosition) {
463
580
  throw Object.assign(new Error("Failed to reorder item"), { code: "MUTATION_FAILED" });
464
581
  }
465
582
 
466
- const result = {
583
+ return {
467
584
  ok: true,
468
585
  item: {
469
586
  itemId: item.itemId,
@@ -473,15 +590,174 @@ async function main(args, { env = process.env, runChild } = {}) {
473
590
  position: afterItem ? "after" : "top",
474
591
  },
475
592
  after: afterItem
476
- ? {
477
- itemId: afterItem.itemId,
478
- issueNumber: afterItem.issueNumber,
479
- prNumber: afterItem.prNumber,
480
- }
593
+ ? { itemId: afterItem.itemId, issueNumber: afterItem.issueNumber, prNumber: afterItem.prNumber }
481
594
  : null,
482
595
  };
596
+ }
597
+
598
+ // ── Subcommand forms: move-to-top / move-after / order ────────────────────
483
599
 
484
- return result;
600
+ function requirePositionals(subcommand, positional) {
601
+ if (subcommand === "move-to-top") {
602
+ if (positional.length !== 1) {
603
+ throw Object.assign(new Error("move-to-top requires exactly one <ref>"), { code: "INVALID_ARGS", usage: USAGE });
604
+ }
605
+ } else if (subcommand === "move-after") {
606
+ if (positional.length !== 2) {
607
+ throw Object.assign(new Error("move-after requires <ref> <after-ref>"), { code: "INVALID_ARGS", usage: USAGE });
608
+ }
609
+ } else if (subcommand === "order") {
610
+ if (positional.length < 2) {
611
+ throw Object.assign(new Error("order requires at least two <ref> values"), { code: "INVALID_ARGS", usage: USAGE });
612
+ }
613
+ }
614
+ }
615
+
616
+ async function mainSubcommand(args, { env, child, repo, project }) {
617
+ const subcommand = args._subcommand;
618
+ const positional = args._positional ?? [];
619
+ requirePositionals(subcommand, positional);
620
+
621
+ // Fetch the board item list ONCE, then resolve every ref (number or id) from
622
+ // that single list — avoids N full-board scans for N refs and enforces the
623
+ // same repo scope for both ref kinds (cross-project refs fail closed).
624
+ const items = await fetchAllItems(project.id, env, child);
625
+
626
+ // Resolve all referenced items up-front (fail closed before any mutation).
627
+ const refs = positional.map((p) => parseItemRef(p));
628
+ const resolved = refs.map((ref) => resolveFromItems(items, ref, repo));
629
+
630
+ // Reordering positions within a single Status column. The before/after snapshot is scoped
631
+ // to one column, and a cross-column move plan is misleading/invalid — so fail closed unless
632
+ // every resolved ref shares the first ref's Status (multi-ref subcommands only).
633
+ if (resolved.length > 1) {
634
+ const primaryStatus = resolved[0].status ?? null;
635
+ const offender = resolved.find((it) => (it.status ?? null) !== primaryStatus);
636
+ if (offender) {
637
+ throw Object.assign(
638
+ new Error(
639
+ `All reordered items must be in the same Status column as the first item (${primaryStatus ?? "(none)"}); ` +
640
+ `${offender.itemId} is in ${offender.status ?? "(none)"}.`,
641
+ ),
642
+ { code: "MIXED_STATUS" },
643
+ );
644
+ }
645
+ }
646
+
647
+ // Build the ordered list of position moves, each { item, afterItem|null }.
648
+ let plan;
649
+ if (subcommand === "move-to-top") {
650
+ plan = [{ item: resolved[0], afterItem: null }];
651
+ } else if (subcommand === "move-after") {
652
+ const [item, afterItem] = resolved;
653
+ if (item.itemId === afterItem.itemId) {
654
+ throw Object.assign(new Error("Cannot reorder an item after itself"), { code: "INVALID_AFTER" });
655
+ }
656
+ plan = [{ item, afterItem }];
657
+ } else {
658
+ // order: ref1 to top, then each subsequent ref after its predecessor.
659
+ plan = resolved.map((item, idx) => ({
660
+ item,
661
+ afterItem: idx === 0 ? null : resolved[idx - 1],
662
+ }));
663
+ }
664
+
665
+ const mutations = plan.map((m) => executePosition(project.id, m.item.itemId, m.afterItem ? m.afterItem.itemId : null));
666
+
667
+ // Status column for the diff snapshot: use the primary moved item's status.
668
+ // Reuse the already-fetched list for the before-snapshot (no extra fetch).
669
+ const statusFilter = resolved[0].status ?? null;
670
+ const before = snapshotFromItems(items, repo, statusFilter);
671
+
672
+ if (args.dryRun) {
673
+ return { ok: true, dryRun: true, mutations, before };
674
+ }
675
+
676
+ // NOTE: `order` applies N sequential position mutations and is NOT atomic. A
677
+ // mid-sequence failure leaves the board partially reordered; re-running the
678
+ // same `order` command is idempotent and is the supported recovery path. The
679
+ // thrown error reports how many moves were applied before failing.
680
+ for (let i = 0; i < mutations.length; i++) {
681
+ const mutation = mutations[i];
682
+ let payload;
683
+ try {
684
+ payload = await ghGraphql(mutation.query, mutation.variables, env, child);
685
+ } catch (err) {
686
+ if (subcommand === "order") {
687
+ err.message = `${err.message} (order partially applied: ${i} of ${mutations.length} moves completed; re-run the same order command to recover)`;
688
+ err.appliedMoves = i;
689
+ err.totalMoves = mutations.length;
690
+ }
691
+ throw err;
692
+ }
693
+ if (!payload?.data?.updateProjectV2ItemPosition) {
694
+ const detail = subcommand === "order"
695
+ ? ` (order partially applied: ${i} of ${mutations.length} moves completed; re-run the same order command to recover)`
696
+ : "";
697
+ throw Object.assign(new Error(`Failed to reorder item${detail}`), {
698
+ code: "MUTATION_FAILED",
699
+ ...(subcommand === "order" ? { appliedMoves: i, totalMoves: mutations.length } : {}),
700
+ });
701
+ }
702
+ }
703
+
704
+ const after = await snapshotOrder(project.id, repo, statusFilter, env, child);
705
+
706
+ const moves = plan.map((m) => ({
707
+ itemId: m.item.itemId,
708
+ issueNumber: m.item.issueNumber,
709
+ prNumber: m.item.prNumber,
710
+ status: m.item.status,
711
+ position: m.afterItem ? "after" : "top",
712
+ afterId: m.afterItem ? m.afterItem.itemId : null,
713
+ }));
714
+
715
+ if (subcommand === "order") {
716
+ return { ok: true, moves, before, after };
717
+ }
718
+
719
+ // move-to-top / move-after: single move, richer shape.
720
+ const m = plan[0];
721
+ return {
722
+ ok: true,
723
+ item: {
724
+ itemId: m.item.itemId,
725
+ issueNumber: m.item.issueNumber,
726
+ prNumber: m.item.prNumber,
727
+ status: m.item.status,
728
+ position: m.afterItem ? "after" : "top",
729
+ },
730
+ after_ref: m.afterItem
731
+ ? { itemId: m.afterItem.itemId, issueNumber: m.afterItem.issueNumber, prNumber: m.afterItem.prNumber }
732
+ : null,
733
+ before,
734
+ after,
735
+ };
736
+ }
737
+
738
+ // ── Main logic ──────────────────────────────────────────────────────────
739
+
740
+ async function main(args, { env = process.env, runChild } = {}) {
741
+ const child = runChild ?? _runChild;
742
+ const repo = validateRepo(args.repo);
743
+ const [owner, repoName] = repo.split("/");
744
+ const projectRef = parseProjectRef(args.project);
745
+
746
+ // Fail closed: the legacy flag form takes no positional arguments. A stray
747
+ // token (e.g. `reorder 630 --item ...`) must not be silently ignored.
748
+ if (!args._subcommand && (args._positional?.length ?? 0) > 0) {
749
+ throw Object.assign(
750
+ new Error(`Unexpected argument: ${args._positional[0]}`),
751
+ { code: "INVALID_ARGS", usage: USAGE },
752
+ );
753
+ }
754
+
755
+ const project = await resolveProject(owner, projectRef, env, child);
756
+
757
+ if (args._subcommand) {
758
+ return mainSubcommand(args, { env, child, repo, project });
759
+ }
760
+ return mainFlagForm(args, { env, child, repo, owner, repoName, project });
485
761
  }
486
762
 
487
763
  // ── CLI entrypoint ──────────────────────────────────────────────────────
@@ -489,7 +765,7 @@ async function main(args, { env = process.env, runChild } = {}) {
489
765
  async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env } = {}) {
490
766
  let args;
491
767
  try {
492
- args = parseArgs(argv);
768
+ args = parseCliArgs(argv);
493
769
  } catch (err) {
494
770
  stderr.write(`${formatCliError(err)}\n`);
495
771
  process.exitCode = 1;