@henryqw/pi-subagent 18.0.1 → 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 +14 -6
  47. package/skills/pi-subagent/SKILL.md +35 -0
@@ -0,0 +1,50 @@
1
+ import { type RunState } from "./schema.ts";
2
+ declare const productiveRunLeaseBrand: unique symbol;
3
+ export interface ProductiveRunLease {
4
+ readonly [productiveRunLeaseBrand]: true;
5
+ }
6
+ export type LifecycleLockOptions = {
7
+ readonly purpose?: "productive";
8
+ readonly productiveRunLease?: ProductiveRunLease;
9
+ } | {
10
+ readonly purpose: "status" | "abort";
11
+ readonly productiveRunLease?: never;
12
+ };
13
+ export interface LifecycleLock {
14
+ readonly productiveRunLeaseActive: boolean;
15
+ }
16
+ export declare class RunStateHandle {
17
+ state: RunState;
18
+ readonly path: string;
19
+ private writes;
20
+ private readonly root;
21
+ private readonly requestId;
22
+ private readonly replace;
23
+ constructor(state: RunState, path: string, replace: (contents: string) => Promise<void>);
24
+ save(): Promise<void>;
25
+ }
26
+ export declare class FileRunStore {
27
+ private readonly agentDir?;
28
+ private readonly onStateSaved?;
29
+ private readonly productiveRunLeases;
30
+ constructor(agentDir?: string, onStateSaved?: (state: RunState) => void);
31
+ stateDirectory(root: string): string;
32
+ statePath(root: string, id: string): string;
33
+ private lockPath;
34
+ private productiveRunPath;
35
+ private assertSafeDestination;
36
+ withProductiveRunLease<T>(root: string, operation: (lease: ProductiveRunLease) => Promise<T>): Promise<T>;
37
+ hasProductiveRunLease(root: string): Promise<boolean>;
38
+ withLock<T>(root: string, operation: (lifecycle: LifecycleLock) => Promise<T>, options?: LifecycleLockOptions): Promise<T>;
39
+ create(state: RunState): Promise<RunStateHandle>;
40
+ load(root: string, id: string): Promise<RunStateHandle>;
41
+ private stateEntries;
42
+ /** Read only version and request status; never migrate or interpret v4 worker evidence. */
43
+ assertLegacyAdmissionSafe(root: string): Promise<void>;
44
+ list(root: string): Promise<{
45
+ states: RunStateHandle[];
46
+ invalidIds: string[];
47
+ }>;
48
+ private handle;
49
+ }
50
+ export {};
package/dist/store.js ADDED
@@ -0,0 +1,288 @@
1
+ import { createHash } from "node:crypto";
2
+ import { realpathSync } from "node:fs";
3
+ import { mkdir, open, readdir, realpath } from "node:fs/promises";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { extensionConfigDir, readTextFileBounded, writePrivateTextFileAtomically } from "@henryqw/pi-config-store";
6
+ import { check, lock } from "proper-lockfile";
7
+ import { parseRunState, RUN_STATE_VERSION } from "./schema.js";
8
+ const INITIAL_STATE_MAX_BYTES = 2 * 1024 * 1024;
9
+ /*
10
+ * create() caps the complete initial serialization at 2 MiB. Loaded requests
11
+ * are independently bounded by parseExecuteRequest.
12
+ *
13
+ * Saves validate and reject atomically instead of dropping valid evidence.
14
+ * Reads use the same finite ceiling and preserve rejected files.
15
+ */
16
+ const STATE_MAX_BYTES = 128 * 1024 * 1024;
17
+ const ADMISSION_MAX_FILES = 256;
18
+ const ADMISSION_MAX_BYTES = STATE_MAX_BYTES;
19
+ const V4_TERMINAL_STATUSES = new Set(["completed", "final_failed", "superseded", "aborted"]);
20
+ const V4_UNFINISHED_STATUSES = new Set(["pending", "running", "needs_attention"]);
21
+ const LOCK_OPTIONS = { realpath: false, stale: 30_000, update: 5_000, retries: 0 };
22
+ const LIFECYCLE_LOCK_OPTIONS = {
23
+ ...LOCK_OPTIONS,
24
+ retries: { forever: true, factor: 1, minTimeout: 25, maxTimeout: 25 },
25
+ };
26
+ const productiveRunLeaseBrand = Symbol("productiveRunLease");
27
+ function isMissing(error) {
28
+ return Boolean(error && typeof error === "object" && error.code === "ENOENT");
29
+ }
30
+ function isAlreadyPresent(error) {
31
+ return Boolean(error && typeof error === "object" && error.code === "EEXIST");
32
+ }
33
+ function isLocked(error) {
34
+ return Boolean(error && typeof error === "object" && error.code === "ELOCKED");
35
+ }
36
+ function isWithin(root, candidate) {
37
+ const fromRoot = relative(root, candidate);
38
+ return fromRoot === "" || (fromRoot !== ".." && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot));
39
+ }
40
+ async function canonicalPlannedPath(path) {
41
+ let ancestor = resolve(path);
42
+ const suffix = [];
43
+ for (;;) {
44
+ try {
45
+ return resolve(await realpath(ancestor), ...suffix.reverse());
46
+ }
47
+ catch (error) {
48
+ if (!isMissing(error))
49
+ throw error;
50
+ const parent = dirname(ancestor);
51
+ if (parent === ancestor)
52
+ throw error;
53
+ suffix.push(basename(ancestor));
54
+ ancestor = parent;
55
+ }
56
+ }
57
+ }
58
+ function serialize(state, maxBytes = STATE_MAX_BYTES) {
59
+ const validated = parseRunState(structuredClone(state));
60
+ const contents = `${JSON.stringify(validated, null, 2)}\n`;
61
+ if (Buffer.byteLength(contents, "utf8") > maxBytes) {
62
+ throw new Error(`pi-subagent state exceeds ${maxBytes} bytes.`);
63
+ }
64
+ return contents;
65
+ }
66
+ export class RunStateHandle {
67
+ state;
68
+ path;
69
+ writes = Promise.resolve();
70
+ root;
71
+ requestId;
72
+ replace;
73
+ constructor(state, path, replace) {
74
+ this.state = state;
75
+ this.path = path;
76
+ this.root = state.root;
77
+ this.requestId = state.request.id;
78
+ this.replace = replace;
79
+ }
80
+ async save() {
81
+ if (this.state.root !== this.root || this.state.request.id !== this.requestId) {
82
+ throw new Error("A state handle cannot change its owned repository or request ID.");
83
+ }
84
+ const contents = serialize(this.state);
85
+ this.writes = this.writes.then(async () => await this.replace(contents));
86
+ await this.writes;
87
+ }
88
+ }
89
+ export class FileRunStore {
90
+ agentDir;
91
+ onStateSaved;
92
+ productiveRunLeases = new WeakMap();
93
+ constructor(agentDir, onStateSaved) {
94
+ this.agentDir = agentDir;
95
+ this.onStateSaved = onStateSaved;
96
+ }
97
+ stateDirectory(root) {
98
+ const canonicalRoot = realpathSync.native(root);
99
+ const directory = join(extensionConfigDir("pi-subagent", this.agentDir), "state", createHash("sha256").update(canonicalRoot).digest("hex"));
100
+ if (isWithin(canonicalRoot, resolve(directory))) {
101
+ throw new Error("Pi Subagent state directory must be outside the Git workspace.");
102
+ }
103
+ return directory;
104
+ }
105
+ statePath(root, id) {
106
+ return join(this.stateDirectory(root), `${id}.json`);
107
+ }
108
+ lockPath(root) {
109
+ return join(this.stateDirectory(root), "lifecycle.lock");
110
+ }
111
+ productiveRunPath(root) {
112
+ return join(this.stateDirectory(root), "productive-run");
113
+ }
114
+ async assertSafeDestination(root, destination) {
115
+ if (isWithin(realpathSync.native(root), await canonicalPlannedPath(destination))) {
116
+ throw new Error("Pi Subagent state directory must be outside the Git workspace.");
117
+ }
118
+ }
119
+ async withProductiveRunLease(root, operation) {
120
+ const directory = this.stateDirectory(root);
121
+ await this.assertSafeDestination(root, directory);
122
+ await mkdir(directory, { recursive: true, mode: 0o700 });
123
+ const path = this.productiveRunPath(root);
124
+ let release;
125
+ try {
126
+ release = await lock(path, { ...LOCK_OPTIONS, lockfilePath: `${path}.lock` });
127
+ }
128
+ catch (error) {
129
+ if (isLocked(error)) {
130
+ throw new Error("Another Pi Subagent productive request is active in this repository.");
131
+ }
132
+ throw error;
133
+ }
134
+ const lease = { [productiveRunLeaseBrand]: true };
135
+ this.productiveRunLeases.set(lease, path);
136
+ try {
137
+ return await operation(lease);
138
+ }
139
+ finally {
140
+ this.productiveRunLeases.delete(lease);
141
+ await release();
142
+ }
143
+ }
144
+ async hasProductiveRunLease(root) {
145
+ const directory = this.stateDirectory(root);
146
+ await this.assertSafeDestination(root, directory);
147
+ const path = this.productiveRunPath(root);
148
+ return await check(path, { realpath: false, stale: LOCK_OPTIONS.stale, lockfilePath: `${path}.lock` });
149
+ }
150
+ async withLock(root, operation, options = {}) {
151
+ const directory = this.stateDirectory(root);
152
+ await this.assertSafeDestination(root, directory);
153
+ await mkdir(directory, { recursive: true, mode: 0o700 });
154
+ const path = this.lockPath(root);
155
+ const release = await lock(path, { ...LIFECYCLE_LOCK_OPTIONS, lockfilePath: `${path}.lock` });
156
+ try {
157
+ const productiveRunLeaseActive = await this.hasProductiveRunLease(root);
158
+ const ownedProductiveRunPath = options.productiveRunLease
159
+ ? this.productiveRunLeases.get(options.productiveRunLease)
160
+ : undefined;
161
+ if (productiveRunLeaseActive
162
+ && (options.purpose ?? "productive") === "productive"
163
+ && ownedProductiveRunPath !== this.productiveRunPath(root)) {
164
+ throw new Error("Another Pi Subagent productive request is active in this repository.");
165
+ }
166
+ const lifecycle = { productiveRunLeaseActive };
167
+ return await operation(lifecycle);
168
+ }
169
+ finally {
170
+ await release();
171
+ }
172
+ }
173
+ async create(state) {
174
+ const path = this.statePath(state.root, state.request.id);
175
+ await this.assertSafeDestination(state.root, path);
176
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
177
+ const contents = serialize(state, INITIAL_STATE_MAX_BYTES);
178
+ let file;
179
+ try {
180
+ file = await open(path, "wx", 0o600);
181
+ await file.writeFile(contents, "utf8");
182
+ await file.sync();
183
+ await file.close();
184
+ file = undefined;
185
+ }
186
+ catch (error) {
187
+ await file?.close();
188
+ if (isAlreadyPresent(error))
189
+ throw new Error(`Pi Subagent request ${state.request.id} already exists.`);
190
+ throw error;
191
+ }
192
+ const saved = parseRunState(structuredClone(state));
193
+ this.onStateSaved?.(structuredClone(saved));
194
+ return this.handle(saved, path);
195
+ }
196
+ async load(root, id) {
197
+ const path = this.statePath(root, id);
198
+ let raw;
199
+ try {
200
+ raw = await readTextFileBounded(path, STATE_MAX_BYTES);
201
+ }
202
+ catch (error) {
203
+ if (error instanceof Error && error.message === `Text file exceeds ${STATE_MAX_BYTES} bytes: ${path}`) {
204
+ throw new Error(`pi-subagent state exceeds ${STATE_MAX_BYTES} bytes.`);
205
+ }
206
+ throw error;
207
+ }
208
+ const state = parseRunState(JSON.parse(raw));
209
+ if (state.root !== realpathSync.native(root) || state.request.id !== id) {
210
+ throw new Error("pi-subagent state identity does not match its repository and filename.");
211
+ }
212
+ return this.handle(state, path);
213
+ }
214
+ async stateEntries(root) {
215
+ try {
216
+ return (await readdir(this.stateDirectory(root), { withFileTypes: true }))
217
+ .filter((item) => item.name.endsWith(".json"))
218
+ .sort((a, b) => a.name.localeCompare(b.name));
219
+ }
220
+ catch (error) {
221
+ if (isMissing(error))
222
+ return [];
223
+ throw error;
224
+ }
225
+ }
226
+ /** Read only version and request status; never migrate or interpret v4 worker evidence. */
227
+ async assertLegacyAdmissionSafe(root) {
228
+ const directory = this.stateDirectory(root);
229
+ await this.assertSafeDestination(root, directory);
230
+ const entries = await this.stateEntries(root);
231
+ if (entries.length > ADMISSION_MAX_FILES) {
232
+ throw new Error(`Pi Subagent cannot inventory more than ${ADMISSION_MAX_FILES} state files for safe admission; recover older requests with their owning version first.`);
233
+ }
234
+ let remainingBytes = ADMISSION_MAX_BYTES;
235
+ for (const entry of entries) {
236
+ const id = entry.name.slice(0, -5);
237
+ const path = join(directory, entry.name);
238
+ const recovery = `State preserved at ${path}. Inspect and recover any retained workers with their compatible owner before starting new isolated work.`;
239
+ let value;
240
+ try {
241
+ if (!entry.isFile())
242
+ throw new Error("State is not a regular file.");
243
+ if (remainingBytes === 0)
244
+ throw new Error("Admission inventory byte limit reached.");
245
+ const raw = await readTextFileBounded(path, remainingBytes);
246
+ remainingBytes -= Buffer.byteLength(raw, "utf8");
247
+ value = JSON.parse(raw);
248
+ }
249
+ catch {
250
+ throw new Error(`Pi Subagent cannot read state ${id} for safe admission. ${recovery}`);
251
+ }
252
+ if (!value || typeof value !== "object" || Array.isArray(value) || !("version" in value)) {
253
+ throw new Error(`Pi Subagent malformed state ${id} blocks safe admission. ${recovery}`);
254
+ }
255
+ if (value.version === RUN_STATE_VERSION)
256
+ continue;
257
+ if (value.version !== 4 || !("status" in value) || typeof value.status !== "string"
258
+ || (!V4_TERMINAL_STATUSES.has(value.status) && !V4_UNFINISHED_STATUSES.has(value.status))) {
259
+ throw new Error(`Pi Subagent malformed or unsupported legacy state ${id} blocks safe admission. ${recovery}`);
260
+ }
261
+ if (V4_UNFINISHED_STATUSES.has(value.status)) {
262
+ throw new Error(`Pi Subagent unfinished v4 request ${id} (${value.status}) blocks new isolated work. ${recovery}`);
263
+ }
264
+ }
265
+ }
266
+ async list(root) {
267
+ const entries = await this.stateEntries(root);
268
+ const states = [];
269
+ const invalidIds = [];
270
+ for (const entry of entries.filter((item) => item.isFile())) {
271
+ const id = entry.name.slice(0, -5);
272
+ try {
273
+ states.push(await this.load(root, id));
274
+ }
275
+ catch {
276
+ invalidIds.push(id);
277
+ }
278
+ }
279
+ return { states, invalidIds };
280
+ }
281
+ handle(state, path) {
282
+ return new RunStateHandle(state, path, async (contents) => {
283
+ await this.assertSafeDestination(state.root, path);
284
+ await writePrivateTextFileAtomically(path, contents);
285
+ this.onStateSaved?.(parseRunState(JSON.parse(contents)));
286
+ });
287
+ }
288
+ }
@@ -0,0 +1,25 @@
1
+ export interface WorkingCheckoutState {
2
+ head: string;
3
+ indexHash: string;
4
+ rawManifestHash: string;
5
+ }
6
+ export interface WorkingSnapshotIdentity extends WorkingCheckoutState {
7
+ tree: string;
8
+ }
9
+ export interface WorkingCheckoutBaseline extends WorkingSnapshotIdentity {
10
+ worktree: string;
11
+ }
12
+ export interface PreparedWorkingChangeEvidence {
13
+ baseline: WorkingCheckoutBaseline;
14
+ identity: WorkingSnapshotIdentity;
15
+ changedPaths: string[];
16
+ patchPath: string;
17
+ rawEvidencePath: string;
18
+ cleanup(): Promise<void>;
19
+ }
20
+ /** Capture mutation-detection identity without requiring a clean checkout or constructing Git objects. */
21
+ export declare function captureWorkingCheckoutState(cwd: string, signal?: AbortSignal): Promise<WorkingCheckoutState>;
22
+ export declare function captureWorkingCheckoutBaseline(cwd: string, signal?: AbortSignal): Promise<WorkingCheckoutBaseline>;
23
+ export declare function prepareWorkingChangeEvidence(baseline: WorkingCheckoutBaseline, signal?: AbortSignal): Promise<PreparedWorkingChangeEvidence>;
24
+ export declare function sameWorkingCheckoutState(left: WorkingCheckoutState, right: WorkingCheckoutState): boolean;
25
+ export declare function sameWorkingSnapshot(left: WorkingSnapshotIdentity, right: WorkingSnapshotIdentity): boolean;
@@ -0,0 +1,261 @@
1
+ import { createHash } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { constants } from "node:fs";
4
+ import { chmod, copyFile, lstat, mkdir, mkdtemp, open, readFile, readlink, realpath, rm, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { isAbsolute, join, resolve } from "node:path";
7
+ import { inspectIndexFlags } from "./worktree.js";
8
+ import { runGit as runCapturedGit } from "./git-process.js";
9
+ import { REVIEW_MAX_PATCH_BYTES, REVIEW_MAX_PATHS } from "./review-evidence.js";
10
+ const GIT_TIMEOUT_MS = 30_000;
11
+ const OUTPUT_LIMIT = REVIEW_MAX_PATCH_BYTES + 1;
12
+ const MANIFEST_MAX_BYTES = 512 * 1024;
13
+ function hash(value) {
14
+ return createHash("sha256").update(value).digest("hex");
15
+ }
16
+ function line(value, field) {
17
+ const text = value.toString("utf8").replace(/\r?\n$/, "");
18
+ if (!text || text.includes("�") || /[\r\n\0]/.test(text))
19
+ throw new Error(`Git returned malformed ${field}.`);
20
+ return text;
21
+ }
22
+ function oid(value, field) {
23
+ const result = line(value, field);
24
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(result))
25
+ throw new Error(`Git returned invalid ${field}.`);
26
+ return result;
27
+ }
28
+ async function command(args, cwd, env = process.env, signal, limit = OUTPUT_LIMIT) {
29
+ signal?.throwIfAborted();
30
+ const child = spawn("git", args, { cwd, env, signal, timeout: GIT_TIMEOUT_MS, stdio: ["ignore", "pipe", "pipe"] });
31
+ const stdout = [];
32
+ const stderr = [];
33
+ let stdoutBytes = 0;
34
+ let stderrBytes = 0;
35
+ let overflow = false;
36
+ const collect = (chunks, chunk, current) => {
37
+ const available = limit - current;
38
+ if (available <= 0) {
39
+ overflow = true;
40
+ return 0;
41
+ }
42
+ chunks.push(chunk.subarray(0, available));
43
+ if (chunk.length > available)
44
+ overflow = true;
45
+ return Math.min(chunk.length, available);
46
+ };
47
+ child.stdout.on("data", (chunk) => { stdoutBytes += collect(stdout, chunk, stdoutBytes); if (overflow)
48
+ child.kill(); });
49
+ child.stderr.on("data", (chunk) => { stderrBytes += collect(stderr, chunk, stderrBytes); if (overflow)
50
+ child.kill(); });
51
+ const code = await new Promise((resolveCode, reject) => {
52
+ child.once("error", reject);
53
+ child.once("close", resolveCode);
54
+ });
55
+ signal?.throwIfAborted();
56
+ if (overflow)
57
+ throw new Error(`Working-change evidence exceeds ${limit - 1} bytes.`);
58
+ return { code: code ?? -1, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) };
59
+ }
60
+ async function git(args, cwd, env, signal) {
61
+ const result = await command(args, cwd, env, signal);
62
+ if (result.code !== 0) {
63
+ const detail = result.stderr.toString("utf8").slice(0, 200).trim();
64
+ throw new Error(`git ${args.join(" ")} failed with exit ${result.code}${detail ? `: ${detail}` : ""}`);
65
+ }
66
+ return result.stdout;
67
+ }
68
+ function paths(value) {
69
+ if (!value.length)
70
+ return [];
71
+ const text = value.toString("utf8");
72
+ if (text.includes("�") || value.at(-1) !== 0)
73
+ throw new Error("Git returned malformed working-change paths.");
74
+ const result = text.slice(0, -1).split("\0");
75
+ if (result.length > REVIEW_MAX_PATHS)
76
+ throw new Error(`Working-change evidence exceeds ${REVIEW_MAX_PATHS} paths.`);
77
+ for (const path of result) {
78
+ if (!path || path.includes("\0") || isAbsolute(path) || path.split(/[\\/]/).some((part) => !part || part === "." || part === "..")) {
79
+ throw new Error(`Git returned malformed or unsupported path: ${JSON.stringify(path)}`);
80
+ }
81
+ }
82
+ return result;
83
+ }
84
+ async function indexPath(worktree, signal) {
85
+ const raw = line(await git(["rev-parse", "--git-path", "index"], worktree, undefined, signal), "index path");
86
+ const path = isAbsolute(raw) ? raw : resolve(worktree, raw);
87
+ return await realpath(path);
88
+ }
89
+ async function repositoryObjects(worktree, signal) {
90
+ const raw = line(await git(["rev-parse", "--git-common-dir"], worktree, undefined, signal), "common Git directory");
91
+ const directory = isAbsolute(raw) ? raw : resolve(worktree, raw);
92
+ return await realpath(join(directory, "objects"));
93
+ }
94
+ async function assertNoExternalFilters(worktree, signal) {
95
+ const result = await command(["config", "--get-regexp", "^filter\\..*\\.(clean|process)$"], worktree, process.env, signal, 64 * 1024);
96
+ if (result.code === 1 && result.stdout.length === 0)
97
+ return;
98
+ if (result.code !== 0)
99
+ throw new Error("Could not inspect effective Git content filters.");
100
+ throw new Error("Checked direct changesets reject configured Git clean/process filters; use isolated mode or remove the filter configuration before retrying.");
101
+ }
102
+ async function assertSupportedIndex(worktree, signal) {
103
+ const flags = await inspectIndexFlags(worktree, runCapturedGit, signal);
104
+ if (flags.failure)
105
+ throw new Error(`Working-change index inspection failed: ${flags.failure}`);
106
+ if (flags.hidden)
107
+ throw new Error("Checked direct changesets reject assume-unchanged or skip-worktree index entries.");
108
+ const stage = (await git(["ls-files", "--stage", "-z"], worktree, undefined, signal)).toString("utf8");
109
+ if (stage.includes("�") || /(?:^|\0)160000 /.test(stage))
110
+ throw new Error("Checked direct changesets reject Git links.");
111
+ }
112
+ async function rawManifest(worktree, signal) {
113
+ const listed = paths(await git(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], worktree, undefined, signal));
114
+ const entries = [];
115
+ for (const path of [...new Set(listed)].sort()) {
116
+ signal?.throwIfAborted();
117
+ const absolute = join(worktree, path);
118
+ let info;
119
+ try {
120
+ info = await lstat(absolute);
121
+ }
122
+ catch (error) {
123
+ if (error.code === "ENOENT") {
124
+ entries.push({ path, kind: "absent" });
125
+ continue;
126
+ }
127
+ throw error;
128
+ }
129
+ if (info.isSymbolicLink()) {
130
+ const target = await readlink(absolute, { encoding: "buffer" });
131
+ entries.push({ path, kind: "symlink", mode: info.mode & 0o7777, size: target.length, hash: hash(target) });
132
+ }
133
+ else if (info.isFile()) {
134
+ const contents = await readFile(absolute);
135
+ entries.push({ path, kind: "file", mode: info.mode & 0o7777, size: contents.length, hash: hash(contents) });
136
+ }
137
+ else {
138
+ throw new Error(`Checked direct evidence rejects unsupported working path type: ${path}`);
139
+ }
140
+ }
141
+ const json = `${JSON.stringify(entries)}\n`;
142
+ if (Buffer.byteLength(json, "utf8") > MANIFEST_MAX_BYTES)
143
+ throw new Error(`Working-change raw manifest exceeds ${MANIFEST_MAX_BYTES} bytes.`);
144
+ return { json, digest: hash(json) };
145
+ }
146
+ async function actualIdentity(worktree, signal) {
147
+ const [head, index] = await Promise.all([
148
+ git(["rev-parse", "--verify", "HEAD"], worktree, undefined, signal),
149
+ readFile(await indexPath(worktree, signal)),
150
+ ]);
151
+ return { head: oid(head, "HEAD"), indexHash: hash(index) };
152
+ }
153
+ async function privateSnapshot(worktree, directory, signal) {
154
+ const privateIndex = join(directory, "index");
155
+ const objectDirectory = join(directory, "objects");
156
+ await mkdir(objectDirectory, { mode: 0o700 });
157
+ await copyFile(await indexPath(worktree, signal), privateIndex, constants.COPYFILE_EXCL);
158
+ await chmod(privateIndex, 0o600);
159
+ const env = {
160
+ ...process.env,
161
+ GIT_INDEX_FILE: privateIndex,
162
+ GIT_OBJECT_DIRECTORY: objectDirectory,
163
+ GIT_ALTERNATE_OBJECT_DIRECTORIES: await repositoryObjects(worktree, signal),
164
+ GIT_CONFIG_NOSYSTEM: "1",
165
+ };
166
+ await git(["add", "-A", "--", "."], worktree, env, signal);
167
+ const tree = oid(await git(["write-tree"], worktree, env, signal), "private working tree");
168
+ const manifest = await rawManifest(worktree, signal);
169
+ return { tree, manifest };
170
+ }
171
+ async function makeDirectory() {
172
+ const directory = await mkdtemp(join(tmpdir(), "pi-subagent-working-review-"));
173
+ await chmod(directory, 0o700);
174
+ let cleaning;
175
+ return { directory, cleanup: () => cleaning ??= rm(directory, { recursive: true, force: true }) };
176
+ }
177
+ async function capture(worktree, directory, signal) {
178
+ await assertNoExternalFilters(worktree, signal);
179
+ await assertSupportedIndex(worktree, signal);
180
+ const before = await actualIdentity(worktree, signal);
181
+ const snapshot = await privateSnapshot(worktree, directory, signal);
182
+ const afterManifest = await rawManifest(worktree, signal);
183
+ const after = await actualIdentity(worktree, signal);
184
+ if (before.head !== after.head || before.indexHash !== after.indexHash || snapshot.manifest.digest !== afterManifest.digest) {
185
+ throw new Error("Working checkout changed while direct evidence was being captured.");
186
+ }
187
+ return {
188
+ identity: { ...before, tree: snapshot.tree, rawManifestHash: snapshot.manifest.digest },
189
+ manifest: snapshot.manifest.json,
190
+ };
191
+ }
192
+ async function canonicalRoot(cwd, signal) {
193
+ const canonical = await realpath(cwd);
194
+ return await realpath(line(await git(["rev-parse", "--show-toplevel"], canonical, undefined, signal), "checkout root"));
195
+ }
196
+ /** Capture mutation-detection identity without requiring a clean checkout or constructing Git objects. */
197
+ export async function captureWorkingCheckoutState(cwd, signal) {
198
+ const worktree = await canonicalRoot(cwd, signal);
199
+ const before = await actualIdentity(worktree, signal);
200
+ const manifest = await rawManifest(worktree, signal);
201
+ const after = await actualIdentity(worktree, signal);
202
+ if (before.head !== after.head || before.indexHash !== after.indexHash) {
203
+ throw new Error("Working checkout changed while direct state was being captured.");
204
+ }
205
+ return { ...before, rawManifestHash: manifest.digest };
206
+ }
207
+ export async function captureWorkingCheckoutBaseline(cwd, signal) {
208
+ const worktree = await canonicalRoot(cwd, signal);
209
+ const branch = await command(["symbolic-ref", "-q", "HEAD"], worktree, process.env, signal, 64 * 1024);
210
+ if (branch.code !== 0)
211
+ throw new Error("Checked direct changesets require an attached branch.");
212
+ if ((await git(["status", "--porcelain=v1", "--untracked-files=all", "--ignore-submodules=none"], worktree, undefined, signal)).length) {
213
+ throw new Error("Checked direct changesets require a clean initial checkout; settle existing work or choose authorized isolated mode. No files were stashed, reset, or deleted.");
214
+ }
215
+ const owned = await makeDirectory();
216
+ try {
217
+ const captured = await capture(worktree, owned.directory, signal);
218
+ return { worktree, ...captured.identity };
219
+ }
220
+ finally {
221
+ await owned.cleanup();
222
+ }
223
+ }
224
+ export async function prepareWorkingChangeEvidence(baseline, signal) {
225
+ const owned = await makeDirectory();
226
+ try {
227
+ const captured = await capture(baseline.worktree, owned.directory, signal);
228
+ if (captured.identity.head !== baseline.head)
229
+ throw new Error("Checked direct changesets cannot move HEAD or create commits.");
230
+ const env = {
231
+ ...process.env,
232
+ GIT_OBJECT_DIRECTORY: join(owned.directory, "objects"),
233
+ GIT_ALTERNATE_OBJECT_DIRECTORIES: await repositoryObjects(baseline.worktree, signal),
234
+ GIT_CONFIG_NOSYSTEM: "1",
235
+ };
236
+ const raw = await git(["diff", "--no-ext-diff", "--no-textconv", "--ignore-submodules=none", "--raw", "-z", baseline.tree, captured.identity.tree], baseline.worktree, env, signal);
237
+ if (raw.toString("utf8").includes("160000"))
238
+ throw new Error("Checked direct changesets reject changed Git links.");
239
+ const changedPaths = paths(await git(["diff", "--no-ext-diff", "--no-textconv", "--ignore-submodules=none", "--name-only", "-z", baseline.tree, captured.identity.tree], baseline.worktree, env, signal));
240
+ const patch = await git(["--no-pager", "diff", "--no-ext-diff", "--no-textconv", "--ignore-submodules=none", "--binary", baseline.tree, captured.identity.tree], baseline.worktree, env, signal);
241
+ if (patch.length > REVIEW_MAX_PATCH_BYTES)
242
+ throw new Error(`Working-change evidence exceeds ${REVIEW_MAX_PATCH_BYTES} bytes.`);
243
+ const patchPath = join(owned.directory, "review.patch");
244
+ const rawEvidencePath = join(owned.directory, "raw-manifest.json");
245
+ const patchFile = await open(patchPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
246
+ await patchFile.writeFile(patch);
247
+ await patchFile.close();
248
+ await writeFile(rawEvidencePath, captured.manifest, { mode: 0o600, flag: "wx" });
249
+ return { baseline, identity: captured.identity, changedPaths, patchPath, rawEvidencePath, cleanup: owned.cleanup };
250
+ }
251
+ catch (error) {
252
+ await owned.cleanup();
253
+ throw error;
254
+ }
255
+ }
256
+ export function sameWorkingCheckoutState(left, right) {
257
+ return left.head === right.head && left.indexHash === right.indexHash && left.rawManifestHash === right.rawManifestHash;
258
+ }
259
+ export function sameWorkingSnapshot(left, right) {
260
+ return sameWorkingCheckoutState(left, right) && left.tree === right.tree;
261
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Decision
4
4
 
5
- The public task executor is an execution mechanism: it receives a prepared Pi Launch, runs one bounded Delegated Task, and returns the result. Main plans and orchestrates; `delegate_task` selects its flat `single`, `parallel`, or `chain` tool policy, while those modes are not executor primitives.
5
+ The public task executor is an execution mechanism: it receives a prepared Pi Launch, runs one bounded Delegated Task, and returns the result. The `delegate_task` extension composes it for direct workflows and isolated checked graphs; those modes are not executor primitives.
6
6
 
7
7
  Generic callers compose workflows with JavaScript. Fan-out and fan-in use promises and collections; sequencing uses ordinary control flow; review loops use explicit caller-owned bounds. The package does not define a recursive workflow AST.
8
8
 
@@ -21,8 +21,8 @@ An explicit call `modelClass` selects a task-model route. Without one, an option
21
21
 
22
22
  ## Scope boundary
23
23
 
24
- `delegate_task` and library callers remain generic. The package does not own checked implementation orchestration.
24
+ The executor remains generic and policy-free beyond child execution limits. Direct and isolated workflow policy belongs to the extension and checked runner, not the executor. External library callers may still compose the mechanism with their own protocols.
25
25
 
26
26
  ## Consequences
27
27
 
28
- The executor remains a stable mechanism while callers own semantic protocols, shared workspace and state, validation, review, retry decisions, integration, cleanup, and bounds.
28
+ The executor remains stable while pi-subagent owns its direct and checked-isolated semantics. Mechanism callers continue to own any protocol they build outside those surfaces.