dev-loops 0.2.6 → 0.2.7

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 (58) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +2 -1
  3. package/.claude/skills/dev-loop/SKILL.md +5 -5
  4. package/CHANGELOG.md +17 -0
  5. package/agents/dev-loop.agent.md +5 -1
  6. package/cli/index.mjs +3 -1
  7. package/package.json +2 -2
  8. package/scripts/_cli-primitives.mjs +2 -0
  9. package/scripts/claude/generate-claude-assets.mjs +12 -2
  10. package/scripts/docs/validate-links.mjs +20 -11
  11. package/scripts/github/capture-review-threads.mjs +32 -14
  12. package/scripts/github/detect-checkpoint-evidence.mjs +28 -11
  13. package/scripts/github/detect-linked-issue-pr.mjs +22 -10
  14. package/scripts/github/manage-sub-issues.mjs +37 -16
  15. package/scripts/github/probe-copilot-review.mjs +24 -12
  16. package/scripts/github/ready-for-review.mjs +17 -8
  17. package/scripts/github/reconcile-draft-gate.mjs +22 -10
  18. package/scripts/github/reply-resolve-review-threads.mjs +34 -15
  19. package/scripts/github/request-copilot-review.mjs +28 -11
  20. package/scripts/github/resolve-tracker-local-spec.mjs +29 -12
  21. package/scripts/github/stage-reviewer-draft.mjs +32 -14
  22. package/scripts/github/upsert-checkpoint-verdict.mjs +49 -26
  23. package/scripts/github/write-gate-findings-log.mjs +41 -20
  24. package/scripts/loop/build-handoff-envelope.mjs +32 -14
  25. package/scripts/loop/conductor-monitor.mjs +25 -9
  26. package/scripts/loop/conductor.mjs +31 -12
  27. package/scripts/loop/copilot-pr-handoff.mjs +31 -14
  28. package/scripts/loop/debt-remediate.mjs +28 -11
  29. package/scripts/loop/detect-copilot-loop-state.mjs +29 -12
  30. package/scripts/loop/detect-copilot-session-activity.mjs +29 -12
  31. package/scripts/loop/detect-initial-copilot-pr-state.mjs +26 -10
  32. package/scripts/loop/detect-internal-only-pr.mjs +31 -13
  33. package/scripts/loop/detect-issue-refinement-artifact.mjs +29 -12
  34. package/scripts/loop/detect-pr-gate-coordination-state.mjs +26 -10
  35. package/scripts/loop/detect-reviewer-loop-state.mjs +35 -16
  36. package/scripts/loop/detect-stale-runner.mjs +32 -14
  37. package/scripts/loop/detect-tracker-pr-state.mjs +23 -8
  38. package/scripts/loop/info.mjs +28 -10
  39. package/scripts/loop/inspect-run-viewer/cli.mjs +44 -21
  40. package/scripts/loop/inspect-run.mjs +35 -16
  41. package/scripts/loop/outer-loop.mjs +35 -16
  42. package/scripts/loop/pr-runner-coordination.mjs +31 -12
  43. package/scripts/loop/pre-commit-branch-guard.mjs +26 -9
  44. package/scripts/loop/pre-flight-gate.mjs +25 -9
  45. package/scripts/loop/pre-pr-ready-gate.mjs +24 -8
  46. package/scripts/loop/pre-push-main-guard.mjs +19 -5
  47. package/scripts/loop/pre-write-remote-freshness-guard.mjs +23 -7
  48. package/scripts/loop/resolve-dev-loop-startup.mjs +29 -12
  49. package/scripts/loop/run-conductor-cycle.mjs +23 -8
  50. package/scripts/loop/run-refinement-audit.mjs +44 -22
  51. package/scripts/loop/run-watch-cycle.mjs +28 -12
  52. package/scripts/loop/steer-loop.mjs +122 -62
  53. package/scripts/loop/watch-initial-copilot-pr.mjs +28 -12
  54. package/scripts/projects/archive-done-items.mjs +410 -0
  55. package/scripts/projects/reorder-queue-item.mjs +334 -76
  56. package/scripts/refine/_refine-helpers.mjs +21 -9
  57. package/scripts/refine/verify.mjs +31 -13
  58. package/skills/dev-loop/SKILL.md +9 -5
@@ -2,20 +2,35 @@
2
2
  import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
3
3
  import { runChild as _runChild } from "../_cli-primitives.mjs";
4
4
 
5
- const USAGE = `Usage: dev-loops project reorder --repo <owner/name> --project <number|id> --item <number|node-id> [--after <number|node-id>]
5
+ const USAGE = `Usage:
6
+ dev-loops project reorder --repo <owner/name> --project <number|id> --item <number|node-id> [--after <number|node-id>]
7
+ dev-loops project reorder move-to-top <ref> --repo <owner/name> --project <number|id>
8
+ dev-loops project reorder move-after <ref> <after-ref> --repo <owner/name> --project <number|id>
9
+ dev-loops project reorder order <ref1> <ref2> ... --repo <owner/name> --project <number|id>
6
10
 
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.
11
+ Reorder GitHub Projects V2 items by board position via updateProjectV2ItemPosition.
12
+
13
+ Forms:
14
+ (no subcommand) Flag form. Moves --item to top, or after --after when provided.
15
+ move-to-top <ref> Move <ref> to the first position in its current Status column.
16
+ move-after <ref> <after-ref>
17
+ Move <ref> immediately after <after-ref>.
18
+ order <ref1> ... Set explicit ordering: ref1 first, ref2 after ref1, and so on.
19
+
20
+ A <ref> is an issue/PR number OR a project item node ID. Works for both issues and PRs.
9
21
 
10
22
  Options:
11
23
  --repo <owner/name> Required. Repository to scope the project search.
12
24
  --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.
25
+ --item <number|node-id> Flag form: item to reorder.
26
+ --after <number|node-id> Flag form: position after this item. When omitted, move to top.
27
+ --dry-run Print the intended GraphQL mutation(s) without executing.
15
28
  --help, -h Show this help.
16
29
 
17
30
  Output (stdout):
18
- JSON: { ok: true, item: { itemId, issueNumber, prNumber, status, position }, after: { itemId, issueNumber, prNumber } | null }
31
+ JSON. Move/move-to-top: { ok, item, after_ref|null, before, after }.
32
+ order: { ok, moves: [...], before, after }.
33
+ dry-run: { ok, dryRun: true, mutations: [{ query, variables }], before }.
19
34
 
20
35
  Exit codes:
21
36
  0 — success
@@ -24,13 +39,27 @@ Exit codes:
24
39
  3 — project, item, or after-item not found
25
40
  `.trim();
26
41
 
27
- const VALID_ARGS = new Set(["--repo", "--project", "--item", "--after", "--help", "-h"]);
42
+ const SUBCOMMANDS = new Set(["move-to-top", "move-after", "order"]);
43
+ const VALID_ARGS = new Set(["--repo", "--project", "--item", "--after", "--dry-run", "--help", "-h"]);
28
44
 
29
45
  function parseArgs(argv) {
30
- const args = {};
31
- for (let i = 0; i < argv.length; i++) {
46
+ const args = { _positional: [] };
47
+ let i = 0;
48
+
49
+ // Optional leading positional subcommand.
50
+ if (argv.length > 0 && SUBCOMMANDS.has(argv[0])) {
51
+ args._subcommand = argv[0];
52
+ i = 1;
53
+ }
54
+
55
+ for (; i < argv.length; i++) {
32
56
  const arg = argv[i];
33
- if (!VALID_ARGS.has(arg) && arg.startsWith("-")) {
57
+ if (!arg.startsWith("-")) {
58
+ // Positional ref (only meaningful with a subcommand).
59
+ args._positional.push(arg);
60
+ continue;
61
+ }
62
+ if (!VALID_ARGS.has(arg)) {
34
63
  throw Object.assign(
35
64
  new Error(`Unknown flag: ${arg}`),
36
65
  { code: "INVALID_ARGS", usage: USAGE },
@@ -56,6 +85,8 @@ function parseArgs(argv) {
56
85
  throw Object.assign(new Error("--after requires a value (number or node ID)"), { code: "INVALID_AFTER" });
57
86
  }
58
87
  args.after = argv[++i];
88
+ } else if (arg === "--dry-run") {
89
+ args.dryRun = true;
59
90
  } else if (arg === "--help" || arg === "-h") {
60
91
  args.help = true;
61
92
  } else {
@@ -296,6 +327,94 @@ async function listAllProjects(login, kind, env, runChild) {
296
327
  return projects;
297
328
  }
298
329
 
330
+ // ── Fetch all project items (paginated, position order) ────────────────
331
+
332
+ async function fetchAllItems(projectId, env, runChild) {
333
+ const allItems = [];
334
+ let after = null;
335
+ while (true) {
336
+ const vars = { projectId };
337
+ if (after) vars.after = after;
338
+ const itemsPayload = await ghGraphql(GET_PROJECT_ITEMS_BY_CONTENT, vars, env, runChild);
339
+ const connection = itemsPayload?.data?.node?.items;
340
+ const nodes = connection?.nodes ?? [];
341
+ allItems.push(...nodes);
342
+ const pageInfo = connection?.pageInfo ?? {};
343
+ if (!pageInfo.hasNextPage) break;
344
+ if (!pageInfo.endCursor) {
345
+ throw Object.assign(
346
+ new Error("Invalid items payload: hasNextPage is true but endCursor is missing"),
347
+ { code: "GH_API_ERROR" },
348
+ );
349
+ }
350
+ after = pageInfo.endCursor;
351
+ }
352
+ return allItems;
353
+ }
354
+
355
+ function statusOf(node) {
356
+ const fvs = node?.fieldValues?.nodes ?? [];
357
+ for (const fv of fvs) {
358
+ if (fv && fv.field && fv.field.name === "Status") return fv.name;
359
+ }
360
+ return null;
361
+ }
362
+
363
+ function describeItem(node) {
364
+ return {
365
+ itemId: node.id,
366
+ issueNumber: node.content?.__typename === "Issue" ? node.content.number : null,
367
+ prNumber: node.content?.__typename === "PullRequest" ? node.content.number : null,
368
+ status: statusOf(node),
369
+ };
370
+ }
371
+
372
+ // Build a diff-friendly snapshot of items in `repo` (optionally a single Status
373
+ // column), in board position order, from a pre-fetched item list.
374
+ function snapshotFromItems(items, repo, statusFilter) {
375
+ return items
376
+ .filter((it) => it.content && it.content.repository?.nameWithOwner === repo)
377
+ .filter((it) => (statusFilter == null ? true : statusOf(it) === statusFilter))
378
+ .map(describeItem);
379
+ }
380
+
381
+ async function snapshotOrder(projectId, repo, statusFilter, env, runChild) {
382
+ const items = await fetchAllItems(projectId, env, runChild);
383
+ return snapshotFromItems(items, repo, statusFilter);
384
+ }
385
+
386
+ // Resolve a ref (number or item node ID) against a pre-fetched item list,
387
+ // enforcing the same repo scope for BOTH number and id refs so a cross-project
388
+ // ref fails closed with ITEM_NOT_FOUND.
389
+ function resolveFromItems(items, itemRef, repo) {
390
+ let match;
391
+ if (itemRef.kind === "id") {
392
+ match = items.find(
393
+ (it) => it.id === itemRef.value && it.content?.repository?.nameWithOwner === repo,
394
+ );
395
+ if (!match) {
396
+ throw Object.assign(
397
+ new Error(`Item "${itemRef.value}" not found in project for repo "${repo}"`),
398
+ { code: "ITEM_NOT_FOUND" },
399
+ );
400
+ }
401
+ } else {
402
+ match = items.find(
403
+ (it) =>
404
+ it.content &&
405
+ it.content.repository?.nameWithOwner === repo &&
406
+ it.content.number === itemRef.value,
407
+ );
408
+ if (!match) {
409
+ throw Object.assign(
410
+ new Error(`Item #${itemRef.value} not found in project for repo "${repo}"`),
411
+ { code: "ITEM_NOT_FOUND" },
412
+ );
413
+ }
414
+ }
415
+ return describeItem(match);
416
+ }
417
+
299
418
  // ── Resolve an item in a project by reference (number or node ID) ──────
300
419
 
301
420
  async function resolveProjectItem(projectId, itemRef, owner, repoName, repo, env, runChild) {
@@ -335,25 +454,7 @@ async function resolveProjectItem(projectId, itemRef, owner, repoName, repo, env
335
454
  } else {
336
455
  // Look up by issue/PR number in the project (paginated)
337
456
  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
- }
457
+ const allItems = await fetchAllItems(projectId, env, runChild);
357
458
 
358
459
  // Filter by matching repo AND number exactly
359
460
  const matchingItems = allItems.filter((it) => {
@@ -400,70 +501,68 @@ function classifyExitCode(err) {
400
501
  return 2;
401
502
  }
402
503
 
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);
504
+ // ── Resolve owner + project (shared) ──────────────────────────────────────
420
505
 
421
- // 2. Resolve project
506
+ async function resolveProject(owner, projectRef, env, child) {
507
+ const { kind: ownerKind } = await resolveOwner(owner, env, child);
422
508
  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
- }
509
+ const project = projectRef.kind === "id"
510
+ ? projects.find((p) => p.id === projectRef.value)
511
+ : projects.find((p) => p.number === projectRef.value);
429
512
  if (!project) {
430
513
  throw Object.assign(
431
514
  new Error(`Project ${projectRef.kind === "id" ? `"${projectRef.value}"` : `number ${projectRef.value}`} not found under owner "${owner}"`),
432
515
  { code: "PROJECT_NOT_FOUND" },
433
516
  );
434
517
  }
518
+ return project;
519
+ }
520
+
521
+ function executePosition(projectId, itemId, afterId) {
522
+ return {
523
+ query: UPDATE_ITEM_POSITION,
524
+ variables: afterId
525
+ ? { projectId, itemId, afterId }
526
+ : { projectId, itemId },
527
+ };
528
+ }
529
+
530
+ // ── Legacy flag form: --item [--after] ────────────────────────────────────
531
+
532
+ async function mainFlagForm(args, { env, child, repo, owner, repoName, project }) {
533
+ const itemRef = parseItemRef(args.item);
534
+ let afterRef = null;
535
+ if (args.after !== undefined) afterRef = parseItemRef(args.after);
435
536
 
436
- // 3. Resolve the item to move
437
537
  const item = await resolveProjectItem(project.id, itemRef, owner, repoName, repo, env, child);
438
538
 
439
- // 4. Resolve the after-item (if provided)
440
539
  let afterItem = null;
441
540
  if (afterRef) {
442
541
  afterItem = await resolveProjectItem(project.id, afterRef, owner, repoName, repo, env, child);
443
-
444
- // Fail closed: cannot reorder after itself
445
542
  if (afterItem.itemId === item.itemId) {
446
- throw Object.assign(
447
- new Error("Cannot reorder an item after itself"),
448
- { code: "INVALID_AFTER" },
449
- );
543
+ throw Object.assign(new Error("Cannot reorder an item after itself"), { code: "INVALID_AFTER" });
450
544
  }
451
545
  }
452
546
 
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);
547
+ const mutation = executePosition(project.id, item.itemId, afterItem ? afterItem.itemId : null);
548
+
549
+ if (args.dryRun) {
550
+ // Include the before snapshot for parity with the subcommand dry-run form.
551
+ const before = await snapshotOrder(project.id, repo, item.status ?? null, env, child);
552
+ return {
553
+ ok: true,
554
+ dryRun: true,
555
+ mutations: [mutation],
556
+ before,
557
+ };
558
+ }
461
559
 
560
+ const mutationPayload = await ghGraphql(mutation.query, mutation.variables, env, child);
462
561
  if (!mutationPayload?.data?.updateProjectV2ItemPosition) {
463
562
  throw Object.assign(new Error("Failed to reorder item"), { code: "MUTATION_FAILED" });
464
563
  }
465
564
 
466
- const result = {
565
+ return {
467
566
  ok: true,
468
567
  item: {
469
568
  itemId: item.itemId,
@@ -473,15 +572,174 @@ async function main(args, { env = process.env, runChild } = {}) {
473
572
  position: afterItem ? "after" : "top",
474
573
  },
475
574
  after: afterItem
476
- ? {
477
- itemId: afterItem.itemId,
478
- issueNumber: afterItem.issueNumber,
479
- prNumber: afterItem.prNumber,
480
- }
575
+ ? { itemId: afterItem.itemId, issueNumber: afterItem.issueNumber, prNumber: afterItem.prNumber }
576
+ : null,
577
+ };
578
+ }
579
+
580
+ // ── Subcommand forms: move-to-top / move-after / order ────────────────────
581
+
582
+ function requirePositionals(subcommand, positional) {
583
+ if (subcommand === "move-to-top") {
584
+ if (positional.length !== 1) {
585
+ throw Object.assign(new Error("move-to-top requires exactly one <ref>"), { code: "INVALID_ARGS", usage: USAGE });
586
+ }
587
+ } else if (subcommand === "move-after") {
588
+ if (positional.length !== 2) {
589
+ throw Object.assign(new Error("move-after requires <ref> <after-ref>"), { code: "INVALID_ARGS", usage: USAGE });
590
+ }
591
+ } else if (subcommand === "order") {
592
+ if (positional.length < 2) {
593
+ throw Object.assign(new Error("order requires at least two <ref> values"), { code: "INVALID_ARGS", usage: USAGE });
594
+ }
595
+ }
596
+ }
597
+
598
+ async function mainSubcommand(args, { env, child, repo, project }) {
599
+ const subcommand = args._subcommand;
600
+ const positional = args._positional ?? [];
601
+ requirePositionals(subcommand, positional);
602
+
603
+ // Fetch the board item list ONCE, then resolve every ref (number or id) from
604
+ // that single list — avoids N full-board scans for N refs and enforces the
605
+ // same repo scope for both ref kinds (cross-project refs fail closed).
606
+ const items = await fetchAllItems(project.id, env, child);
607
+
608
+ // Resolve all referenced items up-front (fail closed before any mutation).
609
+ const refs = positional.map((p) => parseItemRef(p));
610
+ const resolved = refs.map((ref) => resolveFromItems(items, ref, repo));
611
+
612
+ // Reordering positions within a single Status column. The before/after snapshot is scoped
613
+ // to one column, and a cross-column move plan is misleading/invalid — so fail closed unless
614
+ // every resolved ref shares the first ref's Status (multi-ref subcommands only).
615
+ if (resolved.length > 1) {
616
+ const primaryStatus = resolved[0].status ?? null;
617
+ const offender = resolved.find((it) => (it.status ?? null) !== primaryStatus);
618
+ if (offender) {
619
+ throw Object.assign(
620
+ new Error(
621
+ `All reordered items must be in the same Status column as the first item (${primaryStatus ?? "(none)"}); ` +
622
+ `${offender.itemId} is in ${offender.status ?? "(none)"}.`,
623
+ ),
624
+ { code: "MIXED_STATUS" },
625
+ );
626
+ }
627
+ }
628
+
629
+ // Build the ordered list of position moves, each { item, afterItem|null }.
630
+ let plan;
631
+ if (subcommand === "move-to-top") {
632
+ plan = [{ item: resolved[0], afterItem: null }];
633
+ } else if (subcommand === "move-after") {
634
+ const [item, afterItem] = resolved;
635
+ if (item.itemId === afterItem.itemId) {
636
+ throw Object.assign(new Error("Cannot reorder an item after itself"), { code: "INVALID_AFTER" });
637
+ }
638
+ plan = [{ item, afterItem }];
639
+ } else {
640
+ // order: ref1 to top, then each subsequent ref after its predecessor.
641
+ plan = resolved.map((item, idx) => ({
642
+ item,
643
+ afterItem: idx === 0 ? null : resolved[idx - 1],
644
+ }));
645
+ }
646
+
647
+ const mutations = plan.map((m) => executePosition(project.id, m.item.itemId, m.afterItem ? m.afterItem.itemId : null));
648
+
649
+ // Status column for the diff snapshot: use the primary moved item's status.
650
+ // Reuse the already-fetched list for the before-snapshot (no extra fetch).
651
+ const statusFilter = resolved[0].status ?? null;
652
+ const before = snapshotFromItems(items, repo, statusFilter);
653
+
654
+ if (args.dryRun) {
655
+ return { ok: true, dryRun: true, mutations, before };
656
+ }
657
+
658
+ // NOTE: `order` applies N sequential position mutations and is NOT atomic. A
659
+ // mid-sequence failure leaves the board partially reordered; re-running the
660
+ // same `order` command is idempotent and is the supported recovery path. The
661
+ // thrown error reports how many moves were applied before failing.
662
+ for (let i = 0; i < mutations.length; i++) {
663
+ const mutation = mutations[i];
664
+ let payload;
665
+ try {
666
+ payload = await ghGraphql(mutation.query, mutation.variables, env, child);
667
+ } catch (err) {
668
+ if (subcommand === "order") {
669
+ err.message = `${err.message} (order partially applied: ${i} of ${mutations.length} moves completed; re-run the same order command to recover)`;
670
+ err.appliedMoves = i;
671
+ err.totalMoves = mutations.length;
672
+ }
673
+ throw err;
674
+ }
675
+ if (!payload?.data?.updateProjectV2ItemPosition) {
676
+ const detail = subcommand === "order"
677
+ ? ` (order partially applied: ${i} of ${mutations.length} moves completed; re-run the same order command to recover)`
678
+ : "";
679
+ throw Object.assign(new Error(`Failed to reorder item${detail}`), {
680
+ code: "MUTATION_FAILED",
681
+ ...(subcommand === "order" ? { appliedMoves: i, totalMoves: mutations.length } : {}),
682
+ });
683
+ }
684
+ }
685
+
686
+ const after = await snapshotOrder(project.id, repo, statusFilter, env, child);
687
+
688
+ const moves = plan.map((m) => ({
689
+ itemId: m.item.itemId,
690
+ issueNumber: m.item.issueNumber,
691
+ prNumber: m.item.prNumber,
692
+ status: m.item.status,
693
+ position: m.afterItem ? "after" : "top",
694
+ afterId: m.afterItem ? m.afterItem.itemId : null,
695
+ }));
696
+
697
+ if (subcommand === "order") {
698
+ return { ok: true, moves, before, after };
699
+ }
700
+
701
+ // move-to-top / move-after: single move, richer shape.
702
+ const m = plan[0];
703
+ return {
704
+ ok: true,
705
+ item: {
706
+ itemId: m.item.itemId,
707
+ issueNumber: m.item.issueNumber,
708
+ prNumber: m.item.prNumber,
709
+ status: m.item.status,
710
+ position: m.afterItem ? "after" : "top",
711
+ },
712
+ after_ref: m.afterItem
713
+ ? { itemId: m.afterItem.itemId, issueNumber: m.afterItem.issueNumber, prNumber: m.afterItem.prNumber }
481
714
  : null,
715
+ before,
716
+ after,
482
717
  };
718
+ }
719
+
720
+ // ── Main logic ──────────────────────────────────────────────────────────
721
+
722
+ async function main(args, { env = process.env, runChild } = {}) {
723
+ const child = runChild ?? _runChild;
724
+ const repo = validateRepo(args.repo);
725
+ const [owner, repoName] = repo.split("/");
726
+ const projectRef = parseProjectRef(args.project);
727
+
728
+ // Fail closed: the legacy flag form takes no positional arguments. A stray
729
+ // token (e.g. `reorder 630 --item ...`) must not be silently ignored.
730
+ if (!args._subcommand && (args._positional?.length ?? 0) > 0) {
731
+ throw Object.assign(
732
+ new Error(`Unexpected argument: ${args._positional[0]}`),
733
+ { code: "INVALID_ARGS", usage: USAGE },
734
+ );
735
+ }
483
736
 
484
- return result;
737
+ const project = await resolveProject(owner, projectRef, env, child);
738
+
739
+ if (args._subcommand) {
740
+ return mainSubcommand(args, { env, child, repo, project });
741
+ }
742
+ return mainFlagForm(args, { env, child, repo, owner, repoName, project });
485
743
  }
486
744
 
487
745
  // ── CLI entrypoint ──────────────────────────────────────────────────────
@@ -2,7 +2,8 @@
2
2
  import { readFile } from "node:fs/promises";
3
3
 
4
4
  import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
5
- import { parsePositiveInteger, requireOptionValue, runChild } from "../_cli-primitives.mjs";
5
+ import { parseArgs } from "node:util";
6
+ import { parsePositiveInteger, requireTokenValue, runChild } from "../_cli-primitives.mjs";
6
7
  import { detectRepoSlug, parseRepoSlug } from "@dev-loops/core/github/repo-slug";
7
8
 
8
9
  export const FORBIDDEN_PROSE_PATTERNS = [
@@ -23,23 +24,34 @@ export function normalizeIssueNumber(value, label, parseError) {
23
24
 
24
25
  export function parseCheckerCliArgs(argv, usage, checkerName) {
25
26
  const parseError = buildParseError(usage);
26
- const args = [...argv];
27
+ const { tokens } = parseArgs({
28
+ args: [...argv],
29
+ options: { help: { type: "boolean", short: "h" }, input: { type: "string" }, json: { type: "boolean" } },
30
+ allowPositionals: true,
31
+ strict: false,
32
+ tokens: true,
33
+ });
27
34
  const options = { help: false, input: undefined, json: false };
28
- while (args.length > 0) {
29
- const token = args.shift();
30
- if (token === "--help" || token === "-h") {
35
+ for (const token of tokens) {
36
+ if (token.kind === "positional") {
37
+ throw parseError(`Unknown argument: ${token.value}`);
38
+ }
39
+ if (token.kind !== "option") {
40
+ continue;
41
+ }
42
+ if (token.name === "help") {
31
43
  options.help = true;
32
44
  return options;
33
45
  }
34
- if (token === "--input") {
35
- options.input = requireOptionValue(args, "--input", parseError, { flagPattern: /^-/u });
46
+ if (token.name === "input") {
47
+ options.input = requireTokenValue(token, parseError, { flagPattern: /^-/u });
36
48
  continue;
37
49
  }
38
- if (token === "--json") {
50
+ if (token.name === "json") {
39
51
  options.json = true;
40
52
  continue;
41
53
  }
42
- throw parseError(`Unknown argument: ${token}`);
54
+ throw parseError(`Unknown argument: ${token.rawName}`);
43
55
  }
44
56
  if (typeof options.input !== "string" || options.input.trim().length === 0) {
45
57
  throw parseError(`${checkerName} requires --input <path>`);
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { buildParseError, formatCliError } from "../_core-helpers.mjs";
3
- import { parsePositiveInteger, requireOptionValue } from "../_cli-primitives.mjs";
3
+ import { parseArgs } from "node:util";
4
+ import { parsePositiveInteger, requireTokenValue } from "../_cli-primitives.mjs";
4
5
  import { detectRepoSlug, parseRepoSlug } from "@dev-loops/core/github/repo-slug";
5
6
 
6
7
  import { isDirectCliRun, loadTreeFromInput, loadTreeOnline } from "./_refine-helpers.mjs";
@@ -30,7 +31,19 @@ Optional:
30
31
  const parseError = buildParseError(USAGE);
31
32
 
32
33
  export function parseRefineVerifyCliArgs(argv) {
33
- const args = [...argv];
34
+ const { tokens } = parseArgs({
35
+ args: [...argv],
36
+ options: {
37
+ help: { type: "boolean", short: "h" },
38
+ issue: { type: "string" },
39
+ repo: { type: "string" },
40
+ input: { type: "string" },
41
+ json: { type: "boolean" },
42
+ },
43
+ allowPositionals: true,
44
+ strict: false,
45
+ tokens: true,
46
+ });
34
47
  const options = {
35
48
  help: false,
36
49
  issue: undefined,
@@ -39,29 +52,34 @@ export function parseRefineVerifyCliArgs(argv) {
39
52
  json: false,
40
53
  };
41
54
 
42
- while (args.length > 0) {
43
- const token = args.shift();
44
- if (token === "--help" || token === "-h") {
55
+ for (const token of tokens) {
56
+ if (token.kind === "positional") {
57
+ throw parseError(`Unknown argument: ${token.value}`);
58
+ }
59
+ if (token.kind !== "option") {
60
+ continue;
61
+ }
62
+ if (token.name === "help") {
45
63
  options.help = true;
46
64
  return options;
47
65
  }
48
- if (token === "--issue") {
49
- options.issue = parsePositiveInteger(requireOptionValue(args, "--issue", parseError), "Issue number", parseError);
66
+ if (token.name === "issue") {
67
+ options.issue = parsePositiveInteger(requireTokenValue(token, parseError), "Issue number", parseError);
50
68
  continue;
51
69
  }
52
- if (token === "--repo") {
53
- options.repo = requireOptionValue(args, "--repo", parseError, { flagPattern: /^-/u });
70
+ if (token.name === "repo") {
71
+ options.repo = requireTokenValue(token, parseError, { flagPattern: /^-/u });
54
72
  continue;
55
73
  }
56
- if (token === "--input") {
57
- options.input = requireOptionValue(args, "--input", parseError, { flagPattern: /^-/u });
74
+ if (token.name === "input") {
75
+ options.input = requireTokenValue(token, parseError, { flagPattern: /^-/u });
58
76
  continue;
59
77
  }
60
- if (token === "--json") {
78
+ if (token.name === "json") {
61
79
  options.json = true;
62
80
  continue;
63
81
  }
64
- throw parseError(`Unknown argument: ${token}`);
82
+ throw parseError(`Unknown argument: ${token.rawName}`);
65
83
  }
66
84
 
67
85
  const hasIssueMode = options.issue !== undefined;
@@ -34,7 +34,11 @@ For async-required routes (config `workflow.asyncStartMode`, default `required`)
34
34
 
35
35
  > Under the Claude Code harness the dev-loop runs as a single agent: run these steps directly — no read-only boundary and no separate async-subagent dispatch. See [Main Agent Contract](../docs/main-agent-contract.md).
36
36
 
37
- Resolve authoritative state via the startup resolver (`npx dev-loops loop startup --issue <n>` for issues, `npx dev-loops loop startup --pr <n>` for PRs), then immediately build the handoff envelope via `npx dev-loops loop build-envelope --input <resolver-output.json>`. The envelope determines `requiredReads`, `nextAction`, `stopRules`, and `acceptance` — load only those files, execute only that bounded task. It is the first handoff artifact consumed before loading any route pack. See [Workflow Handoff Contract](../docs/workflow-handoff-contract.md) for the derivation contract.
37
+ <!-- pi-only -->
38
+ **CLI invocation (`<dev-loops-package-root>`):** dev-loop CLI commands below are invoked as `node <dev-loops-package-root>/cli/index.mjs <verb...>` using the package-local CLI rather than `npx`, so they resolve unambiguously from the installed package without a global install. Resolve `<dev-loops-package-root>` from this skill's own installed path: this skill is installed at `<package-root>/.pi/skills/dev-loop/SKILL.md`, so the package root is `../../..` from this skill's directory. (The `dev-loop` agent resolves it analogously from its own installed path.)
39
+ <!-- /pi-only -->
40
+
41
+ Resolve authoritative state via the startup resolver (`node <dev-loops-package-root>/cli/index.mjs loop startup --issue <n>` for issues, `node <dev-loops-package-root>/cli/index.mjs loop startup --pr <n>` for PRs), then immediately build the handoff envelope via `node <dev-loops-package-root>/cli/index.mjs loop build-envelope --input <resolver-output.json>`. The envelope determines `requiredReads`, `nextAction`, `stopRules`, and `acceptance` — load only those files, execute only that bounded task. It is the first handoff artifact consumed before loading any route pack. See [Workflow Handoff Contract](../docs/workflow-handoff-contract.md) for the derivation contract.
38
42
 
39
43
  **Retrospective checkpoint gate:** the resolver reads `.pi/dev-loop-retrospective-checkpoint.json` and injects the state. When the checkpoint is `missing` and the repo config `workflow.requireRetrospective` (set via `.devloops` at repo root) is `true`, the resolver returns `needs_reconcile`. Complete or explicitly skip the retrospective before starting.
40
44
 
@@ -109,10 +113,10 @@ When `@dev-loops/core` is available again, switch back to the full helper. The f
109
113
 
110
114
  ## Read-only info shortcut
111
115
 
112
- Info/handoff requests can be served directly via `npx dev-loops loop info` (read-only; no full dev-loop run required):
113
- - `npx dev-loops loop info --issue <n>` — human-readable issue state summary (strategy, route, linked PR, next action)
114
- - `npx dev-loops loop info --pr <n>` — human-readable PR state summary (branch, CI, threads, rounds, action)
115
- - `npx dev-loops loop info --issue <n> --json` — machine-readable JSON output
116
+ Info/handoff requests can be served directly via `node <dev-loops-package-root>/cli/index.mjs loop info` (read-only; no full dev-loop run required):
117
+ - `node <dev-loops-package-root>/cli/index.mjs loop info --issue <n>` — human-readable issue state summary (strategy, route, linked PR, next action)
118
+ - `node <dev-loops-package-root>/cli/index.mjs loop info --pr <n>` — human-readable PR state summary (branch, CI, threads, rounds, action)
119
+ - `node <dev-loops-package-root>/cli/index.mjs loop info --issue <n> --json` — machine-readable JSON output
116
120
 
117
121
  ## Guard rules
118
122