dev-loops 0.2.5 → 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.
- package/.claude/.claude-plugin/plugin.json +9 -2
- package/.claude/agents/dev-loop.md +2 -1
- package/.claude/hooks/_bash-command-classify.mjs +164 -0
- package/.claude/hooks/_hook-decisions.mjs +130 -0
- package/.claude/hooks/_hook-io.mjs +1 -1
- package/.claude/hooks/_run-context.mjs +179 -0
- package/.claude/hooks/post-tool-use-merge.mjs +1 -1
- package/.claude/hooks/pre-tool-use-bash-gate.mjs +2 -2
- package/.claude/hooks/pre-tool-use-write-guard.mjs +1 -1
- package/.claude/skills/dev-loop/SKILL.md +5 -5
- package/.claude/skills/docs/merge-preconditions.md +15 -0
- package/.claude/skills/docs/pr-lifecycle-contract.md +1 -0
- package/CHANGELOG.md +52 -0
- package/agents/dev-loop.agent.md +5 -1
- package/cli/index.mjs +3 -1
- package/package.json +2 -2
- package/scripts/_cli-primitives.mjs +2 -0
- package/scripts/claude/generate-claude-assets.mjs +71 -3
- package/scripts/docs/validate-links.mjs +20 -11
- package/scripts/github/capture-review-threads.mjs +32 -14
- package/scripts/github/detect-checkpoint-evidence.mjs +28 -11
- package/scripts/github/detect-linked-issue-pr.mjs +22 -10
- package/scripts/github/manage-sub-issues.mjs +37 -16
- package/scripts/github/probe-copilot-review.mjs +24 -12
- package/scripts/github/ready-for-review.mjs +22 -10
- package/scripts/github/reconcile-draft-gate.mjs +22 -10
- package/scripts/github/reply-resolve-review-threads.mjs +34 -15
- package/scripts/github/request-copilot-review.mjs +28 -11
- package/scripts/github/resolve-tracker-local-spec.mjs +29 -12
- package/scripts/github/stage-reviewer-draft.mjs +32 -14
- package/scripts/github/upsert-checkpoint-verdict.mjs +49 -26
- package/scripts/github/write-gate-findings-log.mjs +41 -20
- package/scripts/loop/_loop-pr-aggregation.mjs +45 -0
- package/scripts/loop/build-handoff-envelope.mjs +32 -14
- package/scripts/loop/conductor-monitor.mjs +26 -47
- package/scripts/loop/conductor.mjs +31 -12
- package/scripts/loop/copilot-pr-handoff.mjs +31 -14
- package/scripts/loop/debt-remediate.mjs +28 -11
- package/scripts/loop/detect-copilot-loop-state.mjs +29 -12
- package/scripts/loop/detect-copilot-session-activity.mjs +29 -12
- package/scripts/loop/detect-initial-copilot-pr-state.mjs +26 -10
- package/scripts/loop/detect-internal-only-pr.mjs +31 -13
- package/scripts/loop/detect-issue-refinement-artifact.mjs +29 -12
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +28 -11
- package/scripts/loop/detect-reviewer-loop-state.mjs +35 -16
- package/scripts/loop/detect-stale-runner.mjs +32 -14
- package/scripts/loop/detect-tracker-pr-state.mjs +23 -8
- package/scripts/loop/info.mjs +28 -10
- package/scripts/loop/inspect-run-viewer/cli.mjs +44 -21
- package/scripts/loop/inspect-run.mjs +35 -16
- package/scripts/loop/outer-loop.mjs +35 -16
- package/scripts/loop/pr-runner-coordination.mjs +31 -12
- package/scripts/loop/pre-commit-branch-guard.mjs +26 -9
- package/scripts/loop/pre-flight-gate.mjs +25 -9
- package/scripts/loop/pre-pr-ready-gate.mjs +24 -8
- package/scripts/loop/pre-push-main-guard.mjs +19 -5
- package/scripts/loop/pre-write-remote-freshness-guard.mjs +23 -7
- package/scripts/loop/resolve-dev-loop-startup.mjs +29 -12
- package/scripts/loop/run-conductor-cycle.mjs +25 -47
- package/scripts/loop/run-refinement-audit.mjs +44 -22
- package/scripts/loop/run-watch-cycle.mjs +28 -12
- package/scripts/loop/steer-loop.mjs +122 -62
- package/scripts/loop/watch-initial-copilot-pr.mjs +28 -12
- package/scripts/projects/archive-done-items.mjs +410 -0
- package/scripts/projects/reorder-queue-item.mjs +334 -76
- package/scripts/refine/_refine-helpers.mjs +21 -9
- package/scripts/refine/verify.mjs +31 -13
- package/skills/dev-loop/SKILL.md +9 -5
- package/skills/docs/merge-preconditions.md +15 -0
- package/skills/docs/pr-lifecycle-contract.md +1 -0
|
@@ -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:
|
|
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
|
|
8
|
-
|
|
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>
|
|
14
|
-
--after <number|node-id>
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 === "
|
|
35
|
-
options.input =
|
|
46
|
+
if (token.name === "input") {
|
|
47
|
+
options.input = requireTokenValue(token, parseError, { flagPattern: /^-/u });
|
|
36
48
|
continue;
|
|
37
49
|
}
|
|
38
|
-
if (token === "
|
|
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 {
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 === "
|
|
49
|
-
options.issue = parsePositiveInteger(
|
|
66
|
+
if (token.name === "issue") {
|
|
67
|
+
options.issue = parsePositiveInteger(requireTokenValue(token, parseError), "Issue number", parseError);
|
|
50
68
|
continue;
|
|
51
69
|
}
|
|
52
|
-
if (token === "
|
|
53
|
-
options.repo =
|
|
70
|
+
if (token.name === "repo") {
|
|
71
|
+
options.repo = requireTokenValue(token, parseError, { flagPattern: /^-/u });
|
|
54
72
|
continue;
|
|
55
73
|
}
|
|
56
|
-
if (token === "
|
|
57
|
-
options.input =
|
|
74
|
+
if (token.name === "input") {
|
|
75
|
+
options.input = requireTokenValue(token, parseError, { flagPattern: /^-/u });
|
|
58
76
|
continue;
|
|
59
77
|
}
|
|
60
|
-
if (token === "
|
|
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;
|
package/skills/dev-loop/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
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 `
|
|
113
|
-
- `
|
|
114
|
-
- `
|
|
115
|
-
- `
|
|
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
|
|
|
@@ -10,6 +10,21 @@ Canonical owner for merge preconditions across all workflow families.
|
|
|
10
10
|
4. ✅ All review threads resolved
|
|
11
11
|
5. ✅ Explicit merge authorization from operator
|
|
12
12
|
6. ✅ PR body contains `Closes #N` or `Fixes #N`
|
|
13
|
+
7. ✅ PR **title** free of merge-blocking markers — `WIP`, `[WIP]`, `DRAFT`, `DO NOT MERGE`, `🚧` (case-insensitive)
|
|
14
|
+
|
|
15
|
+
## Title markers
|
|
16
|
+
|
|
17
|
+
The PR title is a contract surface, so a merge-blocking marker in the title is enforced
|
|
18
|
+
deterministically (`findBlockingTitleMarkers` in `@dev-loops/core/loop/pr-title-markers`), not
|
|
19
|
+
just reviewed:
|
|
20
|
+
|
|
21
|
+
- At the **draft → ready-for-review** transition: `ready-for-review` refuses `gh pr ready` while the
|
|
22
|
+
title carries a marker.
|
|
23
|
+
- At the **pre-approval gate boundary and final approval** (for non-draft PRs): the gate coordinator
|
|
24
|
+
returns `title_marker_blocked` so a PR un-drafted externally still cannot enter pre-approval or
|
|
25
|
+
reach merge-ready with a marked title.
|
|
26
|
+
|
|
27
|
+
A marker is allowed only while the PR is still in draft; it must be removed before the PR leaves draft.
|
|
13
28
|
|
|
14
29
|
## Merge authorization
|
|
15
30
|
|