@henryqw/pi-subagent 26.5.1 → 27.1.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/CONTEXT.md +10 -6
- package/README.md +48 -18
- package/dist/direct-herdr.d.ts +18 -5
- package/dist/direct-herdr.js +90 -13
- package/dist/herdr-runtime.js +3 -1
- package/dist/index.d.ts +27 -4
- package/dist/index.js +122 -25
- package/dist/integration-git.js +3 -3
- package/dist/prune.d.ts +50 -0
- package/dist/prune.js +192 -0
- package/dist/runner.d.ts +13 -0
- package/dist/runner.js +104 -10
- package/dist/schema.d.ts +7 -0
- package/dist/schema.js +10 -5
- package/dist/store.d.ts +2 -0
- package/dist/store.js +14 -2
- package/dist/worktree.d.ts +3 -0
- package/dist/worktree.js +3 -1
- package/docs/migration.md +163 -0
- package/docs/orchestration.md +19 -10
- package/extensions/admission.ts +6 -11
- package/extensions/config.ts +9 -2
- package/extensions/isolated.ts +115 -37
- package/extensions/result-transport.ts +38 -5
- package/extensions/subagent-command.ts +113 -7
- package/extensions/subagent.ts +177 -60
- package/extensions/workflow.ts +36 -7
- package/package.json +1 -1
- package/skills/pi-subagent/SKILL.md +6 -4
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
-
import { isAbsolute, join } from "node:path";
|
|
2
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { DefaultPackageManager, getAgentDir, parseFrontmatter, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { extensionConfigDir } from "@henryqw/pi-config-store";
|
|
5
|
+
import { extensionConfigDir, extensionConfigPath } from "@henryqw/pi-config-store";
|
|
6
6
|
import { hasDisplayControlCharacters } from "./display-text.js";
|
|
7
7
|
import { loadRoleMcpConfig, parseRoleMcpAllowlist } from "./mcp-role.js";
|
|
8
8
|
import { loadTaskModelsConfig, modelReference, orderedProfileRoutes, PROFILE_NAMES, resolveConfiguredTaskRoute, resolveTaskModelRoute, } from "@henryqw/pi-task-models";
|
|
@@ -155,6 +155,54 @@ function rejectRetiredRoleIsolation(value, source) {
|
|
|
155
155
|
return;
|
|
156
156
|
throw new Error(`${source}: Role isolation is retired; select mode \"isolated\" on delegate_task instead.`);
|
|
157
157
|
}
|
|
158
|
+
const ROLE_FRONTMATTER_KEYS = ["name", "description", "modelClass", "tools", "extensions", "skills", "mcps"];
|
|
159
|
+
const ROLE_DEFAULT_KEYS = ["extensions", "tools", "skills"];
|
|
160
|
+
/** Validate `config.json` `roleDefaults` with the same rules as Role fields; `label` prefixes errors. */
|
|
161
|
+
export function parseRoleDefaults(value, label = "roleDefaults") {
|
|
162
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
163
|
+
throw new Error(`${label} must be a JSON object.`);
|
|
164
|
+
const record = value;
|
|
165
|
+
for (const key of Object.keys(record)) {
|
|
166
|
+
if (!ROLE_DEFAULT_KEYS.includes(key))
|
|
167
|
+
throw new Error(`${label}: unknown key ${JSON.stringify(key)}; expected ${ROLE_DEFAULT_KEYS.join(", ")}.`);
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
...(record.extensions === undefined ? {} : { extensions: extensionList(record.extensions, label) }),
|
|
171
|
+
...(record.tools === undefined ? {} : { tools: stringList(record.tools, "tools", label) }),
|
|
172
|
+
...(record.skills === undefined ? {} : { skills: stringList(record.skills, "skills", label) }),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function readRoleDefaults(agentDir) {
|
|
176
|
+
const path = extensionConfigPath("pi-subagent", agentDir);
|
|
177
|
+
let raw;
|
|
178
|
+
try {
|
|
179
|
+
raw = readFileSync(path, "utf8");
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT")
|
|
183
|
+
return {};
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
let parsed;
|
|
187
|
+
try {
|
|
188
|
+
parsed = JSON.parse(raw);
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
throw new Error(`${path} is not valid JSON (${error instanceof Error ? error.message : String(error)}).`);
|
|
192
|
+
}
|
|
193
|
+
// Whole-file validation belongs to the execution policy reader, which blocks delegation.
|
|
194
|
+
const value = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed.roleDefaults : undefined;
|
|
195
|
+
return value === undefined ? {} : parseRoleDefaults(value, `${path}: roleDefaults`);
|
|
196
|
+
}
|
|
197
|
+
const withDefaults = (defaults, values) => [...new Set([...(defaults ?? []), ...values])];
|
|
198
|
+
function applyRoleDefaults(role, defaults) {
|
|
199
|
+
return {
|
|
200
|
+
...role,
|
|
201
|
+
tools: withDefaults(defaults.tools, role.tools),
|
|
202
|
+
extensions: withDefaults(defaults.extensions, role.extensions),
|
|
203
|
+
skills: withDefaults(defaults.skills, role.skills),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
158
206
|
// Built-in Roles resolved from the package-shipped Markdown relative to this module.
|
|
159
207
|
const BUILTIN_ROLE_NAMES = ["implementer", "reviewer", "scout"];
|
|
160
208
|
/** Single-file Role parser shared by built-in and user roles. */
|
|
@@ -168,6 +216,11 @@ function parseRoleFile(file, raw) {
|
|
|
168
216
|
}
|
|
169
217
|
const frontmatter = parsed.frontmatter;
|
|
170
218
|
rejectRetiredRoleIsolation(frontmatter.isolation, file);
|
|
219
|
+
for (const key of Object.keys(frontmatter)) {
|
|
220
|
+
if (!ROLE_FRONTMATTER_KEYS.includes(key)) {
|
|
221
|
+
throw new Error(`${file}: unknown Role frontmatter key ${JSON.stringify(key)}; expected ${ROLE_FRONTMATTER_KEYS.join(", ")}.`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
171
224
|
const modelClass = roleModelClass(frontmatter.modelClass, file);
|
|
172
225
|
return {
|
|
173
226
|
name: parseRoleName(frontmatter.name, file),
|
|
@@ -196,35 +249,81 @@ export function loadBuiltinRole(name) {
|
|
|
196
249
|
function builtinRoles() {
|
|
197
250
|
return BUILTIN_ROLE_NAMES.map(loadBuiltinRole);
|
|
198
251
|
}
|
|
252
|
+
function claimedRoleName(file) {
|
|
253
|
+
try {
|
|
254
|
+
const { name } = parseFrontmatter(readFileSync(file, "utf8")).frontmatter;
|
|
255
|
+
if (typeof name === "string" && name.trim())
|
|
256
|
+
return name.trim();
|
|
257
|
+
}
|
|
258
|
+
catch { /* The file's own error is reported; fall back to its stem. */ }
|
|
259
|
+
return basename(file, ".md");
|
|
260
|
+
}
|
|
199
261
|
/**
|
|
200
|
-
*
|
|
201
|
-
* A user
|
|
202
|
-
*
|
|
262
|
+
* Package-shipped Roles plus user Roles from `config/pi-subagent`, with
|
|
263
|
+
* `roleDefaults` merged in. A user Role overrides a same-named built-in. Each
|
|
264
|
+
* invalid or duplicate user file is reported and makes its claimed name
|
|
265
|
+
* unavailable without hiding valid Roles. Malformed `roleDefaults` throws.
|
|
203
266
|
*/
|
|
204
|
-
export function
|
|
205
|
-
const
|
|
267
|
+
export function loadRoleCatalog(agentDir = getAgentDir()) {
|
|
268
|
+
const defaults = readRoleDefaults(agentDir);
|
|
269
|
+
const builtins = new Map(builtinRoles().map((role) => [role.name, role]));
|
|
206
270
|
const dir = extensionConfigDir("pi-subagent", agentDir);
|
|
207
|
-
let entries;
|
|
271
|
+
let entries = [];
|
|
208
272
|
try {
|
|
209
273
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
210
274
|
}
|
|
211
275
|
catch (error) {
|
|
212
|
-
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT")
|
|
213
|
-
|
|
214
|
-
|
|
276
|
+
if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT"))
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
const user = new Map();
|
|
280
|
+
const errors = [];
|
|
281
|
+
for (const entry of entries
|
|
282
|
+
.filter((candidate) => candidate.name.endsWith(".md") && (candidate.isFile() || candidate.isSymbolicLink()))
|
|
283
|
+
.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
284
|
+
const file = join(dir, entry.name);
|
|
285
|
+
try {
|
|
286
|
+
const role = readRoleFile(file);
|
|
287
|
+
user.set(role.name, [...(user.get(role.name) ?? []), { file, role }]);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
errors.push({ file, name: claimedRoleName(file), message: error instanceof Error ? error.message : String(error) });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
for (const [name, files] of user) {
|
|
294
|
+
if (files.length < 2)
|
|
295
|
+
continue;
|
|
296
|
+
for (const { file } of files)
|
|
297
|
+
errors.push({ file, name, message: `${file}: Duplicate Subagent role: ${name} (${files.map((item) => item.file).join(", ")}).` });
|
|
298
|
+
}
|
|
299
|
+
const blocked = new Set(errors.map(({ name }) => name));
|
|
300
|
+
const sources = new Map();
|
|
301
|
+
const byName = new Map();
|
|
302
|
+
for (const [name, role] of builtins) {
|
|
303
|
+
byName.set(name, user.get(name)?.[0]?.role ?? role);
|
|
304
|
+
sources.set(name, user.has(name) ? "user override" : "built-in");
|
|
305
|
+
}
|
|
306
|
+
for (const [name, files] of user) {
|
|
307
|
+
if (builtins.has(name))
|
|
308
|
+
continue;
|
|
309
|
+
byName.set(name, files[0].role);
|
|
310
|
+
sources.set(name, "user");
|
|
215
311
|
}
|
|
216
|
-
const
|
|
217
|
-
.
|
|
218
|
-
.
|
|
219
|
-
.map((entry) => readRoleFile(join(dir, entry.name)));
|
|
220
|
-
const names = new Set();
|
|
221
|
-
for (const role of userRoles) {
|
|
222
|
-
if (names.has(role.name))
|
|
223
|
-
throw new Error(`Duplicate Subagent role: ${role.name}.`);
|
|
224
|
-
names.add(role.name);
|
|
225
|
-
byName.set(role.name, role);
|
|
312
|
+
for (const name of blocked) {
|
|
313
|
+
byName.delete(name);
|
|
314
|
+
sources.delete(name);
|
|
226
315
|
}
|
|
227
|
-
return [...byName.values()];
|
|
316
|
+
return { roles: [...byName.values()].map((role) => applyRoleDefaults(role, defaults)), sources, errors };
|
|
317
|
+
}
|
|
318
|
+
/** Select one catalog Role, naming invalid files that claim it. */
|
|
319
|
+
export function findRole(catalog, name) {
|
|
320
|
+
const role = catalog.roles.find((candidate) => candidate.name === name);
|
|
321
|
+
if (role)
|
|
322
|
+
return role;
|
|
323
|
+
const invalid = catalog.errors.filter((error) => error.name === name);
|
|
324
|
+
if (invalid.length)
|
|
325
|
+
throw new Error(`Subagent role ${name} is unavailable: ${invalid.map(({ message }) => message).join("; ")}`);
|
|
326
|
+
throw new Error(`Unknown Subagent role: ${name}. Available roles: ${catalog.roles.map((candidate) => candidate.name).join(", ") || "none"}.${catalog.errors.length ? ` ${catalog.errors.length} invalid Role file(s); run /subagent roles.` : ""}`);
|
|
228
327
|
}
|
|
229
328
|
export function resolveTaskRoute(ctx, profileName, agentDir = getAgentDir()) {
|
|
230
329
|
let config;
|
|
@@ -376,9 +475,7 @@ export async function resolveConfiguredRoleLaunch(pi, ctx, input) {
|
|
|
376
475
|
const roleName = parseRoleName(input.role);
|
|
377
476
|
if (input.modelClass === undefined)
|
|
378
477
|
throw new Error("Configured Role launch requires an explicit modelClass.");
|
|
379
|
-
const role =
|
|
380
|
-
if (!role)
|
|
381
|
-
throw new Error(`Required configured Role ${roleName} is missing or ambiguous.`);
|
|
478
|
+
const role = findRole(loadRoleCatalog(), roleName);
|
|
382
479
|
if (role.mcps?.length)
|
|
383
480
|
loadRoleMcpConfig(getAgentDir(), role.mcps, { codemode: roleActivatesCodemode(role.tools) });
|
|
384
481
|
const resources = await resolveRolePackageResources(role, ctx);
|
package/dist/integration-git.js
CHANGED
|
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { basename, join } from "node:path";
|
|
4
4
|
import { runGit } from "./git-process.js";
|
|
5
5
|
import { CheckedGitRuntime } from "./git-runtime.js";
|
|
6
|
-
import { createChildWorktree, inspectWorktreeDirty, WorktreeSetupError } from "./worktree.js";
|
|
6
|
+
import { createChildWorktree, inspectWorktreeDirty, OWNED_BRANCH_PATTERN, WORKTREES_DIRNAME, WorktreeSetupError } from "./worktree.js";
|
|
7
7
|
import { checkBatchPasses, reviewEvidencePasses, sameIdentity, } from "./schema.js";
|
|
8
8
|
const oidPattern = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
9
9
|
const diagnostic = (value) => value.trim().slice(0, 500);
|
|
@@ -52,8 +52,8 @@ async function ownershipMetadata(root, info, signal) {
|
|
|
52
52
|
const repoRoot = await realpath(primary.slice("worktree ".length));
|
|
53
53
|
if (await realpath(await requireGit(["rev-parse", "--show-toplevel"], root, signal)) !== canonical
|
|
54
54
|
|| info.repoRoot !== repoRoot || info.cwd !== info.path
|
|
55
|
-
||
|
|
56
|
-
|| info.path !== join(repoRoot,
|
|
55
|
+
|| !OWNED_BRANCH_PATTERN.test(info.branch)
|
|
56
|
+
|| info.path !== join(repoRoot, WORKTREES_DIRNAME, basename(info.branch))) {
|
|
57
57
|
throw new Error("Git worktree is not an exact owned checkout of Main.");
|
|
58
58
|
}
|
|
59
59
|
}
|
package/dist/prune.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type GitRunner } from "./git-process.ts";
|
|
2
|
+
/** One reviewed pi-subagent branch, with its registered checkout when one exists. */
|
|
3
|
+
export interface PruneItem {
|
|
4
|
+
branch: string;
|
|
5
|
+
tip: string;
|
|
6
|
+
path?: string;
|
|
7
|
+
prunable: boolean;
|
|
8
|
+
/** Why the item is prunable, or every exact blocker. */
|
|
9
|
+
reason: string;
|
|
10
|
+
/** Ignored entries as Git collapses them; removing the checkout deletes them. */
|
|
11
|
+
ignored: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
export interface PruneOwnership {
|
|
14
|
+
/** The request or state that may still own a resource name (`subagent-<hex>`), if any. */
|
|
15
|
+
owner(name: string): string | undefined;
|
|
16
|
+
/** The live productive lease that blocks pruning, if any. */
|
|
17
|
+
lease?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface PruneGuards {
|
|
20
|
+
/** Ownership and lease evidence across the repository's existing checkouts. */
|
|
21
|
+
ownership(checkouts: readonly string[]): Promise<PruneOwnership>;
|
|
22
|
+
/** Every Herdr pane; rejects when Herdr cannot be inspected. */
|
|
23
|
+
panes(): Promise<readonly {
|
|
24
|
+
paneId: string;
|
|
25
|
+
cwds: readonly string[];
|
|
26
|
+
}[]>;
|
|
27
|
+
/** The confirming session still owns the command; checked immediately before each removal step. */
|
|
28
|
+
current?(): boolean;
|
|
29
|
+
}
|
|
30
|
+
export type PruneRemoval = {
|
|
31
|
+
outcome: "removed" | "skipped" | "failed";
|
|
32
|
+
item: PruneItem;
|
|
33
|
+
reason: string;
|
|
34
|
+
};
|
|
35
|
+
/** Every pane in every Herdr workspace with its shell and foreground working directories. */
|
|
36
|
+
export declare function listHerdrPanes(cwd: string): Promise<{
|
|
37
|
+
paneId: string;
|
|
38
|
+
cwds: string[];
|
|
39
|
+
}[]>;
|
|
40
|
+
/** Read-only review of every pi-subagent worker or integration branch and its checkout. */
|
|
41
|
+
export declare function inspectPruneCandidates(root: string, mainCwd: string, guards: PruneGuards, run?: GitRunner): Promise<PruneItem[]>;
|
|
42
|
+
/**
|
|
43
|
+
* Re-verify each reviewed prunable item immediately before removing its checkout, then delete its
|
|
44
|
+
* branch only at the reviewed tip. Ignored entries are deleted only when `allowIgnored` and all of
|
|
45
|
+
* them were in the review. Returns the repository checkouts that still exist.
|
|
46
|
+
*/
|
|
47
|
+
export declare function removePruneCandidates(root: string, mainCwd: string, reviewed: readonly PruneItem[], allowIgnored: boolean, guards: PruneGuards, run?: GitRunner): Promise<{
|
|
48
|
+
removals: PruneRemoval[];
|
|
49
|
+
checkouts: string[];
|
|
50
|
+
}>;
|
package/dist/prune.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { basename, join, sep } from "node:path";
|
|
3
|
+
import { createHerdrClient } from "@henryqw/pi-herdr";
|
|
4
|
+
import { runGit } from "./git-process.js";
|
|
5
|
+
import { runProcess } from "./process.js";
|
|
6
|
+
import { inspectWorktreeDirty, OWNED_BRANCH_PATTERN, WORKTREES_DIRNAME } from "./worktree.js";
|
|
7
|
+
const HERDR_TIMEOUT_MS = 30_000;
|
|
8
|
+
const IN_PROGRESS_MARKERS = ["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "REBASE_HEAD", "BISECT_LOG", "rebase-merge", "rebase-apply", "sequencer"];
|
|
9
|
+
async function requireGit(run, args, cwd) {
|
|
10
|
+
const result = await run(args, cwd);
|
|
11
|
+
if (result.code !== 0)
|
|
12
|
+
throw new Error(`git ${args[0]} failed (exit ${result.code}): ${result.stderr.trim().slice(0, 200)}`);
|
|
13
|
+
return result.stdout.trimEnd();
|
|
14
|
+
}
|
|
15
|
+
const within = (directory, path) => path === directory || path.startsWith(`${directory}${sep}`);
|
|
16
|
+
/** Every pane in every Herdr workspace with its shell and foreground working directories. */
|
|
17
|
+
export async function listHerdrPanes(cwd) {
|
|
18
|
+
const response = await createHerdrClient(runProcess).json(["pane", "list"], { cwd, timeoutMs: HERDR_TIMEOUT_MS });
|
|
19
|
+
const result = response.result;
|
|
20
|
+
if (result?.type !== "pane_list" || !Array.isArray(result.panes))
|
|
21
|
+
throw new Error("Herdr pane list response is malformed.");
|
|
22
|
+
return result.panes.map((pane) => {
|
|
23
|
+
const cwds = [pane.cwd, pane.foreground_cwd].filter((cwd) => typeof cwd === "string");
|
|
24
|
+
if (typeof pane.pane_id !== "string" || !cwds.length)
|
|
25
|
+
throw new Error("Herdr listed a pane without an ID or working directory.");
|
|
26
|
+
return { paneId: pane.pane_id, cwds };
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
async function snapshot(root, mainCwd, run) {
|
|
30
|
+
const checkouts = [];
|
|
31
|
+
let current;
|
|
32
|
+
for (const field of (await requireGit(run, ["worktree", "list", "--porcelain", "-z"], root)).split("\0")) {
|
|
33
|
+
if (field.startsWith("worktree "))
|
|
34
|
+
checkouts.push(current = { path: field.slice("worktree ".length), locked: false, missing: false });
|
|
35
|
+
else if (current && field.startsWith("branch refs/heads/"))
|
|
36
|
+
current.branch = field.slice("branch refs/heads/".length);
|
|
37
|
+
else if (current && (field === "locked" || field.startsWith("locked ")))
|
|
38
|
+
current.locked = true;
|
|
39
|
+
else if (current && (field === "prunable" || field.startsWith("prunable ")))
|
|
40
|
+
current.missing = true;
|
|
41
|
+
}
|
|
42
|
+
if (!checkouts[0])
|
|
43
|
+
throw new Error("Primary Git worktree is unavailable.");
|
|
44
|
+
const branches = new Map();
|
|
45
|
+
for (const line of (await requireGit(run, ["for-each-ref", "--format=%(refname:lstrip=2) %(objectname)", "refs/heads/"], root)).split("\n")) {
|
|
46
|
+
const [branch, tip] = line.split(" ");
|
|
47
|
+
if (branch && tip && OWNED_BRANCH_PATTERN.test(branch))
|
|
48
|
+
branches.set(branch, tip);
|
|
49
|
+
}
|
|
50
|
+
const targets = [{ label: "Main HEAD", head: await requireGit(run, ["rev-parse", "--verify", "HEAD^{commit}"], mainCwd) }];
|
|
51
|
+
const origin = await run(["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], root);
|
|
52
|
+
if (origin.code === 0) {
|
|
53
|
+
const ref = origin.stdout.trim();
|
|
54
|
+
targets.push({ label: ref.replace(/^refs\/remotes\//, ""), head: await requireGit(run, ["rev-parse", "--verify", `${ref}^{commit}`], root) });
|
|
55
|
+
}
|
|
56
|
+
return { primary: checkouts[0].path, checkouts, branches, targets };
|
|
57
|
+
}
|
|
58
|
+
async function classify(snap, branch, mainCwd, ownership, panes, run, root) {
|
|
59
|
+
const tip = snap.branches.get(branch);
|
|
60
|
+
const name = basename(branch);
|
|
61
|
+
const ownedPath = join(snap.primary, WORKTREES_DIRNAME, name);
|
|
62
|
+
// A detached or switched owned checkout no longer names the branch; find it by its owned path too.
|
|
63
|
+
const checkout = snap.checkouts.find((item) => item.branch === branch) ?? snap.checkouts.find((item) => item.path === ownedPath);
|
|
64
|
+
const blockers = [];
|
|
65
|
+
if (checkout && checkout.branch !== branch)
|
|
66
|
+
blockers.push(`owned checkout is ${checkout.branch ? `on branch ${checkout.branch}` : "detached"}; inspect it manually`);
|
|
67
|
+
let ignored = [];
|
|
68
|
+
const owner = ownership.owner(name);
|
|
69
|
+
if (owner)
|
|
70
|
+
blockers.push(owner);
|
|
71
|
+
if (ownership.lease)
|
|
72
|
+
blockers.push(ownership.lease);
|
|
73
|
+
let merged;
|
|
74
|
+
for (const target of snap.targets) {
|
|
75
|
+
const result = await run(["merge-base", "--is-ancestor", tip, target.head], root);
|
|
76
|
+
if (result.code === 0) {
|
|
77
|
+
merged = target.label;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
if (result.code !== 1)
|
|
81
|
+
throw new Error(`git merge-base failed (exit ${result.code}): ${result.stderr.trim().slice(0, 200)}`);
|
|
82
|
+
}
|
|
83
|
+
if (!merged)
|
|
84
|
+
blockers.push(`branch has commits not in ${snap.targets.map(({ label }) => label).join(" or ")}`);
|
|
85
|
+
if (checkout) {
|
|
86
|
+
const path = checkout.path;
|
|
87
|
+
if (path !== ownedPath)
|
|
88
|
+
blockers.push("checkout is not at its owned .worktrees path");
|
|
89
|
+
if (checkout.locked)
|
|
90
|
+
blockers.push("checkout is locked");
|
|
91
|
+
if (checkout.missing || !existsSync(path))
|
|
92
|
+
blockers.push("checkout directory is missing; inspect it, then run git worktree prune");
|
|
93
|
+
else {
|
|
94
|
+
const real = realpathSync.native(path);
|
|
95
|
+
if (within(real, realpathSync.native(mainCwd)))
|
|
96
|
+
blockers.push("Main runs inside the checkout");
|
|
97
|
+
const gitDir = await requireGit(run, ["rev-parse", "--absolute-git-dir"], path);
|
|
98
|
+
const markers = IN_PROGRESS_MARKERS.filter((marker) => existsSync(join(gitDir, marker)));
|
|
99
|
+
if (markers.length)
|
|
100
|
+
blockers.push(`Git operation in progress (${markers.join(", ")})`);
|
|
101
|
+
const dirty = await inspectWorktreeDirty(path, run, false);
|
|
102
|
+
if (dirty.failure)
|
|
103
|
+
blockers.push(`checkout could not be inspected: ${dirty.failure}`);
|
|
104
|
+
else if (dirty.dirty)
|
|
105
|
+
blockers.push("checkout has tracked or untracked changes");
|
|
106
|
+
else if (dirty.initializedSubmodules)
|
|
107
|
+
blockers.push("checkout has initialized submodules; Git cannot remove it without force, so remove it manually");
|
|
108
|
+
if (typeof panes === "string")
|
|
109
|
+
blockers.push(`Herdr unavailable (${panes}); cannot prove no pane uses the checkout`);
|
|
110
|
+
else {
|
|
111
|
+
const used = panes.filter((pane) => pane.cwds.some((cwd) => within(path, cwd) || within(real, cwd)));
|
|
112
|
+
if (used.length)
|
|
113
|
+
blockers.push(`Herdr pane ${used.map(({ paneId }) => paneId).join(", ")} works inside the checkout`);
|
|
114
|
+
}
|
|
115
|
+
ignored = (await requireGit(run, ["status", "--porcelain=v1", "-z", "--ignored=matching", "--untracked-files=normal"], path))
|
|
116
|
+
.split("\0").filter((entry) => entry.startsWith("!! ")).map((entry) => entry.slice(3));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
branch, tip, ...(checkout ? { path: checkout.path } : {}), prunable: !blockers.length, ignored,
|
|
121
|
+
reason: blockers.length ? blockers.join("; ") : `merged into ${merged}; ${checkout ? "clean checkout" : "no checkout"}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const existing = (snap) => snap.checkouts.filter((item) => !item.missing && existsSync(item.path)).map(({ path }) => path);
|
|
125
|
+
async function evidence(root, mainCwd, guards, run) {
|
|
126
|
+
const snap = await snapshot(root, mainCwd, run);
|
|
127
|
+
const ownership = await guards.ownership(existing(snap));
|
|
128
|
+
let panes;
|
|
129
|
+
try {
|
|
130
|
+
panes = await guards.panes();
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
panes = (error instanceof Error ? error.message : String(error)).slice(0, 200);
|
|
134
|
+
}
|
|
135
|
+
return { snap, ownership, panes };
|
|
136
|
+
}
|
|
137
|
+
/** Read-only review of every pi-subagent worker or integration branch and its checkout. */
|
|
138
|
+
export async function inspectPruneCandidates(root, mainCwd, guards, run = runGit) {
|
|
139
|
+
const { snap, ownership, panes } = await evidence(root, mainCwd, guards, run);
|
|
140
|
+
const items = [];
|
|
141
|
+
for (const branch of [...snap.branches.keys()].sort())
|
|
142
|
+
items.push(await classify(snap, branch, mainCwd, ownership, panes, run, root));
|
|
143
|
+
return items;
|
|
144
|
+
}
|
|
145
|
+
async function removeOne(root, mainCwd, reviewed, allowIgnored, guards, run) {
|
|
146
|
+
const skip = (reason) => ({ outcome: "skipped", item: reviewed, reason });
|
|
147
|
+
const fail = (reason) => ({ outcome: "failed", item: reviewed, reason });
|
|
148
|
+
const { snap, ownership, panes } = await evidence(root, mainCwd, guards, run);
|
|
149
|
+
if (snap.branches.get(reviewed.branch) !== reviewed.tip)
|
|
150
|
+
return skip("branch was deleted or moved since review");
|
|
151
|
+
const fresh = await classify(snap, reviewed.branch, mainCwd, ownership, panes, run, root);
|
|
152
|
+
if (!fresh.prunable)
|
|
153
|
+
return skip(fresh.reason);
|
|
154
|
+
if (fresh.path !== reviewed.path)
|
|
155
|
+
return skip("checkout registration changed since review");
|
|
156
|
+
if (fresh.ignored.some((entry) => !allowIgnored || !reviewed.ignored.includes(entry)))
|
|
157
|
+
return skip("ignored content was not approved or changed since review");
|
|
158
|
+
const stale = () => guards.current !== undefined && !guards.current();
|
|
159
|
+
if (fresh.path) {
|
|
160
|
+
if (stale())
|
|
161
|
+
return skip("session or branch changed before removal; reopen /subagent prune");
|
|
162
|
+
// Git removes ignored content without --force; the checks above approved it.
|
|
163
|
+
const removed = await run(["worktree", "remove", fresh.path], root);
|
|
164
|
+
if (removed.code !== 0)
|
|
165
|
+
return fail(`git worktree remove failed: ${removed.stderr.trim().slice(0, 200)}`);
|
|
166
|
+
if (existsSync(fresh.path))
|
|
167
|
+
return fail("git worktree remove succeeded but the checkout remains");
|
|
168
|
+
}
|
|
169
|
+
if (stale())
|
|
170
|
+
return skip(`${fresh.path ? "checkout removed; " : ""}session or branch changed before branch deletion; branch kept`);
|
|
171
|
+
const deleted = await run(["update-ref", "-d", `refs/heads/${reviewed.branch}`, reviewed.tip], root);
|
|
172
|
+
if (deleted.code !== 0)
|
|
173
|
+
return fail(`${fresh.path ? "checkout removed; " : ""}branch kept: ${deleted.stderr.trim().slice(0, 200)}`);
|
|
174
|
+
return { outcome: "removed", item: reviewed, reason: fresh.reason };
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Re-verify each reviewed prunable item immediately before removing its checkout, then delete its
|
|
178
|
+
* branch only at the reviewed tip. Ignored entries are deleted only when `allowIgnored` and all of
|
|
179
|
+
* them were in the review. Returns the repository checkouts that still exist.
|
|
180
|
+
*/
|
|
181
|
+
export async function removePruneCandidates(root, mainCwd, reviewed, allowIgnored, guards, run = runGit) {
|
|
182
|
+
const removals = [];
|
|
183
|
+
for (const item of reviewed) {
|
|
184
|
+
try {
|
|
185
|
+
removals.push(await removeOne(root, mainCwd, item, allowIgnored, guards, run));
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
removals.push({ outcome: "failed", item, reason: (error instanceof Error ? error.message : String(error)).slice(0, 300) });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { removals, checkouts: existing(await snapshot(root, mainCwd, run)) };
|
|
192
|
+
}
|
package/dist/runner.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { EphemeralSubagentExecutor } from "./ephemeral.ts";
|
|
2
|
+
import type { PruneOwnership } from "./prune.ts";
|
|
2
3
|
import { IntegrationGit } from "./integration-git.ts";
|
|
3
4
|
import { type AllocationKind, type CheckCommand, type ChangesetTaskRequest, type CleanupKind, type ExecuteRequest, type ExecutionPolicySnapshot, type HostAllocationIntent, type HostAllocationPlan, type ModelClass, type ReviewEvidence, type ResumeRequest, type RunState, type TaskAttempt, type TaskRequest, type TaskState, type WorktreeAllocationIntent, type WorktreeAllocationPlan, type WorkspaceIdentity } from "./schema.ts";
|
|
4
5
|
import { FileRunStore } from "./store.ts";
|
|
6
|
+
/** One durable follow-up per attempt; the runner itself never commits, stages, stashes, or discards. */
|
|
7
|
+
export declare const COMMIT_NUDGE_INSTRUCTION = "Your turn ended with uncommitted changes in your worktree. Commit the intended changes now so the worktree is clean, or explain in your reply why they must not be committed. Do not discard work.";
|
|
5
8
|
export declare const CLEANUP_SAFETY_BUDGET_MS = 30000;
|
|
6
9
|
export declare const TERMINATION_SAFETY_BUDGET_MS = 15000;
|
|
7
10
|
export declare const STATUS_INSPECTION_BUDGET_MS = 5000;
|
|
@@ -65,6 +68,11 @@ export type WorkerResult = {
|
|
|
65
68
|
} | {
|
|
66
69
|
outcome: "blocked" | "not_prompted";
|
|
67
70
|
diagnostic: string;
|
|
71
|
+
}
|
|
72
|
+
/** The exact turn finished in its valid owned worktree with tracked or untracked changes. */
|
|
73
|
+
| {
|
|
74
|
+
outcome: "uncommitted";
|
|
75
|
+
diagnostic: string;
|
|
68
76
|
} | {
|
|
69
77
|
outcome: "unknown" | "interrupted";
|
|
70
78
|
diagnostic: string;
|
|
@@ -314,6 +322,11 @@ export declare class IsolatedRunner {
|
|
|
314
322
|
invalidIds: string[];
|
|
315
323
|
leaseBusy: boolean;
|
|
316
324
|
}>;
|
|
325
|
+
/** Prune evidence across checkouts. A non-terminal state owns every resource name its saved text
|
|
326
|
+
* mentions; an unreadable state owns every name its raw text mentions, or every name when unreadable. */
|
|
327
|
+
pruneOwnership(checkouts: readonly string[]): Promise<PruneOwnership>;
|
|
328
|
+
/** After pruning, reconcile aborted requests that recorded a removed resource through the guarded abort path. */
|
|
329
|
+
reconcilePrunedAborts(checkouts: readonly string[], names: readonly string[]): Promise<string[]>;
|
|
317
330
|
canFollowup(root: string, requestId: string, taskId: string): boolean;
|
|
318
331
|
drainFollowups(root: string, requestId: string, taskId: string): string[];
|
|
319
332
|
queueFollowup(root: string, requestId: string, taskId: string, instruction: string): string;
|