dev-loops 0.2.7 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +1 -1
  3. package/.claude/agents/developer.md +1 -0
  4. package/.claude/agents/fixer.md +1 -0
  5. package/.claude/agents/review.md +30 -0
  6. package/.claude/hooks/_run-context.mjs +9 -16
  7. package/.claude/skills/copilot-pr-followup/SKILL.md +62 -6
  8. package/.claude/skills/dev-loop/SKILL.md +6 -6
  9. package/.claude/skills/docs/anti-patterns.md +2 -0
  10. package/.claude/skills/docs/copilot-loop-operations.md +3 -3
  11. package/.claude/skills/docs/issue-intake-procedure.md +4 -0
  12. package/.claude/skills/docs/merge-preconditions.md +65 -1
  13. package/.claude/skills/docs/stop-conditions.md +1 -0
  14. package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
  15. package/.claude/skills/local-implementation/SKILL.md +25 -5
  16. package/AGENTS.md +1 -1
  17. package/CHANGELOG.md +69 -0
  18. package/README.md +8 -2
  19. package/agents/developer.agent.md +1 -0
  20. package/agents/fixer.agent.md +1 -0
  21. package/agents/review.agent.md +30 -0
  22. package/cli/index.mjs +60 -8
  23. package/extension/README.md +1 -1
  24. package/package.json +3 -3
  25. package/scripts/README.md +8 -7
  26. package/scripts/_core-helpers.mjs +1 -0
  27. package/scripts/claude/headless-dev-loop.mjs +53 -13
  28. package/scripts/claude/headless-info-smoke.mjs +45 -11
  29. package/scripts/github/build-adjacent-bundle.mjs +448 -0
  30. package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
  31. package/scripts/github/detect-checkpoint-evidence.mjs +95 -4
  32. package/scripts/github/offer-human-handoff.mjs +147 -0
  33. package/scripts/github/post-gate-findings.mjs +392 -0
  34. package/scripts/github/probe-ci-status.mjs +468 -0
  35. package/scripts/github/reconcile-draft-gate.mjs +2 -2
  36. package/scripts/github/request-copilot-review.mjs +72 -11
  37. package/scripts/github/resolve-handoff-candidates.mjs +412 -0
  38. package/scripts/github/upsert-checkpoint-verdict.mjs +599 -17
  39. package/scripts/github/verify-fresh-review-context.mjs +12 -1
  40. package/scripts/github/write-gate-context.mjs +634 -0
  41. package/scripts/github/write-gate-findings-log.mjs +1 -1
  42. package/scripts/loop/_stale-runner-detection.mjs +1 -1
  43. package/scripts/loop/_worktree-path.mjs +27 -0
  44. package/scripts/loop/cleanup-worktree.mjs +175 -0
  45. package/scripts/loop/copilot-pr-handoff.mjs +1 -1
  46. package/scripts/loop/detect-change-scope.mjs +36 -11
  47. package/scripts/loop/detect-pr-gate-coordination-state.mjs +30 -18
  48. package/scripts/loop/detect-stale-runner.mjs +3 -4
  49. package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
  50. package/scripts/loop/ensure-worktree.mjs +219 -0
  51. package/scripts/loop/outer-loop.mjs +1 -1
  52. package/scripts/loop/pr-runner-coordination.mjs +1 -1
  53. package/scripts/loop/pre-flight-gate.mjs +10 -7
  54. package/scripts/loop/pre-push-main-guard.mjs +4 -4
  55. package/scripts/loop/provision-worktree.mjs +243 -0
  56. package/scripts/loop/resolve-dev-loop-startup.mjs +5 -5
  57. package/scripts/loop/run-queue.mjs +112 -16
  58. package/scripts/loop/run-watch-cycle.mjs +75 -22
  59. package/scripts/projects/add-queue-item.mjs +80 -48
  60. package/scripts/projects/archive-done-items.mjs +136 -39
  61. package/scripts/projects/ensure-queue-board.mjs +67 -65
  62. package/scripts/projects/list-queue-items.mjs +59 -57
  63. package/scripts/projects/move-queue-item.mjs +125 -125
  64. package/scripts/projects/reorder-queue-item.mjs +67 -48
  65. package/scripts/projects/sync-item-status.mjs +199 -0
  66. package/skills/copilot-pr-followup/SKILL.md +62 -6
  67. package/skills/dev-loop/SKILL.md +2 -2
  68. package/skills/dev-loop/scripts/log-bash-exit-1.mjs +2 -2
  69. package/skills/dev-loop/scripts/phase-files.mjs +2 -2
  70. package/skills/docs/anti-patterns.md +2 -0
  71. package/skills/docs/copilot-loop-operations.md +3 -3
  72. package/skills/docs/issue-intake-procedure.md +4 -0
  73. package/skills/docs/merge-preconditions.md +65 -1
  74. package/skills/docs/stop-conditions.md +1 -0
  75. package/skills/docs/tracker-first-loop-state.md +6 -6
  76. package/skills/local-implementation/SKILL.md +25 -5
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Shared worktree-path helpers (issue #909). Extracted so create/cleanup agree
3
+ * on how a path is canonicalized for comparison/safety checks.
4
+ */
5
+ import { realpathSync } from "node:fs";
6
+ import path from "node:path";
7
+
8
+ /**
9
+ * Canonicalize for comparison: resolve symlinks on the longest existing prefix
10
+ * (macOS /var → /private/var), keeping any not-yet-created leaf. Lets us match a
11
+ * git-reported worktree path against a target that may not exist yet, and lets a
12
+ * safety prefix-check see through a symlinked namespace dir.
13
+ */
14
+ export function canonicalize(p) {
15
+ let cur = path.resolve(p);
16
+ const tail = [];
17
+ for (;;) {
18
+ try {
19
+ return path.join(realpathSync(cur), ...tail);
20
+ } catch {
21
+ const parent = path.dirname(cur);
22
+ if (parent === cur) return path.resolve(p); // hit root without resolving
23
+ tail.unshift(path.basename(cur));
24
+ cur = parent;
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Namespace-scoped post-merge worktree cleanup (issue #909).
4
+ *
5
+ * Resolves the canonical worktree path via the shared resolver, then runs
6
+ * `git worktree remove --force <path>` + `git worktree prune` from the main
7
+ * checkout (so it never removes the cwd).
8
+ *
9
+ * CLEANUP-SAFETY INVARIANT: refuses to remove any path NOT under
10
+ * `tmp/worktrees/dev-loops/`. A hand-made `tmp/worktrees/my-experiment` can
11
+ * never be force-removed by the loop.
12
+ *
13
+ * FAIL-SOFT: a git error is logged and reported but does NOT fail the process
14
+ * (exit 0 with a reason) so it never breaks a merge-completion flow. Prints a
15
+ * JSON result to stdout.
16
+ */
17
+ import { execFileSync } from "node:child_process";
18
+ import path from "node:path";
19
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
20
+ import { requireTokenValue } from "../_cli-primitives.mjs";
21
+ import { parseArgs } from "node:util";
22
+ import { resolveWorktreePath, WORKTREE_NAMESPACE } from "@dev-loops/core/loop/handoff-envelope";
23
+ import { canonicalize } from "./_worktree-path.mjs";
24
+
25
+ const USAGE = `Usage:
26
+ cleanup-worktree.mjs --repo-root <p> (--issue <n> | --pr <n> | --path <p>)
27
+ Remove a loop-owned worktree after merge: git worktree remove --force + prune.
28
+ Refuses any path not under ${WORKTREE_NAMESPACE}/.
29
+ Required:
30
+ --repo-root <p> Absolute path to the main checkout (git runs here).
31
+ one of:
32
+ --issue <n> Issue number (resolves the canonical path).
33
+ --pr <n> PR number (resolves the canonical path).
34
+ --path <p> Explicit worktree path (must be under the namespace).
35
+ Optional:
36
+ -h, --help Show this help.
37
+ Output (stdout, JSON):
38
+ { "ok": bool, "removed": <path>|null, "reason": "<why>" }
39
+ ok is true on success/skip (incl. fail-soft git errors); false ONLY when the
40
+ path is refused for being outside ${WORKTREE_NAMESPACE}/ (removed: null).`.trim();
41
+
42
+ const parseError = buildParseError(USAGE);
43
+
44
+ function parsePositiveInt(value, flag) {
45
+ const n = Number(value);
46
+ if (!Number.isInteger(n) || n < 1) throw parseError(`${flag} must be a positive integer`);
47
+ return n;
48
+ }
49
+
50
+ export function parseCleanupWorktreeCliArgs(argv) {
51
+ const options = { help: false, repoRoot: undefined, issue: undefined, pr: undefined, path: undefined };
52
+ const { tokens } = parseArgs({
53
+ args: [...argv],
54
+ options: {
55
+ help: { type: "boolean", short: "h" },
56
+ "repo-root": { type: "string" },
57
+ issue: { type: "string" },
58
+ pr: { type: "string" },
59
+ path: { type: "string" },
60
+ },
61
+ allowPositionals: true,
62
+ strict: false,
63
+ tokens: true,
64
+ });
65
+ for (const token of tokens) {
66
+ if (token.kind === "positional") throw parseError(`Unknown argument: ${token.value}`);
67
+ if (token.kind !== "option") continue;
68
+ if (token.name === "help") {
69
+ options.help = true;
70
+ return options;
71
+ }
72
+ if (token.name === "repo-root") {
73
+ options.repoRoot = requireTokenValue(token, parseError, { flagPattern: /^-/u });
74
+ continue;
75
+ }
76
+ if (token.name === "issue") {
77
+ options.issue = parsePositiveInt(requireTokenValue(token, parseError, { flagPattern: /^-/u }), "--issue");
78
+ continue;
79
+ }
80
+ if (token.name === "pr") {
81
+ options.pr = parsePositiveInt(requireTokenValue(token, parseError, { flagPattern: /^-/u }), "--pr");
82
+ continue;
83
+ }
84
+ if (token.name === "path") {
85
+ options.path = requireTokenValue(token, parseError, { flagPattern: /^-/u });
86
+ continue;
87
+ }
88
+ throw parseError(`Unknown argument: ${token.rawName}`);
89
+ }
90
+ if (options.help) return options;
91
+ if (!options.repoRoot) throw parseError("Missing required --repo-root");
92
+ const selectors = [options.issue, options.pr, options.path].filter((v) => v !== undefined);
93
+ if (selectors.length === 0) throw parseError("One of --issue, --pr, or --path is required");
94
+ if (selectors.length > 1) throw parseError("Provide exactly one of --issue, --pr, or --path");
95
+ return options;
96
+ }
97
+
98
+ /**
99
+ * True when `target` is at or under `<repoRoot>/tmp/worktrees/dev-loops/`.
100
+ * Canonicalizes (realpath) every side first: a purely lexical prefix check is
101
+ * bypassable when the namespace dir (or a parent) is a symlink pointing outside
102
+ * the repo — `git worktree remove --force` would then delete outside the repo.
103
+ * Two checks close that hole: (1) the resolved namespace must still live inside
104
+ * the resolved repo-root, and (2) the resolved target must live inside the
105
+ * resolved namespace.
106
+ */
107
+ function isUnderNamespace(target, repoRoot) {
108
+ const realRoot = canonicalize(repoRoot);
109
+ const nsRoot = canonicalize(path.join(repoRoot, WORKTREE_NAMESPACE));
110
+ const real = canonicalize(target);
111
+ const within = (child, parent) => child === parent || child.startsWith(parent + path.sep);
112
+ // Namespace must resolve inside the repo (refuses a symlinked-out namespace),
113
+ // and the target must resolve inside that namespace.
114
+ return within(nsRoot, realRoot) && within(real, nsRoot);
115
+ }
116
+
117
+ export function cleanupWorktree({ repoRoot, issue, pr, path: explicitPath }, { gitCommand = "git" } = {}) {
118
+ const root = path.resolve(repoRoot);
119
+
120
+ let target;
121
+ if (explicitPath !== undefined) {
122
+ target = path.resolve(root, explicitPath);
123
+ } else {
124
+ const kind = issue !== undefined ? "issue" : "pr";
125
+ const number = issue !== undefined ? issue : pr;
126
+ target = resolveWorktreePath({ repoRoot: root, kind, number });
127
+ }
128
+
129
+ // Cleanup-safety invariant: only ever remove loop-owned worktrees.
130
+ if (!isUnderNamespace(target, root)) {
131
+ return {
132
+ ok: false,
133
+ removed: null,
134
+ reason: `refused: ${target} is not under ${WORKTREE_NAMESPACE}/`,
135
+ };
136
+ }
137
+
138
+ try {
139
+ execFileSync(gitCommand, ["worktree", "remove", "--force", target], {
140
+ cwd: root,
141
+ encoding: "utf8",
142
+ stdio: ["ignore", "pipe", "pipe"],
143
+ });
144
+ execFileSync(gitCommand, ["worktree", "prune"], {
145
+ cwd: root,
146
+ encoding: "utf8",
147
+ stdio: ["ignore", "pipe", "pipe"],
148
+ });
149
+ } catch (err) {
150
+ // Fail-soft: never break a merge-completion flow on a git error.
151
+ const detail = (err.stderr ?? err.message ?? "").toString().trim();
152
+ return { ok: true, removed: null, reason: `git error (non-fatal): ${detail}` };
153
+ }
154
+
155
+ return { ok: true, removed: target, reason: "removed" };
156
+ }
157
+
158
+ export function runCli(argv = process.argv.slice(2), { stdout = process.stdout } = {}) {
159
+ const options = parseCleanupWorktreeCliArgs(argv);
160
+ if (options.help) {
161
+ stdout.write(`${USAGE}\n`);
162
+ return;
163
+ }
164
+ const result = cleanupWorktree(options);
165
+ stdout.write(`${JSON.stringify(result)}\n`);
166
+ }
167
+
168
+ if (isDirectCliRun(import.meta.url)) {
169
+ try {
170
+ runCli();
171
+ } catch (error) {
172
+ process.stderr.write(`${formatCliError(error)}\n`);
173
+ process.exitCode = 1;
174
+ }
175
+ }
@@ -333,7 +333,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
333
333
  let interpretation = interpretLoopState(snapshot, refinementConfig);
334
334
 
335
335
  // Check for human comments since last subagent action
336
- // Only active in async subagent context (DEVLOOPS_RUN_ID, or the PI_SUBAGENT_RUN_ID alias, set)
336
+ // Only active in async subagent context (DEVLOOPS_RUN_ID set)
337
337
  let humanCommentCheck = { paused: false };
338
338
  if (resolveRunId(env)) {
339
339
  humanCommentCheck = await detectRecentHumanComments(
@@ -1,12 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync } from "node:child_process";
3
3
  import process from "node:process";
4
- function parseArgs() {
5
- const args = process.argv.slice(2);
6
- const opts = { base: null, head: null };
7
- for (let i = 0; i < args.length; i++) {
8
- if (args[i] === "--help" || args[i] === "-h") {
9
- process.stdout.write(`Usage: detect-change-scope.mjs [--base <ref>] [--head <ref>]
4
+ import { parseArgs } from "node:util";
5
+
6
+ const USAGE = `Usage: detect-change-scope.mjs [--base <ref>] [--head <ref>]
10
7
  Detect change scope from git diff for light-mode eligibility.
11
8
  Options:
12
9
  --base <ref> Override base ref (default: HEAD~1)
@@ -15,11 +12,39 @@ Options:
15
12
  Exit codes:
16
13
  0 Success
17
14
  1 Error
18
- `);
19
- process.exit(0);
15
+ `;
16
+
17
+ function parseCliArgs(argv) {
18
+ const { tokens } = parseArgs({
19
+ args: [...argv],
20
+ options: {
21
+ base: { type: "string" },
22
+ head: { type: "string" },
23
+ help: { type: "boolean", short: "h" },
24
+ },
25
+ allowPositionals: true,
26
+ strict: false,
27
+ tokens: true,
28
+ });
29
+
30
+ const opts = { base: null, head: null };
31
+ for (const token of tokens) {
32
+ if (token.kind === "option") {
33
+ if (token.name === "help") {
34
+ if (token.value !== undefined) {
35
+ throw new Error(`unknown argument: ${token.rawName}=${token.value}`);
36
+ }
37
+ process.stdout.write(USAGE);
38
+ process.exit(0);
39
+ }
40
+ if (token.name === "base") {
41
+ opts.base = token.value ?? null;
42
+ continue;
43
+ }
44
+ if (token.name === "head") {
45
+ opts.head = token.value ?? null;
46
+ }
20
47
  }
21
- if (args[i] === "--base" && i + 1 < args.length) opts.base = args[++i];
22
- else if (args[i] === "--head" && i + 1 < args.length) opts.head = args[++i];
23
48
  }
24
49
  return opts;
25
50
  }
@@ -64,7 +89,7 @@ function isEligibleForLightMode(scope, threshold) {
64
89
  return scope.filesChanged <= threshold.maxFiles && scope.linesChanged <= threshold.maxLines;
65
90
  }
66
91
  async function main() {
67
- const opts = parseArgs();
92
+ const opts = parseCliArgs(process.argv.slice(2));
68
93
  const scope = detectScope(opts);
69
94
  let threshold = { maxFiles: 3, maxLines: 200 };
70
95
  let eligible = false;
@@ -6,13 +6,13 @@ import {
6
6
  formatCliError,
7
7
  isCopilotLogin,
8
8
  isDirectCliRun,
9
- normalizeTimestamp,
10
9
  parseJsonText,
11
10
  parseReviewThreads,
11
+ resolveDraftGateRoundResetMs,
12
12
  summarizeCopilotReviews,
13
13
  } from "../_core-helpers.mjs";
14
14
  import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
15
- import { loadDevLoopConfig, resolveGateConfig, resolveRefinementConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
15
+ import { loadDevLoopConfig, resolveGateConfig, resolveRefinement, resolveRefinementConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
16
16
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
17
17
  import { buildSnapshotFromPrFacts, interpretLoopState, summarizeLoopInterpretation } from "@dev-loops/core/loop/copilot-loop-state";
18
18
  import { evaluatePrGateCoordination, PR_CHECKPOINT, PR_CHECKPOINT_ACTION } from "@dev-loops/core/loop/pr-gate-coordination";
@@ -303,18 +303,13 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
303
303
  const gateEvidence = await detectCheckpointEvidence(options, runtime);
304
304
  // When draft gate was re-passed on a different head, use its timestamp
305
305
  // to reset the Copilot round count — only reviews after the re-pass count.
306
- // Use prefix matching for the head SHA comparison so shortened SHAs (7+)
307
- // from gate comments match the full headRefOid.
308
- const draftGateHeadSha = gateEvidence.draftGate?.headSha;
309
- const draftGateOnCurrentHead = typeof draftGateHeadSha === "string"
310
- && typeof currentHeadSha === "string"
311
- && currentHeadSha.startsWith(draftGateHeadSha);
312
- const draftGateResetAtMs = gateEvidence.draftGate?.verdict === "clean"
313
- && typeof draftGateHeadSha === "string"
314
- && !draftGateOnCurrentHead
315
- && typeof gateEvidence.draftGate?.updatedAt === "string"
316
- ? normalizeTimestamp(gateEvidence.draftGate.updatedAt)
317
- : null;
306
+ // Shared with request-copilot-review so both scripts compute the same
307
+ // completed round count / cap (#896). Prefix matching for the head SHA lets
308
+ // shortened SHAs (7+) from gate comments match the full headRefOid.
309
+ const draftGateResetAtMs = resolveDraftGateRoundResetMs({
310
+ draftGate: gateEvidence.draftGate,
311
+ currentHeadSha,
312
+ });
318
313
  const reviewSummary = summarizeCopilotReviews(prData?.reviews, { headSha: currentHeadSha, draftGateResetAtMs });
319
314
  const reviewRequestStatus = requestedReviewers.requested
320
315
  ? "requested"
@@ -338,8 +333,19 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
338
333
  if (gateEvidence.currentHeadSha !== currentHeadSha) {
339
334
  throw new Error(`PR head changed while loading gate coordination facts for ${options.repo}#${options.pr}; refuse to evaluate mixed-head gate state.`);
340
335
  }
341
- const interpretation = interpretLoopState(snapshot);
342
- const disposition = summarizeLoopInterpretation(interpretation);
336
+ // Resolve the refinement config (round cap, low-signal heuristic) and feed it to
337
+ // the interpreter. Without it, the interpreter cannot see maxCopilotRounds and so
338
+ // never resolves ROUND_CAP_CLEAN_FALLBACK — a post-cap clean head would fall to
339
+ // READY_TO_REREQUEST_REVIEW, dead-ending the loop at the round cap (#896). This
340
+ // keeps the gate-coordination interpretation consistent with the standalone
341
+ // detect-copilot-loop-state path and with request-copilot-review's cap logic.
342
+ const interpreterRepoRoot = runtime.repoRoot ?? process.cwd();
343
+ const interpreterConfigResult = await loadDevLoopConfig({ repoRoot: interpreterRepoRoot });
344
+ const interpreterRefinementConfig = (Array.isArray(interpreterConfigResult.errors) && interpreterConfigResult.errors.length > 0)
345
+ ? resolveRefinement({ version: 1 })
346
+ : resolveRefinement(interpreterConfigResult.config ?? { version: 1 });
347
+ const interpretation = interpretLoopState(snapshot, interpreterRefinementConfig);
348
+ const disposition = summarizeLoopInterpretation(interpretation, interpreterRefinementConfig);
343
349
  const mergeStateStatus = typeof prData?.mergeStateStatus === "string" && prData.mergeStateStatus.trim().length > 0
344
350
  ? prData.mergeStateStatus.trim().toUpperCase()
345
351
  : null;
@@ -362,6 +368,7 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
362
368
  interpretation,
363
369
  disposition,
364
370
  refinementArtifact,
371
+ refinementConfig: interpreterRefinementConfig,
365
372
  };
366
373
  }
367
374
 
@@ -429,9 +436,13 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
429
436
  && typeof (context.snapshot?.copilotReviewRoundCount) === "number"
430
437
  && context.snapshot?.copilotReviewRoundCount >= maxCopilotRounds;
431
438
  const sameHeadCleanConverged = context.interpretation?.sameHeadCleanConverged ?? false;
439
+ // Round-cap clean fallback (#896): the interpreter resolved a clean post-cap head
440
+ // (zero unresolved threads + green CI) that Copilot will not re-review. The formal
441
+ // request guard must not fire here — pre_approval_gate reviews the post-cap head.
442
+ const roundCapCleanFallback = context.interpretation?.roundCapCleanEligible ?? false;
432
443
  const copilotReviewEverFormallyRequested = copilotReviewRequestStatus === "none"
433
444
  && guardBoundaries.has(result.gateBoundary)
434
- && !(roundCapReached && sameHeadCleanConverged)
445
+ && !(roundCapReached && (sameHeadCleanConverged || roundCapCleanFallback))
435
446
  ? await fetchCopilotEverFormallyRequested(
436
447
  { repo: context.repo, pr: context.pr },
437
448
  runtime,
@@ -442,7 +453,8 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
442
453
  copilotReviewRoundCount: context.snapshot?.copilotReviewRoundCount ?? 0,
443
454
  copilotReviewEverFormallyRequested,
444
455
  maxCopilotRounds,
445
- sameHeadCleanConverged: context.interpretation?.sameHeadCleanConverged ?? false,
456
+ sameHeadCleanConverged,
457
+ roundCapCleanFallback,
446
458
  gateBoundary: result.gateBoundary,
447
459
  })) {
448
460
  result.gateBoundary = PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW;
@@ -19,11 +19,10 @@ Required:
19
19
  Optional:
20
20
  --stale-runner-max-age-ms <ms>
21
21
  Override the staleness threshold (default 30 minutes,
22
- or $PI_DEV_LOOP_STALE_RUNNER_MAX_AGE_MS).
22
+ or $DEVLOOPS_STALE_RUNNER_MAX_AGE_MS).
23
23
  --run-id <id> Override the active run id (default: read from
24
- DEVLOOPS_RUN_ID, falling back to the PI_SUBAGENT_RUN_ID
25
- alias). When supplied, the detector additionally verifies
26
- the current run id is still the active owner.
24
+ DEVLOOPS_RUN_ID). When supplied, the detector additionally
25
+ verifies the current run id is still the active owner.
27
26
  Output (stdout, JSON; always includes staleRunnerCheck):
28
27
  {
29
28
  "ok": true,
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import process from "node:process";
3
3
  import { execFileSync } from "node:child_process";
4
+ import { parseArgs } from "node:util";
4
5
  import { interpretTrackerLoopState } from "@dev-loops/core/loop/tracker-first-loop-state";
6
+
5
7
  function showHelp() {
6
8
  process.stdout.write(`Usage: detect-tracker-first-loop-state.mjs --repo <owner/name> --issue <number>
7
9
  Detect tracker-first loop state for a GitHub issue.
@@ -15,40 +17,65 @@ Exit codes:
15
17
  `);
16
18
  process.exit(0);
17
19
  }
18
- function parseArgs() {
19
- const args = process.argv.slice(2);
20
+
21
+ function parseCliArgs(argv) {
22
+ const { tokens } = parseArgs({
23
+ args: [...argv],
24
+ options: {
25
+ repo: { type: "string" },
26
+ issue: { type: "string" },
27
+ help: { type: "boolean", short: "h" },
28
+ },
29
+ allowPositionals: true,
30
+ strict: false,
31
+ tokens: true,
32
+ });
33
+
20
34
  const opts = { repo: null, issue: null };
21
- for (let i = 0; i < args.length; i++) {
22
- if (args[i] === "--help" || args[i] === "-h") {
23
- showHelp();
35
+ for (const token of tokens) {
36
+ if (token.kind === "option") {
37
+ if (token.name === "help") {
38
+ if (token.value !== undefined) {
39
+ throw new Error(`unknown argument: ${token.rawName}=${token.value}`);
40
+ }
41
+ showHelp();
42
+ }
43
+ if (token.name === "repo") {
44
+ opts.repo = token.value ?? null;
45
+ continue;
46
+ }
47
+ if (token.name === "issue") {
48
+ opts.issue = token.value ?? null;
49
+ }
24
50
  }
25
- if (args[i] === "--repo" && i + 1 < args.length) opts.repo = args[++i];
26
- else if (args[i] === "--issue" && i + 1 < args.length) opts.issue = args[++i];
27
51
  }
28
52
  return opts;
29
53
  }
54
+
30
55
  async function main() {
31
- const opts = parseArgs();
32
- if (!opts.repo || !opts.issue) {
56
+ const rawOpts = parseCliArgs(process.argv.slice(2));
57
+ if (!rawOpts.repo || !rawOpts.issue) {
33
58
  process.stderr.write(
34
59
  JSON.stringify({ ok: false, error: "--repo and --issue required" }) + "\n"
35
60
  );
36
61
  process.exitCode = 1;
37
62
  return;
38
63
  }
64
+ const repo = rawOpts.repo;
65
+ const issue = rawOpts.issue;
39
66
  let rawState = "";
40
67
  let prContext = null;
41
68
  try {
42
69
  const issueJson = execFileSync(
43
70
  "gh",
44
- ["issue", "view", String(opts.issue), "--repo", opts.repo, "--json", "state,title", "--jq", ".state"],
71
+ ["issue", "view", String(issue), "--repo", repo, "--json", "state,title", "--jq", ".state"],
45
72
  { encoding: "utf8" }
46
73
  ).trim();
47
74
  rawState = issueJson;
48
75
  try {
49
76
  const prJson = execFileSync(
50
77
  "gh",
51
- ["pr", "list", "--repo", opts.repo, "--search", `${opts.issue} in:body`, "--state", "open", "--json", "number,state,headRefName", "--jq", ".[0]"],
78
+ ["pr", "list", "--repo", repo, "--search", `${issue} in:body`, "--state", "open", "--json", "number,state,headRefName", "--jq", ".[0]"],
52
79
  { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }
53
80
  ).trim();
54
81
  if (prJson) prContext = JSON.parse(prJson);