dev-loops 0.3.0 → 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 (53) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +1 -1
  3. package/.claude/hooks/_run-context.mjs +9 -16
  4. package/.claude/skills/copilot-pr-followup/SKILL.md +9 -7
  5. package/.claude/skills/dev-loop/SKILL.md +6 -6
  6. package/.claude/skills/docs/anti-patterns.md +1 -1
  7. package/.claude/skills/docs/copilot-loop-operations.md +1 -1
  8. package/.claude/skills/docs/issue-intake-procedure.md +4 -0
  9. package/.claude/skills/docs/merge-preconditions.md +65 -1
  10. package/.claude/skills/docs/stop-conditions.md +1 -0
  11. package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
  12. package/.claude/skills/local-implementation/SKILL.md +8 -2
  13. package/CHANGELOG.md +26 -0
  14. package/README.md +8 -2
  15. package/cli/index.mjs +21 -2
  16. package/extension/README.md +1 -1
  17. package/package.json +3 -3
  18. package/scripts/README.md +2 -2
  19. package/scripts/github/offer-human-handoff.mjs +147 -0
  20. package/scripts/github/probe-ci-status.mjs +468 -0
  21. package/scripts/github/request-copilot-review.mjs +3 -3
  22. package/scripts/github/resolve-handoff-candidates.mjs +412 -0
  23. package/scripts/github/upsert-checkpoint-verdict.mjs +2 -2
  24. package/scripts/loop/_stale-runner-detection.mjs +1 -1
  25. package/scripts/loop/_worktree-path.mjs +27 -0
  26. package/scripts/loop/cleanup-worktree.mjs +175 -0
  27. package/scripts/loop/copilot-pr-handoff.mjs +1 -1
  28. package/scripts/loop/detect-stale-runner.mjs +3 -4
  29. package/scripts/loop/ensure-worktree.mjs +219 -0
  30. package/scripts/loop/outer-loop.mjs +1 -1
  31. package/scripts/loop/pr-runner-coordination.mjs +1 -1
  32. package/scripts/loop/pre-flight-gate.mjs +10 -7
  33. package/scripts/loop/pre-push-main-guard.mjs +4 -4
  34. package/scripts/loop/provision-worktree.mjs +243 -0
  35. package/scripts/loop/resolve-dev-loop-startup.mjs +5 -5
  36. package/scripts/loop/run-queue.mjs +25 -1
  37. package/scripts/loop/run-watch-cycle.mjs +75 -22
  38. package/scripts/projects/add-queue-item.mjs +20 -5
  39. package/scripts/projects/archive-done-items.mjs +2 -1
  40. package/scripts/projects/ensure-queue-board.mjs +2 -1
  41. package/scripts/projects/list-queue-items.mjs +2 -1
  42. package/scripts/projects/move-queue-item.mjs +2 -1
  43. package/scripts/projects/reorder-queue-item.mjs +5 -4
  44. package/scripts/projects/sync-item-status.mjs +2 -1
  45. package/skills/copilot-pr-followup/SKILL.md +9 -7
  46. package/skills/dev-loop/SKILL.md +2 -2
  47. package/skills/docs/anti-patterns.md +1 -1
  48. package/skills/docs/copilot-loop-operations.md +1 -1
  49. package/skills/docs/issue-intake-procedure.md +4 -0
  50. package/skills/docs/merge-preconditions.md +65 -1
  51. package/skills/docs/stop-conditions.md +1 -0
  52. package/skills/docs/tracker-first-loop-state.md +6 -6
  53. package/skills/local-implementation/SKILL.md +8 -2
@@ -0,0 +1,412 @@
1
+ #!/usr/bin/env node
2
+ import { readFile as fsReadFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
6
+ import { parsePrNumber, runChild } from "../_cli-primitives.mjs";
7
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
8
+ import {
9
+ loadDevLoopConfig,
10
+ resolveHumanHandoffConfig,
11
+ } from "@dev-loops/core/config";
12
+
13
+ const USAGE = `Usage: resolve-handoff-candidates.mjs --repo <owner/name> --pr <number> [--changed-files <a,b,c>] [--pr-author <login>]
14
+ Resolve an ordered, deduped list of human-handoff reviewer/assignee candidates
15
+ for a PR, from the sources configured under \`approval.humanHandoff\` (#920).
16
+ This is the "offer" side: it surfaces candidates only — it never assigns anyone.
17
+
18
+ Sources (in priority order):
19
+ assignees Static configured list (approval.humanHandoff.assignees).
20
+ codeowners .github/CODEOWNERS / CODEOWNERS / docs/CODEOWNERS matched
21
+ against the PR's changed paths (last-match-wins per
22
+ CODEOWNERS semantics). Team handles (@org/team) are
23
+ included, flagged with isTeam:true.
24
+ recent-committers git log authors on the PR's changed paths (recent commits),
25
+ by GitHub login where derivable from the commit email,
26
+ excluding the PR author and bots.
27
+
28
+ Which sources run is controlled by approval.humanHandoff.candidatesFrom.
29
+ Disabled (default) => no-op: ok:true with an empty candidate list.
30
+
31
+ Required:
32
+ --repo <owner/name>
33
+ --pr <number>
34
+ Optional:
35
+ --changed-files <csv> Comma-separated changed paths. When omitted, derived
36
+ from \`gh pr view --json files\`.
37
+ --pr-author <login> PR author login (excluded from recent-committers).
38
+ When omitted, derived from \`gh pr view --json author\`.
39
+ Output (stdout, JSON):
40
+ { "ok": true, "enabled": bool, "candidates": [{ "login", "source", "isTeam"?, "paths"? }],
41
+ "changedFiles": [...], "sources": [...], "warnings": [...] }
42
+ Exit codes:
43
+ 0 Success (including disabled no-op and fail-soft per-source skips)
44
+ 1 Argument error`.trim();
45
+
46
+ const parseError = buildParseError(USAGE);
47
+
48
+ function nextValue(args, i, flag) {
49
+ const value = args[i];
50
+ if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
51
+ throw parseError(`Missing value for ${flag}`);
52
+ }
53
+ return value;
54
+ }
55
+
56
+ const RECENT_COMMITTERS_LIMIT = 50;
57
+ const CODEOWNERS_PATHS = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"];
58
+ // Bot author handles to exclude from recent-committers.
59
+ const BOT_PATTERN = /\[bot\]$|^(?:dependabot|github-actions|copilot|renovate)$/i;
60
+
61
+ export function parseResolveCandidatesCliArgs(argv) {
62
+ const args = [...argv];
63
+ if (args.includes("--help") || args.includes("-h")) {
64
+ return { help: true };
65
+ }
66
+ const options = { help: false, repo: undefined, pr: undefined, changedFiles: undefined, prAuthor: undefined };
67
+ for (let i = 0; i < args.length; i += 1) {
68
+ const token = args[i];
69
+ if (token === "--repo") { options.repo = nextValue(args, ++i, "--repo"); continue; }
70
+ if (token === "--pr") { options.pr = parsePrNumber(nextValue(args, ++i, "--pr"), parseError); continue; }
71
+ if (token === "--changed-files") {
72
+ options.changedFiles = nextValue(args, ++i, "--changed-files")
73
+ .split(",").map((s) => s.trim()).filter((s) => s.length > 0);
74
+ continue;
75
+ }
76
+ if (token === "--pr-author") { options.prAuthor = nextValue(args, ++i, "--pr-author").trim().replace(/^@/, ""); continue; }
77
+ throw parseError(`Unknown argument: ${token}`);
78
+ }
79
+ if (options.repo === undefined || options.pr === undefined) {
80
+ throw parseError("Resolving handoff candidates requires both --repo <owner/name> and --pr <number>");
81
+ }
82
+ try {
83
+ parseRepoSlug(options.repo);
84
+ } catch (error) {
85
+ throw parseError(error instanceof Error ? error.message : String(error));
86
+ }
87
+ return options;
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // CODEOWNERS — parse + match
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /**
95
+ * Parse CODEOWNERS content into ordered { pattern, owners } rules.
96
+ * Comments (#...) and blank lines are skipped. Owners keep their leading `@`.
97
+ * @param {string} content
98
+ * @returns {{ pattern: string, owners: string[] }[]}
99
+ */
100
+ export function parseCodeowners(content) {
101
+ const rules = [];
102
+ for (const rawLine of String(content).split("\n")) {
103
+ // A `#` starts a comment only at line start (after leading whitespace) or
104
+ // when preceded by whitespace; a mid-token `#` is part of the pattern/owner
105
+ // (gitignore/CODEOWNERS semantics).
106
+ const line = rawLine.replace(/(^|\s)#.*$/, "").trim();
107
+ if (line === "") continue;
108
+ const [pattern, ...owners] = line.split(/\s+/);
109
+ if (!pattern) continue;
110
+ rules.push({ pattern, owners: owners.filter((o) => o.length > 0) });
111
+ }
112
+ return rules;
113
+ }
114
+
115
+ /**
116
+ * Match a CODEOWNERS pattern against a repo-relative path (gitignore-ish).
117
+ * Leading `/` anchors to repo root; trailing `/` matches a directory subtree;
118
+ * `*` matches within a path segment, `**` across segments; a bare pattern with
119
+ * no slash matches the path's basename or any segment.
120
+ * @param {string} pattern
121
+ * @param {string} filePath
122
+ * @returns {boolean}
123
+ */
124
+ export function codeownersMatch(pattern, filePath) {
125
+ const file = filePath.replace(/^\.?\//, "");
126
+ let pat = pattern;
127
+ const dirOnly = pat.endsWith("/");
128
+ if (dirOnly) pat = pat.slice(0, -1);
129
+ // Bare name (no slash, not anchored): match any path segment subtree.
130
+ const anchored = pat.startsWith("/");
131
+ if (anchored) pat = pat.slice(1);
132
+
133
+ const toRegex = (p) => {
134
+ let re = "";
135
+ for (let i = 0; i < p.length; i += 1) {
136
+ const c = p[i];
137
+ if (c === "*") {
138
+ if (p[i + 1] === "*") { re += ".*"; i += 1; }
139
+ else re += "[^/]*";
140
+ } else if ("\\^$.|?+()[]{}".includes(c)) {
141
+ re += `\\${c}`;
142
+ } else {
143
+ re += c;
144
+ }
145
+ }
146
+ return re;
147
+ };
148
+
149
+ // Bare-vs-anchored is decided on the NORMALIZED pattern: a trailing-only `/`
150
+ // (e.g. `build/`) normalizes to a bare token with no leading/internal slash,
151
+ // so it matches at any depth like gitignore. Only a leading or internal slash
152
+ // anchors the match.
153
+ if (!anchored && !pat.includes("/")) {
154
+ // Bare token: match a full segment anywhere, plus its subtree.
155
+ const seg = toRegex(pat);
156
+ return new RegExp(`(?:^|/)${seg}(?:/|$)`).test(file);
157
+ }
158
+
159
+ const body = toRegex(pat);
160
+ // Anchored (or contains slash): match from root, allow subtree under it.
161
+ const re = new RegExp(`^${body}(?:/|$)`);
162
+ if (re.test(file)) return true;
163
+ // Directory pattern also matches the directory's whole subtree.
164
+ if (dirOnly) return new RegExp(`^${body}/`).test(file);
165
+ return false;
166
+ }
167
+
168
+ /**
169
+ * Resolve CODEOWNERS owners for a set of changed paths. Last-match-wins per
170
+ * CODEOWNERS semantics: the final matching rule for each path wins.
171
+ * @param {{ pattern: string, owners: string[] }[]} rules
172
+ * @param {string[]} changedFiles
173
+ * @returns {Map<string, Set<string>>} owner login (no `@`) -> set of paths
174
+ */
175
+ export function ownersForPaths(rules, changedFiles) {
176
+ /** @type {Map<string, Set<string>>} */
177
+ const byOwner = new Map();
178
+ for (const file of changedFiles) {
179
+ let winner = null;
180
+ for (const rule of rules) {
181
+ if (codeownersMatch(rule.pattern, file)) winner = rule;
182
+ }
183
+ if (!winner || winner.owners.length === 0) continue;
184
+ for (const owner of winner.owners) {
185
+ const login = owner.replace(/^@/, "");
186
+ if (login === "") continue;
187
+ if (!byOwner.has(login)) byOwner.set(login, new Set());
188
+ byOwner.get(login).add(file);
189
+ }
190
+ }
191
+ return byOwner;
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Source resolvers (each fail-soft: returns candidates + a warning on error)
196
+ // ---------------------------------------------------------------------------
197
+
198
+ async function resolveCodeownersSource(changedFiles, { repoRoot, readFile }) {
199
+ const warnings = [];
200
+ for (const rel of CODEOWNERS_PATHS) {
201
+ let content;
202
+ try {
203
+ content = await readFile(path.join(repoRoot, rel), "utf8");
204
+ } catch (error) {
205
+ // ENOENT => genuinely absent at this location: try the next path quietly.
206
+ // Any other error (EACCES / IO) is real: surface it rather than silently
207
+ // claiming "no CODEOWNERS file found".
208
+ if (error && error.code === "ENOENT") continue;
209
+ warnings.push(`codeowners: failed to read ${rel}: ${error instanceof Error ? error.message : String(error)}`);
210
+ continue;
211
+ }
212
+ const rules = parseCodeowners(content);
213
+ const byOwner = ownersForPaths(rules, changedFiles);
214
+ return {
215
+ candidates: [...byOwner.entries()].map(([login, paths]) => ({
216
+ login,
217
+ source: "codeowners",
218
+ isTeam: login.includes("/"),
219
+ paths: [...paths],
220
+ })),
221
+ warnings,
222
+ };
223
+ }
224
+ // Fail-soft: no CODEOWNERS file anywhere -> no candidates, no abort. Keep any
225
+ // non-ENOENT read warnings so a permissions/IO failure is not masked.
226
+ return { candidates: [], warnings: [...warnings, "codeowners: no CODEOWNERS file found"] };
227
+ }
228
+
229
+ /**
230
+ * Map a git author email to a GitHub login. `<login>@users.noreply.github.com`
231
+ * (optionally `<id>+<login>@...`) carries the login directly; otherwise fall
232
+ * back to the local-part of the email as a best-effort handle.
233
+ */
234
+ export function loginFromCommitEmail(email) {
235
+ const e = String(email).trim().toLowerCase();
236
+ const noreply = e.match(/^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$/);
237
+ if (noreply) return noreply[1];
238
+ const at = e.indexOf("@");
239
+ return at > 0 ? e.slice(0, at) : null;
240
+ }
241
+
242
+ async function resolveRecentCommittersSource(changedFiles, { repoRoot, prAuthor, run }) {
243
+ if (changedFiles.length === 0) {
244
+ return { candidates: [], warnings: [] };
245
+ }
246
+ let stdout;
247
+ try {
248
+ const result = await run("git", [
249
+ "-C", repoRoot,
250
+ "log", `-n${RECENT_COMMITTERS_LIMIT}`, "--no-merges", "--pretty=format:%ae",
251
+ "--", ...changedFiles,
252
+ ]);
253
+ if (result.code !== 0) {
254
+ return { candidates: [], warnings: [`recent-committers: git log exited ${result.code}`] };
255
+ }
256
+ stdout = result.stdout;
257
+ } catch (error) {
258
+ return { candidates: [], warnings: [`recent-committers: ${error instanceof Error ? error.message : String(error)}`] };
259
+ }
260
+ const author = prAuthor ? prAuthor.toLowerCase() : null;
261
+ const seen = new Set();
262
+ const candidates = [];
263
+ for (const line of stdout.split("\n")) {
264
+ const email = line.trim();
265
+ if (email === "") continue;
266
+ const login = loginFromCommitEmail(email);
267
+ if (!login) continue;
268
+ const key = login.toLowerCase();
269
+ if (seen.has(key)) continue;
270
+ if (BOT_PATTERN.test(login)) continue;
271
+ if (author && key === author) continue;
272
+ seen.add(key);
273
+ candidates.push({ login, source: "recent-committers" });
274
+ }
275
+ return { candidates, warnings: [] };
276
+ }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // gh lookups for changed files / author (only when not supplied)
280
+ // ---------------------------------------------------------------------------
281
+
282
+ async function fetchPrFacts(repo, pr, { run, ghCommand }) {
283
+ const result = await run(ghCommand, [
284
+ "pr", "view", String(pr), "--repo", repo, "--json", "files,author",
285
+ ]);
286
+ if (result.code !== 0) {
287
+ throw new Error(result.stderr.trim() || `gh pr view exited ${result.code}`);
288
+ }
289
+ const payload = JSON.parse(result.stdout);
290
+ const files = Array.isArray(payload?.files)
291
+ ? payload.files.map((f) => (typeof f?.path === "string" ? f.path : "")).filter((p) => p.length > 0)
292
+ : [];
293
+ const author = typeof payload?.author?.login === "string" ? payload.author.login : null;
294
+ return { files, author };
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // Orchestration
299
+ // ---------------------------------------------------------------------------
300
+
301
+ /**
302
+ * Resolve handoff candidates. Pure-ish: all IO is injected.
303
+ * @param {{ repo: string, pr: number, changedFiles?: string[], prAuthor?: string|null }} input
304
+ * @param {object} deps
305
+ * @returns {Promise<object>}
306
+ */
307
+ export async function resolveHandoffCandidates(input, deps = {}) {
308
+ const {
309
+ config,
310
+ repoRoot = process.cwd(),
311
+ ghCommand = "gh",
312
+ run = (cmd, args) => runChild(cmd, args),
313
+ readFile = fsReadFile,
314
+ } = deps;
315
+
316
+ const handoff = resolveHumanHandoffConfig(config);
317
+ const warnings = [];
318
+
319
+ if (!handoff.enabled) {
320
+ return {
321
+ ok: true,
322
+ enabled: false,
323
+ candidates: [],
324
+ changedFiles: input.changedFiles ?? [],
325
+ sources: [],
326
+ warnings: [],
327
+ };
328
+ }
329
+
330
+ let changedFiles = input.changedFiles;
331
+ let prAuthor = input.prAuthor ?? null;
332
+ if (changedFiles === undefined || prAuthor === null) {
333
+ try {
334
+ const facts = await fetchPrFacts(input.repo, input.pr, { run, ghCommand });
335
+ if (changedFiles === undefined) changedFiles = facts.files;
336
+ if (prAuthor === null) prAuthor = facts.author;
337
+ } catch (error) {
338
+ warnings.push(`pr-facts: ${error instanceof Error ? error.message : String(error)}`);
339
+ if (changedFiles === undefined) changedFiles = [];
340
+ }
341
+ }
342
+
343
+ const ordered = [];
344
+ // 1. Static assignees — highest priority.
345
+ for (const login of handoff.assignees) {
346
+ ordered.push({ login: login.replace(/^@/, ""), source: "assignees", isTeam: login.includes("/") });
347
+ }
348
+
349
+ // 2/3. Other sources, in the CANONICAL priority order
350
+ // (assignees > codeowners > recent-committers), regardless of the order they
351
+ // appear in candidatesFrom. candidatesFrom only selects WHICH sources run,
352
+ // not their relative rank.
353
+ const enabledSources = new Set(handoff.candidatesFrom);
354
+ if (enabledSources.has("codeowners")) {
355
+ const result = await resolveCodeownersSource(changedFiles, { repoRoot, readFile });
356
+ ordered.push(...result.candidates);
357
+ warnings.push(...result.warnings);
358
+ }
359
+ if (enabledSources.has("recent-committers")) {
360
+ const result = await resolveRecentCommittersSource(changedFiles, { repoRoot, prAuthor, run });
361
+ ordered.push(...result.candidates);
362
+ warnings.push(...result.warnings);
363
+ }
364
+
365
+ // Dedup by login (case-insensitive), first occurrence wins (preserves the
366
+ // assignees > codeowners > recent-committers priority).
367
+ const seen = new Set();
368
+ const candidates = [];
369
+ for (const c of ordered) {
370
+ const key = c.login.toLowerCase();
371
+ if (seen.has(key)) continue;
372
+ seen.add(key);
373
+ candidates.push(c);
374
+ }
375
+
376
+ return {
377
+ ok: true,
378
+ enabled: true,
379
+ candidates,
380
+ changedFiles,
381
+ sources: handoff.candidatesFrom,
382
+ warnings,
383
+ };
384
+ }
385
+
386
+ export async function main(argv = process.argv.slice(2), deps = {}) {
387
+ let options;
388
+ try {
389
+ options = parseResolveCandidatesCliArgs(argv);
390
+ } catch (error) {
391
+ process.stderr.write(`${formatCliError(error)}\n`);
392
+ return 1;
393
+ }
394
+ if (options.help) {
395
+ process.stdout.write(`${USAGE}\n`);
396
+ return 0;
397
+ }
398
+ const repoRoot = deps.repoRoot ?? process.cwd();
399
+ const { config } = deps.config !== undefined
400
+ ? { config: deps.config }
401
+ : await loadDevLoopConfig({ repoRoot });
402
+ const result = await resolveHandoffCandidates(
403
+ { repo: options.repo, pr: options.pr, changedFiles: options.changedFiles, prAuthor: options.prAuthor ?? null },
404
+ { ...deps, config, repoRoot },
405
+ );
406
+ process.stdout.write(`${JSON.stringify(result)}\n`);
407
+ return 0;
408
+ }
409
+
410
+ if (isDirectCliRun(import.meta.url)) {
411
+ main().then((code) => { process.exitCode = code; });
412
+ }
@@ -1194,7 +1194,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
1194
1194
  if (existing) {
1195
1195
  const updated = await updateComment({ repo: options.repo, commentId: existing.commentId, body: desiredBody }, { env, ghCommand });
1196
1196
  // Post-update verification: verify the updated comment is visible via direct API fetch by comment ID.
1197
- // A run id is set (production context) — DEVLOOPS_RUN_ID, or the PI_SUBAGENT_RUN_ID alias.
1197
+ // A run id is set (production context) — DEVLOOPS_RUN_ID.
1198
1198
  let updateVerificationWarning = null;
1199
1199
  if (envRunId) {
1200
1200
  let verified = await verifyComment({ repo: options.repo, commentId: updated.commentId }, { env, ghCommand });
@@ -1229,7 +1229,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
1229
1229
  // comment is not yet returned by paginated list endpoints. A direct fetch
1230
1230
  // by comment ID confirms the comment is persisted, preventing the evidence
1231
1231
  // checker from falsely reporting "missing" and triggering a duplicate post.
1232
- // Only active when a run id is set (production context) — DEVLOOPS_RUN_ID or the PI_SUBAGENT_RUN_ID alias.
1232
+ // Only active when a run id is set (production context) — DEVLOOPS_RUN_ID.
1233
1233
  let verified = true;
1234
1234
  let verificationWarning = null;
1235
1235
  if (envRunId) {
@@ -18,7 +18,7 @@ export function resolveStaleRunnerMaxAgeMs(options = {}, env = process.env) {
18
18
  if (Number.isFinite(explicit) && explicit > 0) {
19
19
  return Math.floor(explicit);
20
20
  }
21
- const fromEnv = parsePositiveIntegerMs(env?.PI_DEV_LOOP_STALE_RUNNER_MAX_AGE_MS, NaN);
21
+ const fromEnv = parsePositiveIntegerMs(env?.DEVLOOPS_STALE_RUNNER_MAX_AGE_MS, NaN);
22
22
  if (Number.isFinite(fromEnv) && fromEnv > 0) {
23
23
  return fromEnv;
24
24
  }
@@ -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(
@@ -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,