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.
- package/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +1 -1
- package/.claude/hooks/_run-context.mjs +9 -16
- package/.claude/skills/copilot-pr-followup/SKILL.md +9 -7
- package/.claude/skills/dev-loop/SKILL.md +6 -6
- package/.claude/skills/docs/anti-patterns.md +1 -1
- package/.claude/skills/docs/copilot-loop-operations.md +1 -1
- package/.claude/skills/docs/issue-intake-procedure.md +4 -0
- package/.claude/skills/docs/merge-preconditions.md +65 -1
- package/.claude/skills/docs/stop-conditions.md +1 -0
- package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
- package/.claude/skills/local-implementation/SKILL.md +8 -2
- package/CHANGELOG.md +26 -0
- package/README.md +8 -2
- package/cli/index.mjs +21 -2
- package/extension/README.md +1 -1
- package/package.json +3 -3
- package/scripts/README.md +2 -2
- package/scripts/github/offer-human-handoff.mjs +147 -0
- package/scripts/github/probe-ci-status.mjs +468 -0
- package/scripts/github/request-copilot-review.mjs +3 -3
- package/scripts/github/resolve-handoff-candidates.mjs +412 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +2 -2
- package/scripts/loop/_stale-runner-detection.mjs +1 -1
- package/scripts/loop/_worktree-path.mjs +27 -0
- package/scripts/loop/cleanup-worktree.mjs +175 -0
- package/scripts/loop/copilot-pr-handoff.mjs +1 -1
- package/scripts/loop/detect-stale-runner.mjs +3 -4
- package/scripts/loop/ensure-worktree.mjs +219 -0
- package/scripts/loop/outer-loop.mjs +1 -1
- package/scripts/loop/pr-runner-coordination.mjs +1 -1
- package/scripts/loop/pre-flight-gate.mjs +10 -7
- package/scripts/loop/pre-push-main-guard.mjs +4 -4
- package/scripts/loop/provision-worktree.mjs +243 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +5 -5
- package/scripts/loop/run-queue.mjs +25 -1
- package/scripts/loop/run-watch-cycle.mjs +75 -22
- package/scripts/projects/add-queue-item.mjs +20 -5
- package/scripts/projects/archive-done-items.mjs +2 -1
- package/scripts/projects/ensure-queue-board.mjs +2 -1
- package/scripts/projects/list-queue-items.mjs +2 -1
- package/scripts/projects/move-queue-item.mjs +2 -1
- package/scripts/projects/reorder-queue-item.mjs +5 -4
- package/scripts/projects/sync-item-status.mjs +2 -1
- package/skills/copilot-pr-followup/SKILL.md +9 -7
- package/skills/dev-loop/SKILL.md +2 -2
- package/skills/docs/anti-patterns.md +1 -1
- package/skills/docs/copilot-loop-operations.md +1 -1
- package/skills/docs/issue-intake-procedure.md +4 -0
- package/skills/docs/merge-preconditions.md +65 -1
- package/skills/docs/stop-conditions.md +1 -0
- package/skills/docs/tracker-first-loop-state.md +6 -6
- 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
|
|
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
|
|
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?.
|
|
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
|
|
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 $
|
|
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
|
|
25
|
-
|
|
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,
|