@henryqw/pi-subagent 18.0.0 → 22.0.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 (47) hide show
  1. package/CONTEXT.md +76 -24
  2. package/README.md +116 -142
  3. package/dist/composition.d.ts +11 -0
  4. package/dist/composition.js +107 -0
  5. package/dist/direct-herdr.d.ts +17 -0
  6. package/dist/direct-herdr.js +219 -0
  7. package/dist/ephemeral.d.ts +2 -2
  8. package/dist/ephemeral.js +7 -7
  9. package/dist/git-runtime.d.ts +110 -0
  10. package/dist/git-runtime.js +472 -0
  11. package/dist/herdr-runtime.d.ts +137 -0
  12. package/dist/herdr-runtime.js +1228 -0
  13. package/dist/index.d.ts +11 -5
  14. package/dist/index.js +20 -16
  15. package/dist/integration-git.d.ts +59 -0
  16. package/dist/integration-git.js +484 -0
  17. package/dist/launch-runtime.d.ts +54 -0
  18. package/dist/launch-runtime.js +195 -0
  19. package/dist/process.d.ts +9 -0
  20. package/dist/process.js +11 -0
  21. package/dist/runner.d.ts +356 -0
  22. package/dist/runner.js +2710 -0
  23. package/dist/schema.d.ts +2234 -0
  24. package/dist/schema.js +1236 -0
  25. package/dist/store.d.ts +50 -0
  26. package/dist/store.js +288 -0
  27. package/dist/working-evidence.d.ts +25 -0
  28. package/dist/working-evidence.js +261 -0
  29. package/docs/adr/001-composable-ephemeral-execution.md +3 -3
  30. package/docs/delegation-routing.html +89 -0
  31. package/docs/delegation-routing.svg +78 -0
  32. package/docs/orchestration-architecture.html +238 -0
  33. package/docs/orchestration-architecture.svg +189 -0
  34. package/docs/orchestration.md +122 -331
  35. package/example.png +0 -0
  36. package/examples/roles/implementer.md +2 -3
  37. package/extensions/admission.ts +66 -0
  38. package/extensions/config.ts +51 -37
  39. package/extensions/isolated.ts +638 -0
  40. package/extensions/result-transport.ts +13 -103
  41. package/extensions/role-tools.ts +11 -11
  42. package/extensions/subagent-command.ts +167 -0
  43. package/extensions/subagent.ts +264 -540
  44. package/extensions/task-name.ts +1 -2
  45. package/extensions/workflow.ts +60 -96
  46. package/package.json +19 -11
  47. package/skills/pi-subagent/SKILL.md +35 -0
@@ -0,0 +1,484 @@
1
+ import { chmod, lstat, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { basename, join } from "node:path";
4
+ import { runGit } from "./git-process.js";
5
+ import { CheckedGitRuntime } from "./git-runtime.js";
6
+ import { createChildWorktree, inspectWorktreeDirty, WorktreeSetupError } from "./worktree.js";
7
+ import { checkBatchPasses, reviewEvidencePasses, sameIdentity, } from "./schema.js";
8
+ const oidPattern = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
9
+ const diagnostic = (value) => value.trim().slice(0, 500);
10
+ const failure = (args, result) => `git ${args[0]} failed (exit ${result.code}): ${diagnostic(result.stderr || result.stdout)}`;
11
+ const unknown = (failure, worktree) => ({
12
+ outcome: "unknown", failure, possibleResources: [worktree.path, worktree.branch],
13
+ });
14
+ async function git(args, cwd, signal) {
15
+ if (signal.aborted)
16
+ throw signal.reason;
17
+ return await runGit(args, cwd, signal);
18
+ }
19
+ async function requireGit(args, cwd, signal) {
20
+ const result = await git(args, cwd, signal);
21
+ if (result.code !== 0)
22
+ throw new Error(failure(args, result));
23
+ return result.stdout.trimEnd();
24
+ }
25
+ async function ancestor(base, tip, cwd, signal) {
26
+ const args = ["merge-base", "--is-ancestor", base, tip];
27
+ const result = await git(args, cwd, signal);
28
+ if (result.code === 0)
29
+ return true;
30
+ if (result.code === 1)
31
+ return false;
32
+ throw new Error(failure(args, result));
33
+ }
34
+ async function sharedHistory(base, tip, cwd, signal) {
35
+ const args = ["merge-base", base, tip];
36
+ const result = await git(args, cwd, signal);
37
+ if (result.code === 1)
38
+ return false;
39
+ if (result.code !== 0)
40
+ throw new Error(failure(args, result));
41
+ if (!oidPattern.test(result.stdout.trim()))
42
+ throw new Error("Git returned a malformed shared ancestor.");
43
+ return true;
44
+ }
45
+ /** Check helper-derived names even after a safely removed checkout is no longer present. */
46
+ async function ownershipMetadata(root, info, signal) {
47
+ const canonical = await realpath(root);
48
+ if (await realpath(await requireGit(["rev-parse", "--show-toplevel"], root, signal)) !== canonical
49
+ || info.repoRoot !== canonical || info.cwd !== info.path
50
+ || !/^pi-subagent\/subagent-[0-9a-f]{24}$/.test(info.branch)
51
+ || info.path !== join(canonical, ".worktrees", basename(info.branch))) {
52
+ throw new Error("Git worktree is not an exact owned checkout of Main.");
53
+ }
54
+ }
55
+ /** Only helper-derived, still-registered worktrees belonging to the exact Main repository. */
56
+ async function owned(root, info, signal) {
57
+ await ownershipMetadata(root, info, signal);
58
+ if (await realpath(info.path) !== info.path)
59
+ throw new Error("Owned worktree path changed.");
60
+ const registered = await requireGit(["worktree", "list", "--porcelain", "-z"], root, signal);
61
+ if (!registered.split("\0").includes(`worktree ${info.path}`))
62
+ throw new Error("Owned worktree registration is missing.");
63
+ const common = await realpath(await requireGit(["rev-parse", "--path-format=absolute", "--git-common-dir"], root, signal));
64
+ const other = await realpath(await requireGit(["rev-parse", "--path-format=absolute", "--git-common-dir"], info.path, signal));
65
+ if (common !== other)
66
+ throw new Error("Owned worktree belongs to a different repository.");
67
+ }
68
+ async function exists(path) {
69
+ try {
70
+ await lstat(path);
71
+ return true;
72
+ }
73
+ catch (error) {
74
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT")
75
+ return false;
76
+ throw error;
77
+ }
78
+ }
79
+ let inspector; // Lazy: schema -> index -> runner -> integration-git -> git-runtime.
80
+ async function inspect(path, signal) {
81
+ return await (inspector ??= new CheckedGitRuntime()).inspectMain({ root: path }, { signal, deadline: Date.now() + 30_000, timeoutMs: 30_000 });
82
+ }
83
+ async function current(root, info, signal) {
84
+ await owned(root, info, signal);
85
+ const identity = await inspect(info.path, signal);
86
+ if (identity.branch !== `refs/heads/${info.branch}`)
87
+ throw new Error("Owned worktree switched branch.");
88
+ const tip = await requireGit(["rev-parse", "--verify", `refs/heads/${info.branch}^{commit}`], root, signal);
89
+ if (identity.head !== tip)
90
+ throw new Error("Owned branch differs from checked-out HEAD.");
91
+ return identity;
92
+ }
93
+ async function parents(commit, root, signal) {
94
+ const line = await requireGit(["rev-list", "--parents", "-n", "1", commit], root, signal);
95
+ const ids = line.split(" ");
96
+ if (ids[0] !== commit || !ids.every((id) => oidPattern.test(id)))
97
+ throw new Error("Git returned malformed merge ancestry.");
98
+ return ids.slice(1);
99
+ }
100
+ // Git's merge normally updates the checked-out HEAD and ref through a reference
101
+ // transaction. Reject any unexpected ref movement *inside* that transaction.
102
+ function refGuard(branch, from, to) {
103
+ return `#!/usr/bin/env node
104
+ const {readFileSync} = require("node:fs");
105
+ const branch = ${JSON.stringify(branch)}, from = ${JSON.stringify(from)}, to = ${JSON.stringify(to)};
106
+ const state = process.argv[2];
107
+ if (state !== "preparing" && state !== "prepared") process.exit(0);
108
+ function reject() { process.stderr.write("pi-subagent: promotion ref drift\\n"); process.exit(1); }
109
+ const input = readFileSync(0, "utf8");
110
+ if (!input.endsWith("\\n")) reject();
111
+ const lines = input.slice(0, -1).split("\\n").map(line => {
112
+ const parts = line.split(" ");
113
+ if (parts.length !== 3) reject();
114
+ return parts;
115
+ });
116
+ if (new Set(lines.map(line => line[2])).size !== lines.length) reject();
117
+ if (lines.some(([old, next, ref]) => !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(old)
118
+ || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(next)
119
+ || (ref !== "HEAD" && ref !== "ORIG_HEAD" && ref !== "AUTO_MERGE" && !ref.startsWith("refs/")))) reject();
120
+ if (lines.some(([old, next, ref]) => ref === "ORIG_HEAD" && next !== from)) reject();
121
+ const moving = lines.filter(line => line[2] === "HEAD" || line[2].startsWith("refs/"));
122
+ const exact = (line, ref) => line[0] === from && line[1] === to && line[2] === ref;
123
+ if (!moving.length) process.exit(0);
124
+ if (state === "preparing") {
125
+ if (moving.length !== 1 || !exact(moving[0], "HEAD")) reject();
126
+ } else if (!(moving.length === 1 && exact(moving[0], branch)
127
+ || moving.length === 2 && moving.some(line => exact(line, "HEAD"))
128
+ && moving.some(line => exact(line, branch)))) reject();
129
+ `;
130
+ }
131
+ async function proveHistory(root, base, stages, branch, signal) {
132
+ let previous = { ...base, branch };
133
+ for (const stage of stages) {
134
+ const ancestry = await parents(stage.tip.head, root, signal);
135
+ if (!sameIdentity(stage.previous, previous) || !oidPattern.test(stage.worker.head)
136
+ || stage.worker.index !== stage.worker.tree
137
+ || stage.worker.head === previous.head
138
+ || !await sharedHistory(base.head, stage.worker.head, root, signal)
139
+ || stage.tip.branch !== branch || stage.tip.index !== stage.tip.tree
140
+ || ancestry.length !== 2 || ancestry[0] !== previous.head || ancestry[1] !== stage.worker.head) {
141
+ throw new Error("Integration stage provenance differs from its recorded first-parent/worker merge.");
142
+ }
143
+ previous = stage.tip;
144
+ }
145
+ return previous;
146
+ }
147
+ export class IntegrationGit {
148
+ /** Refresh is allowed only for the exact clean checked-out Main and a strict same-branch descendant. */
149
+ async inspectMainAdvance(root, from, to, signal) {
150
+ if (from.branch !== to.branch || from.head === to.head || from.index !== from.tree || to.index !== to.tree
151
+ || !sameIdentity(await inspect(root, signal), to) || !await ancestor(from.head, to.head, root, signal)) {
152
+ throw new Error("Main refresh requires the exact clean same-branch descendant of recorded Main.");
153
+ }
154
+ }
155
+ /** Persist the prepared path/branch/base before Git is allowed to create anything. */
156
+ async allocate(root, childId, base, onPrepared, signal) {
157
+ if (!sameIdentity(await inspect(root, signal), base))
158
+ return { outcome: "drift", failure: "Main drifted before integration allocation." };
159
+ let prepared;
160
+ try {
161
+ const added = await createChildWorktree(root, childId, runGit, signal, async (info) => {
162
+ prepared = info;
163
+ if (info.baseCommit !== base.head)
164
+ throw new Error("Integration base differs from recorded Main.");
165
+ await onPrepared(info);
166
+ if (!sameIdentity(await inspect(root, signal), base))
167
+ throw new Error("Main drifted before worktree add.");
168
+ });
169
+ if (!added)
170
+ return { outcome: "blocked", failure: "Integration requires committed Git Main." };
171
+ if (!sameIdentity(await inspect(root, signal), base))
172
+ return unknown("Main drifted during worktree allocation; retain the worktree.", added);
173
+ await current(root, added, signal);
174
+ return { outcome: "ready", value: added };
175
+ }
176
+ catch (error) {
177
+ if (error instanceof WorktreeSetupError)
178
+ return unknown(error.message, error.worktree);
179
+ if (prepared)
180
+ return unknown(`Allocation could not be proved: ${String(error)}`, prepared);
181
+ throw error;
182
+ }
183
+ }
184
+ /** Prove each recorded merge before any new merge or final gate. */
185
+ async inspectCombined(root, integration, base, stages, signal) {
186
+ if (integration.baseCommit !== base.head)
187
+ throw new Error("Integration worktree has a different base.");
188
+ const tip = await current(root, integration, signal);
189
+ const previous = await proveHistory(root, base, stages, tip.branch, signal);
190
+ if (!sameIdentity(tip, previous))
191
+ throw new Error("Integration tip drifted from recorded stages.");
192
+ return tip;
193
+ }
194
+ /** Main's correction is one exact committed child of the checked staged tip. */
195
+ async inspectCorrection(root, integration, base, stages, from, to, signal) {
196
+ const staged = await proveHistory(root, base, stages, `refs/heads/${integration.branch}`, signal);
197
+ if (!sameIdentity(staged, from) || !sameIdentity(await current(root, integration, signal), to)
198
+ || to.head === from.head || (await parents(to.head, root, signal)).join() !== from.head) {
199
+ throw new Error("Main correction must be one clean committed child of the exact staged tip.");
200
+ }
201
+ }
202
+ /** Main orders calls. On conflict leave MERGE_HEAD and the index untouched for manual resolution. */
203
+ async stage(root, integration, base, stages, worker, candidate, signal) {
204
+ const previous = await this.inspectCombined(root, integration, base, stages, signal);
205
+ if (worker.path === integration.path || !(await ancestor(worker.baseCommit, base.head, root, signal)
206
+ || stages.some((stage) => stage.tip.head === worker.baseCommit))
207
+ || !sameIdentity(await current(root, worker, signal), candidate)
208
+ || candidate.head === worker.baseCommit || !await ancestor(worker.baseCommit, candidate.head, root, signal)
209
+ || await ancestor(candidate.head, previous.head, root, signal)) {
210
+ return { outcome: "blocked", failure: "Worker is not an unchanged independent committed candidate from the recorded base." };
211
+ }
212
+ const args = ["merge", "--no-ff", "--no-edit", "--no-autostash", "--no-overwrite-ignore", candidate.head];
213
+ let merged;
214
+ try {
215
+ merged = await git(args, integration.path, signal);
216
+ }
217
+ catch (error) {
218
+ return unknown(`Merge outcome uncertain: ${String(error)}`, integration);
219
+ }
220
+ if (merged.code !== 0) {
221
+ if (merged.code !== -1) {
222
+ try {
223
+ const mergeHead = await git(["rev-parse", "--verify", "MERGE_HEAD"], integration.path, signal);
224
+ if (mergeHead.code === 0 && mergeHead.stdout.trim() === candidate.head) {
225
+ return { outcome: "conflict", failure: "Merge needs Main's resolution in the retained integration worktree.", possibleResources: [integration.path, integration.branch] };
226
+ }
227
+ }
228
+ catch { /* Retain the uncertain merge. */ }
229
+ }
230
+ return unknown(failure(args, merged), integration);
231
+ }
232
+ return await this.confirmStage(root, integration, base, stages, worker, candidate, signal);
233
+ }
234
+ /** After manual conflict resolution: only the exact two-parent merge is a valid receipt. */
235
+ async confirmStage(root, integration, base, stages, worker, candidate, signal) {
236
+ try {
237
+ const previous = stages.at(-1)?.tip ?? { ...base, branch: `refs/heads/${integration.branch}` };
238
+ if (worker.path === integration.path || !(await ancestor(worker.baseCommit, base.head, root, signal)
239
+ || stages.some((stage) => stage.tip.head === worker.baseCommit))
240
+ || candidate.head === worker.baseCommit || !await ancestor(worker.baseCommit, candidate.head, root, signal)
241
+ || !sameIdentity(await current(root, worker, signal), candidate)) {
242
+ throw new Error("Worker candidate changed or does not descend from the recorded base.");
243
+ }
244
+ await owned(root, integration, signal);
245
+ const tip = await current(root, integration, signal);
246
+ const ancestry = await parents(tip.head, root, signal);
247
+ if (ancestry.length !== 2 || ancestry[0] !== previous.head || ancestry[1] !== candidate.head)
248
+ throw new Error("Resolution is not the exact expected two-parent merge.");
249
+ const receipt = { previous, worker: candidate, tip };
250
+ await this.inspectCombined(root, integration, base, [...stages, receipt], signal);
251
+ return { outcome: "ready", value: receipt };
252
+ }
253
+ catch (error) {
254
+ return unknown(`Stage resolution could not be proved: ${String(error)}`, integration);
255
+ }
256
+ }
257
+ /** Reconcile an interrupted merge without retrying it or discarding conflict state. */
258
+ async reconcileStage(root, integration, base, stages, worker, candidate, signal) {
259
+ try {
260
+ if (!sameIdentity(await current(root, worker, signal), candidate))
261
+ throw new Error("Worker candidate drifted before stage reconciliation.");
262
+ await owned(root, integration, signal);
263
+ const previous = await proveHistory(root, base, stages, `refs/heads/${integration.branch}`, signal);
264
+ const head = await requireGit(["rev-parse", "HEAD"], integration.path, signal);
265
+ const branch = await requireGit(["symbolic-ref", "HEAD"], integration.path, signal);
266
+ if (branch !== previous.branch)
267
+ throw new Error("Integration branch changed.");
268
+ const mergeHead = await git(["rev-parse", "--verify", "MERGE_HEAD"], integration.path, signal);
269
+ if (mergeHead.code !== 0 && mergeHead.code !== 128)
270
+ throw new Error("Merge state could not be inspected.");
271
+ if (head === previous.head && mergeHead.code === 0 && mergeHead.stdout.trim() === candidate.head) {
272
+ return { outcome: "conflict", failure: "Exact merge remains pending Main resolution.", possibleResources: [integration.path, integration.branch] };
273
+ }
274
+ if (head === previous.head && mergeHead.code !== 0) {
275
+ await this.inspectCombined(root, integration, base, stages, signal);
276
+ return { outcome: "ready", value: "not_started" };
277
+ }
278
+ if (mergeHead.code !== 0)
279
+ return await this.confirmStage(root, integration, base, stages, worker, candidate, signal);
280
+ throw new Error("Merge state differs from the recorded stage intent.");
281
+ }
282
+ catch (error) {
283
+ return unknown(`Interrupted stage outcome uncertain: ${String(error)}`, integration);
284
+ }
285
+ }
286
+ async inspectWorker(root, worker, candidate, signal) {
287
+ if (!sameIdentity(await current(root, worker, signal), candidate))
288
+ throw new Error("Staged worker candidate changed after selection.");
289
+ }
290
+ /** Read-only reconciliation of a lost promotion response; never repeat the merge. */
291
+ async reconcilePromotion(root, integration, base, stages, signal, correction) {
292
+ try {
293
+ const tip = correction ? (await this.inspectCorrection(root, integration, base, stages, correction.from, correction.to, signal), correction.to)
294
+ : await this.inspectCombined(root, integration, base, stages, signal);
295
+ const main = await inspect(root, signal);
296
+ if (main.branch === base.branch && main.head === tip.head && sameIdentity(main, { ...tip, branch: base.branch })) {
297
+ return { outcome: "ready", value: main };
298
+ }
299
+ return { outcome: "unknown", failure: sameIdentity(main, base)
300
+ ? "Promotion did not change Main; intent is retained and cannot be replayed."
301
+ : "Main differs from both the expected base and the promoted tip; manual recovery required.",
302
+ possibleResources: [integration.path, integration.branch] };
303
+ }
304
+ catch (error) {
305
+ return unknown(`Promotion reconciliation uncertain: ${String(error)}`, integration);
306
+ }
307
+ }
308
+ /** Checks/review must be persisted for this exact combined tip; never move Main via update-ref. */
309
+ async promote(input, signal) {
310
+ const { root, integration, base, stages } = input;
311
+ if (!stages.length)
312
+ return { outcome: "blocked", failure: "No worker stages to promote." };
313
+ const tip = input.correction ? (await this.inspectCorrection(root, integration, base, stages, input.correction.from, input.correction.to, signal), input.correction.to)
314
+ : await this.inspectCombined(root, integration, base, stages, signal);
315
+ if (input.checks.phase !== "final" || !checkBatchPasses(input.checks, input.commands, tip)
316
+ || (input.criterion !== undefined && !reviewEvidencePasses(input.review, "final", input.criterion, base, tip))
317
+ || (input.criterion === undefined && input.review !== undefined)) {
318
+ return { outcome: "blocked", failure: "Exact combined-tip final checks/review evidence is missing or failed." };
319
+ }
320
+ if (!sameIdentity(await inspect(root, signal), base))
321
+ return { outcome: "drift", failure: "Main drifted before promotion." };
322
+ const hookDir = await mkdtemp(join(tmpdir(), "pi-subagent-promote-"));
323
+ const args = ["-c", `core.hooksPath=${hookDir}`, "merge", "--ff-only", "--no-autostash", "--no-overwrite-ignore", tip.head];
324
+ let merged;
325
+ try {
326
+ await writeFile(join(hookDir, "reference-transaction"), refGuard(base.branch, base.head, tip.head));
327
+ await chmod(join(hookDir, "reference-transaction"), 0o700);
328
+ merged = await git(args, root, signal);
329
+ }
330
+ catch (error) {
331
+ return { outcome: "unknown", failure: `Promotion outcome uncertain: ${String(error)}`, possibleResources: [integration.path, integration.branch] };
332
+ }
333
+ finally {
334
+ await rm(hookDir, { recursive: true });
335
+ }
336
+ if (merged.code !== 0) {
337
+ if (merged.code === -1)
338
+ return { outcome: "unknown", failure: failure(args, merged), possibleResources: [integration.path, integration.branch] };
339
+ if (merged.stderr.includes("pi-subagent: promotion ref drift"))
340
+ return { outcome: "drift", failure: "Main drifted at the guarded ref transaction." };
341
+ try {
342
+ if (sameIdentity(await inspect(root, signal), base))
343
+ return { outcome: "blocked", failure: failure(args, merged) };
344
+ }
345
+ catch { /* The exact post-failure state is unknown. */ }
346
+ return { outcome: "unknown", failure: failure(args, merged), possibleResources: [integration.path, integration.branch] };
347
+ }
348
+ try {
349
+ const main = await inspect(root, signal);
350
+ if (main.branch === base.branch && main.head === tip.head && main.index === tip.index && main.tree === tip.tree)
351
+ return { outcome: "ready", value: main };
352
+ }
353
+ catch { /* Retain all resources when verification fails. */ }
354
+ return { outcome: "unknown", failure: "Promotion returned success but exact clean Main tip could not be proved.", possibleResources: [integration.path, integration.branch] };
355
+ }
356
+ /** Explicit release of an unselected clean checkout. The branch deletion is an exact ref compare-and-swap. */
357
+ async release(root, info, expected, kind, signal) {
358
+ const possibleResources = [info.path, info.branch];
359
+ try {
360
+ await ownershipMetadata(root, info, signal);
361
+ if (expected.branch !== `refs/heads/${info.branch}` || expected.index !== expected.tree
362
+ || !await ancestor(info.baseCommit, expected.head, root, signal)) {
363
+ return { outcome: "blocked", failure: "Release lacks an exact clean owned branch and base lineage." };
364
+ }
365
+ const registered = await requireGit(["worktree", "list", "--porcelain", "-z"], root, signal);
366
+ const present = registered.split("\0").includes(`worktree ${info.path}`);
367
+ const pathPresent = await exists(info.path);
368
+ const ref = `refs/heads/${info.branch}`;
369
+ if (!present && registered.split("\0").includes(`branch ${ref}`)) {
370
+ return { outcome: "blocked", failure: "Owned branch is checked out in a different registered worktree." };
371
+ }
372
+ const branch = await git(["rev-parse", "--verify", ref], root, signal);
373
+ if (branch.code !== 0 && branch.code !== 128)
374
+ throw new Error("Owned branch tip could not be inspected.");
375
+ if (kind === "worktree") {
376
+ if (!present && !pathPresent)
377
+ return { outcome: "ready", value: "absent" };
378
+ if (!present || !pathPresent || branch.stdout.trim() !== expected.head
379
+ || !sameIdentity(await current(root, info, signal), expected)) {
380
+ return { outcome: "blocked", failure: "Owned checkout, registration, branch or clean candidate changed." };
381
+ }
382
+ const dirty = await inspectWorktreeDirty(info.path, (args, cwd) => git(args, cwd, signal));
383
+ if (dirty.failure || dirty.dirty)
384
+ return { outcome: "blocked", failure: "Owned checkout has ignored, untracked or tracked changes; release preserves them." };
385
+ const args = ["worktree", "remove", info.path];
386
+ const removed = await git(args, root, signal);
387
+ if (removed.code !== 0)
388
+ return unknown(failure(args, removed), info);
389
+ const after = await requireGit(["worktree", "list", "--porcelain", "-z"], root, signal);
390
+ if (await exists(info.path) || after.split("\0").includes(`worktree ${info.path}`))
391
+ return unknown("Worktree removal returned success but checkout remains.", info);
392
+ return { outcome: "ready", value: "removed" };
393
+ }
394
+ if (present || pathPresent)
395
+ return { outcome: "blocked", failure: "Release the exact checkout before branch deletion." };
396
+ if (branch.code === 128)
397
+ return { outcome: "ready", value: "absent" };
398
+ if (branch.stdout.trim() !== expected.head)
399
+ return { outcome: "blocked", failure: "Owned branch differs from the approved tip." };
400
+ const args = ["update-ref", "-d", ref, expected.head];
401
+ const deleted = await git(args, root, signal);
402
+ if (deleted.code !== 0)
403
+ return unknown(failure(args, deleted), info);
404
+ const after = await git(["show-ref", "--verify", "--quiet", ref], root, signal);
405
+ if (after.code !== 1)
406
+ return unknown("Ref deletion returned success but absence could not be proved.", info);
407
+ return { outcome: "ready", value: "removed" };
408
+ }
409
+ catch (error) {
410
+ return { outcome: "unknown", failure: `Release state uncertain: ${String(error)}`, possibleResources };
411
+ }
412
+ }
413
+ /** Cleanup is deliberately separate per resource; callers persist each proven substep. */
414
+ async cleanup(root, integration, info, base, stages, promoted, kind, signal, correction) {
415
+ const possibleResources = [info.path, info.branch];
416
+ try {
417
+ await ownershipMetadata(root, info, signal);
418
+ await ownershipMetadata(root, integration, signal);
419
+ if (!stages.length || !sameIdentity(await inspect(root, signal), promoted)
420
+ || promoted.branch !== base.branch || promoted.head !== (correction?.to ?? stages.at(-1).tip).head) {
421
+ return { outcome: "blocked", failure: "Cleanup requires the unchanged exact promoted Main tip." };
422
+ }
423
+ await proveHistory(root, base, stages, `refs/heads/${integration.branch}`, signal);
424
+ if (correction) {
425
+ if (!sameIdentity(correction.from, stages.at(-1).tip)
426
+ || (await parents(correction.to.head, root, signal)).join() !== correction.from.head) {
427
+ return { outcome: "blocked", failure: "Correction provenance changed before cleanup." };
428
+ }
429
+ if (kind === "worktree" && info.path === integration.path) {
430
+ await this.inspectCorrection(root, integration, base, stages, correction.from, correction.to, signal);
431
+ }
432
+ }
433
+ const stageIndex = stages.findIndex((stage) => stage.worker.branch === `refs/heads/${info.branch}`);
434
+ const expected = info.path === integration.path ? correction?.to ?? stages.at(-1).tip
435
+ : stages[stageIndex]?.worker;
436
+ const stagedBase = info.path === integration.path ? info.baseCommit === base.head
437
+ : await ancestor(info.baseCommit, base.head, root, signal)
438
+ || stages.slice(0, stageIndex).some((stage) => stage.tip.head === info.baseCommit);
439
+ if (!expected || !stagedBase || !await ancestor(info.baseCommit, expected.head, root, signal)) {
440
+ return { outcome: "blocked", failure: "No proven staged base and candidate for this owned checkout." };
441
+ }
442
+ const registered = await requireGit(["worktree", "list", "--porcelain", "-z"], root, signal);
443
+ const present = registered.split("\0").includes(`worktree ${info.path}`);
444
+ const pathPresent = await exists(info.path);
445
+ const ref = `refs/heads/${info.branch}`;
446
+ const branch = await git(["rev-parse", "--verify", ref], root, signal);
447
+ if (branch.code !== 0 && branch.code !== 128)
448
+ throw new Error("Branch tip could not be inspected.");
449
+ if (kind === "worktree") {
450
+ if (!present && !pathPresent)
451
+ return { outcome: "ready", value: "absent" };
452
+ if (!present || !pathPresent || branch.stdout.trim() !== expected.head)
453
+ return { outcome: "blocked", failure: "Worktree registration/path/branch do not match approved tip." };
454
+ if (!sameIdentity(await current(root, info, signal), expected))
455
+ return { outcome: "blocked", failure: "Worktree differs from approved clean candidate." };
456
+ const args = ["worktree", "remove", info.path];
457
+ const removed = await git(args, root, signal);
458
+ if (removed.code !== 0)
459
+ return unknown(failure(args, removed), info);
460
+ const after = await requireGit(["worktree", "list", "--porcelain", "-z"], root, signal);
461
+ if (await exists(info.path) || after.split("\0").includes(`worktree ${info.path}`))
462
+ return unknown("Worktree remove returned success but checkout remains.", info);
463
+ return { outcome: "ready", value: "removed" };
464
+ }
465
+ if (present || pathPresent)
466
+ return { outcome: "blocked", failure: "Remove the exact worktree before branch deletion." };
467
+ if (branch.code === 128)
468
+ return { outcome: "ready", value: "absent" };
469
+ if (branch.stdout.trim() !== expected.head)
470
+ return { outcome: "blocked", failure: "Branch differs from approved tip." };
471
+ const args = ["branch", "-d", "--", info.branch];
472
+ const deleted = await git(args, root, signal);
473
+ if (deleted.code !== 0)
474
+ return unknown(failure(args, deleted), info);
475
+ const after = await git(["show-ref", "--verify", "--quiet", ref], root, signal);
476
+ if (after.code !== 1)
477
+ return unknown("Branch delete returned success but ref absence could not be proved.", info);
478
+ return { outcome: "ready", value: "removed" };
479
+ }
480
+ catch (error) {
481
+ return { outcome: "unknown", failure: `Cleanup state uncertain: ${String(error)}`, possibleResources };
482
+ }
483
+ }
484
+ }
@@ -0,0 +1,54 @@
1
+ import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { type EphemeralSubagentExecutionBudget } from "./ephemeral.ts";
3
+ import type { CoordinatorRuntime, OperationContext, TransientLaunchHandle, VerifiedLaunch } from "./runner.ts";
4
+ import { type ExecuteRequest, type ModelClass, type WorkspaceIdentity } from "./schema.ts";
5
+ export declare const ISOLATED_MODEL_TASK: {
6
+ readonly id: "pi-subagent/isolatedRoleLaunch";
7
+ readonly label: "Isolated Role launch";
8
+ readonly purpose: "Resolve one task-scoped isolated Role launch.";
9
+ readonly defaultProfile: "balanced";
10
+ };
11
+ type LaunchPi = Pick<ExtensionAPI, "events" | "getCommands">;
12
+ export interface LaunchRuntimeOptions {
13
+ pi: LaunchPi;
14
+ context(): ExtensionContext;
15
+ resolveRoot(cwd: string, context: OperationContext): Promise<string>;
16
+ preflightHost?(input: {
17
+ request: ExecuteRequest;
18
+ cwd: string;
19
+ root: string;
20
+ }, context: OperationContext): Promise<void>;
21
+ inspectMain(input: {
22
+ root: string;
23
+ }, context: OperationContext): Promise<WorkspaceIdentity>;
24
+ executionBudget?: () => Omit<EphemeralSubagentExecutionBudget, "startedAt">;
25
+ now?: () => number;
26
+ randomToken?: () => string;
27
+ }
28
+ export declare function materializeTransientLaunch<Launch extends {
29
+ readonly args: readonly string[];
30
+ readonly role: string;
31
+ }>(prepared: {
32
+ launch: Launch;
33
+ prompt: string;
34
+ promptArgIndex: number;
35
+ }, signal?: AbortSignal): Promise<{
36
+ readonly launch: Launch;
37
+ cleanup(): Promise<void>;
38
+ }>;
39
+ export declare class RoleLaunchRuntime implements CoordinatorRuntime {
40
+ private readonly options;
41
+ constructor(options: LaunchRuntimeOptions);
42
+ now(): number;
43
+ randomToken(): string;
44
+ private prepareLaunch;
45
+ preflight(input: {
46
+ request: ExecuteRequest;
47
+ cwd: string;
48
+ }, context: OperationContext): Promise<{
49
+ root: string;
50
+ main: WorkspaceIdentity;
51
+ }>;
52
+ acquireLaunch(role: string, modelClass: ModelClass, context: OperationContext): Promise<TransientLaunchHandle<VerifiedLaunch>>;
53
+ }
54
+ export {};