pi-squad 0.16.6 → 0.17.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.
@@ -0,0 +1,451 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import type { ExtensionAPI, ExtensionEvent } from "@earendil-works/pi-coding-agent";
5
+ import type { TSchema } from "typebox";
6
+ import type { Squad, Task } from "./types.js";
7
+
8
+ export const SPEC_CHUNK_BYTES = 32_768;
9
+ export const MAX_SPEC_BYTES = 1_048_576;
10
+ const MAX_GOAL_BYTES = 65_536;
11
+ const MAX_TITLE_BYTES = 1_024;
12
+ const MAX_DESCRIPTION_BYTES = 131_072;
13
+ const MAX_EMBEDDED_BYTES = 524_288;
14
+ const MAX_ARTIFACT_PATH_BYTES = 32_768;
15
+ const HASH = /^[a-f0-9]{64}$/;
16
+ const TASK_ID = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
17
+ export const isFileSpecTaskId = (value: string): boolean => TASK_ID.test(value);
18
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
19
+ const ARTIFACT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
20
+ const THINKING = new Set([null, "off", "minimal", "low", "medium", "high", "xhigh", "max"]);
21
+ const BASE64_RUN = /(?:data:[^,;]+;base64,)?[A-Za-z0-9+/]{4097,}={0,2}/;
22
+ const sha256 = (value: Buffer): string => createHash("sha256").update(value).digest("hex");
23
+ const utf8Bytes = (value: string): number => Buffer.byteLength(value, "utf8");
24
+
25
+ export interface FileSpecTask {
26
+ id: string; title: string; description: string; agent: string;
27
+ depends: string[]; inheritContext: boolean; artifactRefs: string[];
28
+ }
29
+ export interface FileSpecArtifact {
30
+ id: string; path: string; sha256: string; bytes: number; purpose: string; mediaType?: string;
31
+ }
32
+ export interface FileSpec {
33
+ schemaVersion: 1;
34
+ goal: string;
35
+ tasks: FileSpecTask[];
36
+ agents: Record<string, { model: string | null; thinking: string | null }>;
37
+ config: { maxConcurrency: number; autoUnblock: boolean; maxRetries: number };
38
+ artifacts: FileSpecArtifact[];
39
+ }
40
+ export interface PreparedSpec { spec: FileSpec; raw: Buffer; sha256: string }
41
+ interface ChunkRecord {
42
+ index: number; startByte: number; endByteExclusive: number; bytes: number; sha256: string;
43
+ toolCallId: string; deliveredAt: string;
44
+ }
45
+ interface ChunkMetadata {
46
+ version: 1; squadId: string; taskId: string; index: number; chunkCount: number;
47
+ startByte: number; endByteExclusive: number; chunkBytes: number; chunkSha256: string;
48
+ specBytes: number; specSha256: string;
49
+ }
50
+
51
+ function fail(code: string, message: string): never { throw new Error(`${code}: ${message}`); }
52
+ function exactKeys(value: unknown, allowed: readonly string[], where: string): asserts value is Record<string, unknown> {
53
+ if (!value || typeof value !== "object" || Array.isArray(value)) fail("SPEC_MALFORMED", `${where} must be an object`);
54
+ for (const key of Object.keys(value)) if (!allowed.includes(key)) fail("SPEC_MALFORMED", `unknown ${where} property "${key}"`);
55
+ }
56
+ function requireString(value: unknown, where: string, nonempty = false): asserts value is string {
57
+ if (typeof value !== "string" || (nonempty && value.length === 0)) fail("SPEC_MALFORMED", `${where} must be ${nonempty ? "a nonempty " : "a "}string`);
58
+ }
59
+ function requireArray(value: unknown, where: string): asserts value is unknown[] {
60
+ if (!Array.isArray(value)) fail("SPEC_MALFORMED", `${where} must be an array`);
61
+ }
62
+ function checkEmbedded(value: string, where: string, max: number): number {
63
+ const measured = utf8Bytes(value);
64
+ if (measured > max) fail("SPEC_TOO_LARGE", `${where} is ${measured} bytes; limit is ${max}; use an artifact reference`);
65
+ if (BASE64_RUN.test(value)) fail("SPEC_TOO_LARGE", `${where} contains an oversized Base64/data-URI-like run; use an artifact reference`);
66
+ return measured;
67
+ }
68
+
69
+ /** Strict JSON parser: JSON.parse semantics plus duplicate-object-key rejection. */
70
+ function parseJsonNoDuplicateKeys(text: string): unknown {
71
+ let cursor = 0;
72
+ const whitespace = (): void => { while (text[cursor] === " " || text[cursor] === "\t" || text[cursor] === "\r" || text[cursor] === "\n") cursor++; };
73
+ const string = (): string => {
74
+ const start = cursor++;
75
+ while (cursor < text.length) {
76
+ const char = text[cursor++];
77
+ if (char === "\\") { cursor++; continue; }
78
+ if (char === "\"") {
79
+ try { return JSON.parse(text.slice(start, cursor)) as string; }
80
+ catch { fail("SPEC_MALFORMED", "invalid JSON string"); }
81
+ }
82
+ }
83
+ return fail("SPEC_MALFORMED", "unterminated JSON string");
84
+ };
85
+ const value = (): unknown => {
86
+ whitespace();
87
+ const char = text[cursor];
88
+ if (char === "{") {
89
+ cursor++; whitespace(); const result: Record<string, unknown> = {}; const keys = new Set<string>();
90
+ if (text[cursor] === "}") { cursor++; return result; }
91
+ while (true) {
92
+ whitespace(); if (text[cursor] !== "\"") fail("SPEC_MALFORMED", "object key must be a string");
93
+ const key = string(); if (keys.has(key)) fail("SPEC_MALFORMED", `duplicate object key "${key}"`); keys.add(key);
94
+ whitespace(); if (text[cursor++] !== ":") fail("SPEC_MALFORMED", "missing colon after object key");
95
+ result[key] = value(); whitespace(); const separator = text[cursor++];
96
+ if (separator === "}") return result; if (separator !== ",") fail("SPEC_MALFORMED", "invalid object separator");
97
+ }
98
+ }
99
+ if (char === "[") {
100
+ cursor++; whitespace(); const result: unknown[] = []; if (text[cursor] === "]") { cursor++; return result; }
101
+ while (true) { result.push(value()); whitespace(); const separator = text[cursor++]; if (separator === "]") return result; if (separator !== ",") fail("SPEC_MALFORMED", "invalid array separator"); }
102
+ }
103
+ if (char === "\"") return string();
104
+ const rest = text.slice(cursor);
105
+ for (const [literal, parsed] of [["true", true], ["false", false], ["null", null]] as const) {
106
+ if (rest.startsWith(literal)) { cursor += literal.length; return parsed; }
107
+ }
108
+ const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(rest);
109
+ if (!match) fail("SPEC_MALFORMED", `invalid JSON value at byte ${Buffer.byteLength(text.slice(0, cursor), "utf8")}`);
110
+ cursor += match[0].length; const parsed = Number(match[0]); if (!Number.isFinite(parsed)) fail("SPEC_MALFORMED", "non-finite number"); return parsed;
111
+ };
112
+ const parsed = value(); whitespace(); if (cursor !== text.length) fail("SPEC_MALFORMED", "trailing non-whitespace after JSON value"); return parsed;
113
+ }
114
+
115
+ function sameIdentity(a: fs.Stats, b: fs.Stats): boolean {
116
+ return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
117
+ }
118
+ /** Open once, reject final symlinks, read all bytes, and detect in-place mutation. */
119
+ export function stableReadFile(filePath: string, code: "SPEC_UNSTABLE" | "ARTIFACT_UNSTABLE", maxBytes?: number): Buffer {
120
+ let before: fs.Stats; try { before = fs.lstatSync(filePath); } catch (error) { fail(code, `cannot stat ${filePath}: ${(error as Error).message}`); }
121
+ if (before.isSymbolicLink() || !before.isFile()) fail(code, `${filePath} must be a regular non-symlink file`);
122
+ if (maxBytes !== undefined && before.size > maxBytes) fail("SPEC_TOO_LARGE", `canonical spec is ${before.size} bytes; limit is ${maxBytes}; use artifact references`);
123
+ let fd: number | undefined;
124
+ try {
125
+ const noFollow = (fs.constants as Record<string, number>).O_NOFOLLOW ?? 0;
126
+ fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow);
127
+ const opened = fs.fstatSync(fd); if (!sameIdentity(before, opened)) fail(code, `${filePath} changed while opening`);
128
+ const chunks: Buffer[] = []; const buffer = Buffer.allocUnsafe(64 * 1024); let total = 0;
129
+ while (true) { const count = fs.readSync(fd, buffer, 0, buffer.length, null); if (count === 0) break; total += count; if (maxBytes !== undefined && total > maxBytes) fail("SPEC_TOO_LARGE", `canonical spec exceeds ${maxBytes} bytes; use artifact references`); chunks.push(Buffer.from(buffer.subarray(0, count))); }
130
+ const after = fs.fstatSync(fd); if (!sameIdentity(opened, after) || total !== after.size) fail(code, `${filePath} changed while reading`);
131
+ return Buffer.concat(chunks, total);
132
+ } catch (error) {
133
+ if (error instanceof Error && (error.message.startsWith(`${code}:`) || error.message.startsWith("SPEC_TOO_LARGE:"))) throw error;
134
+ fail(code, `cannot securely read ${filePath}: ${(error as Error).message}`);
135
+ } finally { if (fd !== undefined) try { fs.closeSync(fd); } catch { /* best effort */ } }
136
+ throw new Error("unreachable stable read state");
137
+ }
138
+
139
+ /** Hash an external artifact with constant memory while retaining the same stable-descriptor checks. */
140
+ function stableHashFile(filePath: string, code: "ARTIFACT_UNSTABLE"): { bytes: number; sha256: string } {
141
+ let before: fs.Stats; try { before = fs.lstatSync(filePath); } catch (error) { fail(code, `cannot stat ${filePath}: ${(error as Error).message}`); }
142
+ if (before.isSymbolicLink() || !before.isFile()) fail(code, `${filePath} must be a regular non-symlink file`);
143
+ let fd: number | undefined;
144
+ try {
145
+ const noFollow = (fs.constants as Record<string, number>).O_NOFOLLOW ?? 0;
146
+ fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow);
147
+ const opened = fs.fstatSync(fd); if (!sameIdentity(before, opened)) fail(code, `${filePath} changed while opening`);
148
+ const digest = createHash("sha256"); const buffer = Buffer.allocUnsafe(64 * 1024); let total = 0;
149
+ while (true) { const count = fs.readSync(fd, buffer, 0, buffer.length, null); if (count === 0) break; digest.update(buffer.subarray(0, count)); total += count; }
150
+ const after = fs.fstatSync(fd); if (!sameIdentity(opened, after) || total !== after.size) fail(code, `${filePath} changed while hashing`);
151
+ return { bytes: total, sha256: digest.digest("hex") };
152
+ } catch (error) {
153
+ if (error instanceof Error && error.message.startsWith(`${code}:`)) throw error;
154
+ fail(code, `cannot securely hash ${filePath}: ${(error as Error).message}`);
155
+ } finally { if (fd !== undefined) try { fs.closeSync(fd); } catch { /* best effort */ } }
156
+ throw new Error("unreachable stable hash state");
157
+ }
158
+
159
+ function validateSpec(parsed: unknown): FileSpec {
160
+ exactKeys(parsed, ["schemaVersion", "goal", "tasks", "agents", "config", "artifacts"], "spec");
161
+ if (parsed.schemaVersion !== 1) fail("SPEC_MALFORMED", "schemaVersion must equal 1");
162
+ requireString(parsed.goal, "goal", true); let embeddedBytes = checkEmbedded(parsed.goal, "goal", MAX_GOAL_BYTES);
163
+ requireArray(parsed.tasks, "tasks"); if (parsed.tasks.length < 1 || parsed.tasks.length > 128) fail("SPEC_MALFORMED", "tasks must contain 1..128 entries");
164
+ if (!parsed.agents || typeof parsed.agents !== "object" || Array.isArray(parsed.agents)) fail("SPEC_MALFORMED", "agents must be an object");
165
+ const agents = parsed.agents as Record<string, unknown>; const agentNames = Object.keys(agents);
166
+ if (agentNames.length > 64) fail("SPEC_MALFORMED", "agents exceeds 64 entries");
167
+ for (const [name, raw] of Object.entries(agents)) {
168
+ if (!NAME.test(name)) fail("SPEC_MALFORMED", `invalid agent name "${name}"`);
169
+ exactKeys(raw, ["model", "thinking"], `agent ${name}`);
170
+ if (!(raw.model === null || typeof raw.model === "string") || !THINKING.has(raw.thinking as string | null)) fail("SPEC_MALFORMED", `invalid agent ${name}`);
171
+ if (!("model" in raw) || !("thinking" in raw)) fail("SPEC_MALFORMED", `agent ${name} requires model and thinking`);
172
+ }
173
+ exactKeys(parsed.config, ["maxConcurrency", "autoUnblock", "maxRetries"], "config");
174
+ const config = parsed.config;
175
+ if (!Number.isInteger(config.maxConcurrency) || (config.maxConcurrency as number) < 1 || (config.maxConcurrency as number) > 64 || typeof config.autoUnblock !== "boolean" || !Number.isInteger(config.maxRetries) || (config.maxRetries as number) < 0 || (config.maxRetries as number) > 20) fail("SPEC_MALFORMED", "invalid config");
176
+ for (const key of ["maxConcurrency", "autoUnblock", "maxRetries"]) if (!(key in config)) fail("SPEC_MALFORMED", `config requires ${key}`);
177
+
178
+ const taskIds = new Set<string>(); const tasks = parsed.tasks as Record<string, unknown>[];
179
+ for (const raw of tasks) {
180
+ exactKeys(raw, ["id", "title", "description", "agent", "depends", "inheritContext", "artifactRefs"], "task");
181
+ for (const key of ["id", "title", "description", "agent", "depends", "inheritContext", "artifactRefs"]) if (!(key in raw)) fail("SPEC_MALFORMED", `task requires ${key}`);
182
+ requireString(raw.id, "task.id"); requireString(raw.title, "task.title", true); requireString(raw.description, "task.description"); requireString(raw.agent, "task.agent"); requireArray(raw.depends, "task.depends"); requireArray(raw.artifactRefs, "task.artifactRefs");
183
+ if (!TASK_ID.test(raw.id) || taskIds.has(raw.id)) fail("SPEC_MALFORMED", `invalid or duplicate task id "${raw.id}"`); taskIds.add(raw.id);
184
+ if (!agents[raw.agent]) fail("SPEC_MALFORMED", `task ${raw.id} uses undeclared agent ${raw.agent}`);
185
+ if (typeof raw.inheritContext !== "boolean" || !raw.depends.every((d) => typeof d === "string") || new Set(raw.depends).size !== raw.depends.length || !raw.artifactRefs.every((a) => typeof a === "string") || new Set(raw.artifactRefs).size !== raw.artifactRefs.length) fail("SPEC_MALFORMED", `invalid task ${raw.id}`);
186
+ embeddedBytes += checkEmbedded(raw.title, `task ${raw.id} title`, MAX_TITLE_BYTES) + checkEmbedded(raw.description, `task ${raw.id} description`, MAX_DESCRIPTION_BYTES);
187
+ }
188
+ for (const task of tasks) for (const dependency of task.depends as string[]) if (!taskIds.has(dependency) || dependency === task.id) fail("SPEC_MALFORMED", `invalid dependency ${dependency} on task ${task.id}`);
189
+ const visiting = new Set<string>(), visited = new Set<string>(), byId = new Map(tasks.map((task) => [task.id as string, task]));
190
+ const visit = (id: string): void => { if (visiting.has(id)) fail("SPEC_MALFORMED", "task dependency graph contains a cycle"); if (visited.has(id)) return; visiting.add(id); for (const dep of byId.get(id)!.depends as string[]) visit(dep); visiting.delete(id); visited.add(id); };
191
+ for (const id of taskIds) visit(id);
192
+
193
+ requireArray(parsed.artifacts, "artifacts"); if (parsed.artifacts.length > 1024) fail("SPEC_MALFORMED", "artifacts exceeds 1024 entries");
194
+ const artifactIds = new Set<string>(); const artifacts = parsed.artifacts as Record<string, unknown>[];
195
+ for (const raw of artifacts) {
196
+ exactKeys(raw, ["id", "path", "sha256", "bytes", "purpose", "mediaType"], "artifact");
197
+ for (const key of ["id", "path", "sha256", "bytes", "purpose"]) if (!(key in raw)) fail("SPEC_MALFORMED", `artifact requires ${key}`);
198
+ requireString(raw.id, "artifact.id"); requireString(raw.path, "artifact.path", true); requireString(raw.sha256, "artifact.sha256"); requireString(raw.purpose, "artifact.purpose", true);
199
+ if (!ARTIFACT_ID.test(raw.id) || artifactIds.has(raw.id)) fail("SPEC_MALFORMED", `invalid or duplicate artifact id "${raw.id}"`); artifactIds.add(raw.id);
200
+ if (raw.path.includes("\0") || !path.isAbsolute(raw.path) || utf8Bytes(raw.path) > MAX_ARTIFACT_PATH_BYTES || !HASH.test(raw.sha256) || !Number.isSafeInteger(raw.bytes) || (raw.bytes as number) < 0 || !(raw.mediaType === undefined || (typeof raw.mediaType === "string" && raw.mediaType.length > 0))) fail("SPEC_MALFORMED", `invalid artifact ${raw.id}`);
201
+ embeddedBytes += checkEmbedded(raw.purpose, `artifact ${raw.id} purpose`, MAX_DESCRIPTION_BYTES);
202
+ }
203
+ if (embeddedBytes > MAX_EMBEDDED_BYTES) fail("SPEC_TOO_LARGE", `embedded contract is ${embeddedBytes} bytes; limit is ${MAX_EMBEDDED_BYTES}; use artifact references`);
204
+ for (const task of tasks) for (const artifact of task.artifactRefs as string[]) if (!artifactIds.has(artifact)) fail("SPEC_MALFORMED", `unknown artifact reference ${artifact}`);
205
+ return parsed as unknown as FileSpec;
206
+ }
207
+
208
+ export function prepareSpec(specFile: string, expectedHash: string, cwd: string): PreparedSpec {
209
+ if (!HASH.test(expectedHash)) fail("SPEC_HASH_MISMATCH", "specSha256 must be 64 lowercase hex characters");
210
+ if (typeof specFile !== "string" || specFile.length === 0 || specFile.includes("\0")) fail("SPEC_MALFORMED", "invalid spec path");
211
+ const source = path.resolve(cwd, specFile); const raw = stableReadFile(source, "SPEC_UNSTABLE", MAX_SPEC_BYTES);
212
+ if (sha256(raw) !== expectedHash) fail("SPEC_HASH_MISMATCH", "source bytes do not match specSha256");
213
+ if (raw.length >= 3 && raw[0] === 0xef && raw[1] === 0xbb && raw[2] === 0xbf) fail("SPEC_MALFORMED", "UTF-8 BOM is forbidden");
214
+ let text: string; try { text = new TextDecoder("utf-8", { fatal: true }).decode(raw); } catch { fail("SPEC_MALFORMED", "invalid UTF-8"); }
215
+ const spec = validateSpec(parseJsonNoDuplicateKeys(text));
216
+ for (const artifact of spec.artifacts) {
217
+ let actual: { bytes: number; sha256: string }; try { actual = stableHashFile(artifact.path, "ARTIFACT_UNSTABLE"); } catch (error) { if ((error as Error).message.startsWith("ARTIFACT_")) throw error; fail("ARTIFACT_INVALID", (error as Error).message); }
218
+ if (actual.bytes !== artifact.bytes) fail("ARTIFACT_SIZE_MISMATCH", `${artifact.id}: ${actual.bytes} != ${artifact.bytes}`);
219
+ if (actual.sha256 !== artifact.sha256) fail("ARTIFACT_HASH_MISMATCH", artifact.id);
220
+ }
221
+ return { spec, raw, sha256: expectedHash };
222
+ }
223
+
224
+ export function chunkRanges(raw: Buffer): Array<[number, number]> {
225
+ const ranges: Array<[number, number]> = []; let start = 0;
226
+ while (start < raw.length) { let end = Math.min(start + SPEC_CHUNK_BYTES, raw.length); while (end < raw.length && (raw[end] & 0xc0) === 0x80) end--; if (end <= start) throw new Error("validated UTF-8 chunk made no progress"); ranges.push([start, end]); start = end; }
227
+ return ranges;
228
+ }
229
+
230
+ function attestationPath(squad: Squad, task: Task): string {
231
+ return path.join(path.dirname(path.dirname(squad.spec!.path)), task.id, "spec-read-attestation.json");
232
+ }
233
+ function artifactsStillMatch(spec: FileSpec): boolean {
234
+ try { return spec.artifacts.every((artifact) => { const actual = stableHashFile(artifact.path, "ARTIFACT_UNSTABLE"); return actual.bytes === artifact.bytes && actual.sha256 === artifact.sha256; }); }
235
+ catch { return false; }
236
+ }
237
+ function exactObjectKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
238
+ const keys = Object.keys(value).sort(); return keys.length === expected.length && [...expected].sort().every((key, index) => key === keys[index]);
239
+ }
240
+ function isRfc3339(value: unknown): value is string {
241
+ return typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) && Number.isFinite(Date.parse(value));
242
+ }
243
+ function canonicalMetadataMatches(squad: Squad, raw: Buffer): boolean {
244
+ return Boolean(squad.spec && squad.spec.schemaVersion === 1 && HASH.test(squad.spec.sha256) && squad.spec.chunkBytes === SPEC_CHUNK_BYTES && squad.spec.bytes === raw.length && squad.spec.chunkCount === chunkRanges(raw).length && sha256(raw) === squad.spec.sha256);
245
+ }
246
+ export function validateCanonicalSpec(squad: Squad): boolean {
247
+ if (!squad.spec) return true;
248
+ try { return canonicalMetadataMatches(squad, stableReadFile(squad.spec.path, "SPEC_UNSTABLE")); }
249
+ catch { return false; }
250
+ }
251
+
252
+ export function validateTaskSpecAttestation(squad: Squad, task: Task, options: { verifyArtifacts?: boolean } = {}): boolean {
253
+ if (!squad.spec) return true;
254
+ try {
255
+ const raw = stableReadFile(squad.spec.path, "SPEC_UNSTABLE");
256
+ if (!canonicalMetadataMatches(squad, raw)) return false;
257
+ if (options.verifyArtifacts !== false) {
258
+ const decoded = new TextDecoder("utf-8", { fatal: true }).decode(raw);
259
+ if (!artifactsStillMatch(validateSpec(parseJsonNoDuplicateKeys(decoded)))) return false;
260
+ }
261
+ const attestationRaw = stableReadFile(attestationPath(squad, task), "SPEC_UNSTABLE");
262
+ const attestationText = new TextDecoder("utf-8", { fatal: true }).decode(attestationRaw);
263
+ const attestation = parseJsonNoDuplicateKeys(attestationText) as Record<string, unknown>;
264
+ const topKeys = ["version", "state", "squadId", "taskId", "specSha256", "specBytes", "chunkBytes", "chunkCount", "chunks", "completedAt"];
265
+ if (!attestation || typeof attestation !== "object" || !exactObjectKeys(attestation, topKeys)) return false;
266
+ const ranges = chunkRanges(raw); const chunks = attestation.chunks;
267
+ if (attestation.version !== 1 || attestation.state !== "complete" || attestation.squadId !== squad.id || attestation.taskId !== task.id || attestation.specSha256 !== squad.spec.sha256 || attestation.specBytes !== raw.length || attestation.chunkBytes !== SPEC_CHUNK_BYTES || attestation.chunkCount !== ranges.length || !Array.isArray(chunks) || chunks.length !== ranges.length || !isRfc3339(attestation.completedAt)) return false;
268
+ return chunks.every((rawChunk, index) => {
269
+ if (!rawChunk || typeof rawChunk !== "object" || Array.isArray(rawChunk)) return false; const chunk = rawChunk as Record<string, unknown>; const [start, end] = ranges[index];
270
+ return exactObjectKeys(chunk, ["index", "startByte", "endByteExclusive", "bytes", "sha256", "toolCallId", "deliveredAt"]) && chunk.index === index && chunk.startByte === start && chunk.endByteExclusive === end && chunk.bytes === end - start && chunk.sha256 === sha256(raw.subarray(start, end)) && typeof chunk.toolCallId === "string" && chunk.toolCallId.length > 0 && isRfc3339(chunk.deliveredAt);
271
+ });
272
+ } catch { return false; }
273
+ }
274
+
275
+ function withFileLock<T>(filePath: string, operation: () => T): T {
276
+ fs.mkdirSync(path.dirname(filePath), { recursive: true }); const lock = `${filePath}.lock`; const started = Date.now(); let fd: number | undefined;
277
+ while (fd === undefined) { try { fd = fs.openSync(lock, "wx", 0o600); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; try { if (Date.now() - fs.statSync(lock).mtimeMs > 30_000) { fs.unlinkSync(lock); continue; } } catch { continue; } if (Date.now() - started > 10_000) throw new Error(`Timed out acquiring ${lock}`); Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2); } }
278
+ try { return operation(); } finally { try { fs.closeSync(fd); } catch { /* ignore */ } try { fs.unlinkSync(lock); } catch { /* ignore */ } }
279
+ }
280
+ function writeJsonDurable(filePath: string, value: unknown): void {
281
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); const temporary = `${filePath}.tmp.${process.pid}.${randomUUID()}`; const fd = fs.openSync(temporary, "wx", 0o600);
282
+ try { fs.writeFileSync(fd, JSON.stringify(value, null, 2) + "\n"); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
283
+ fs.renameSync(temporary, filePath);
284
+ try { const dirFd = fs.openSync(path.dirname(filePath), fs.constants.O_RDONLY); try { fs.fsyncSync(dirFd); } finally { fs.closeSync(dirFd); } } catch { /* directory fsync unavailable */ }
285
+ }
286
+
287
+ function archiveInvalidProgress(statePath: string): void {
288
+ if (!fs.existsSync(statePath)) return;
289
+ const archived = `${statePath}.invalid.${Date.now()}.${randomUUID()}`;
290
+ try { fs.renameSync(statePath, archived); }
291
+ catch { try { fs.unlinkSync(statePath); } catch { /* fail-closed caller will keep the guard shut */ } }
292
+ }
293
+
294
+ function loadValidProgress(statePath: string, manifest: { squadId: string; taskId: string; sha256: string; bytes: number }, raw: Buffer): { state: "required" | "reading" | "complete"; records: ChunkRecord[] } {
295
+ if (!fs.existsSync(statePath)) return { state: "required", records: [] };
296
+ try {
297
+ const stateRaw = stableReadFile(statePath, "SPEC_UNSTABLE");
298
+ const stateText = new TextDecoder("utf-8", { fatal: true }).decode(stateRaw);
299
+ const state = parseJsonNoDuplicateKeys(stateText) as Record<string, unknown>;
300
+ const ranges = chunkRanges(raw);
301
+ const keys = ["version", "state", "squadId", "taskId", "specSha256", "specBytes", "chunkBytes", "chunkCount", "chunks"];
302
+ if (!state || typeof state !== "object" || !exactObjectKeys(state, keys) || state.version !== 1 || !["reading", "complete"].includes(String(state.state)) || state.squadId !== manifest.squadId || state.taskId !== manifest.taskId || state.specSha256 !== manifest.sha256 || state.specBytes !== manifest.bytes || state.chunkBytes !== SPEC_CHUNK_BYTES || state.chunkCount !== ranges.length || !Array.isArray(state.chunks) || state.chunks.length > ranges.length) throw new Error("invalid progress identity");
303
+ const seen = new Set<number>();
304
+ const records = state.chunks.map((rawRecord) => {
305
+ if (!rawRecord || typeof rawRecord !== "object" || Array.isArray(rawRecord)) throw new Error("invalid progress chunk");
306
+ const record = rawRecord as Record<string, unknown>;
307
+ if (!exactObjectKeys(record, ["index", "startByte", "endByteExclusive", "bytes", "sha256", "toolCallId", "deliveredAt"]) || !Number.isInteger(record.index) || seen.has(record.index as number)) throw new Error("invalid progress chunk identity");
308
+ const index = record.index as number; const range = ranges[index];
309
+ if (!range || record.startByte !== range[0] || record.endByteExclusive !== range[1] || record.bytes !== range[1] - range[0] || record.sha256 !== sha256(raw.subarray(range[0], range[1])) || typeof record.toolCallId !== "string" || record.toolCallId.length === 0 || !isRfc3339(record.deliveredAt)) throw new Error("invalid progress chunk metadata");
310
+ seen.add(index); return record as unknown as ChunkRecord;
311
+ });
312
+ records.sort((a, b) => a.index - b.index);
313
+ if (state.state === "complete" && records.length !== ranges.length) throw new Error("invalid progress state");
314
+ return { state: state.state as "reading" | "complete", records };
315
+ } catch {
316
+ archiveInvalidProgress(statePath);
317
+ return { state: "required", records: [] };
318
+ }
319
+ }
320
+
321
+ function completeAttestationValue(manifest: { squadId: string; taskId: string; sha256: string; bytes: number }, raw: Buffer, records: ChunkRecord[]): Record<string, unknown> {
322
+ return { version: 1, state: "complete", squadId: manifest.squadId, taskId: manifest.taskId, specSha256: manifest.sha256, specBytes: manifest.bytes, chunkBytes: SPEC_CHUNK_BYTES, chunkCount: chunkRanges(raw).length, chunks: records, completedAt: new Date().toISOString() };
323
+ }
324
+
325
+ function registerInvalidManifestGuard(pi: ExtensionAPI, reason: string): void {
326
+ pi.on("tool_call", () => ({ block: true, reason: `Invalid file-spec child manifest: ${reason}. All tools are blocked.` }));
327
+ pi.on("before_agent_start", (event: { systemPrompt: string }) => ({ systemPrompt: `${event.systemPrompt}\n\nFile-spec bootstrap failed closed: ${reason}. No tool or task completion is authorized.` }));
328
+ }
329
+
330
+ export function registerChildSpecReader(pi: ExtensionAPI): boolean {
331
+ const env = process.env;
332
+ if (env.PI_SQUAD_CHILD !== "1") return false;
333
+ const specKeys = ["PI_SQUAD_SPEC_PATH", "PI_SQUAD_SPEC_SHA256", "PI_SQUAD_SPEC_BYTES", "PI_SQUAD_SPEC_CHUNK_BYTES"] as const;
334
+ const hasSpecManifest = specKeys.some((key) => env[key] !== undefined);
335
+ if (!hasSpecManifest) return false;
336
+ const required = [env.PI_SQUAD_ID, env.PI_SQUAD_TASK_ID, ...specKeys.map((key) => env[key])];
337
+ const bytesText = env.PI_SQUAD_SPEC_BYTES ?? ""; const bytes = Number(bytesText);
338
+ const invalidReason = required.some((value) => !value)
339
+ ? "required environment fields are missing"
340
+ : !isFileSpecTaskId(env.PI_SQUAD_TASK_ID!) || !/^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/.test(env.PI_SQUAD_ID!)
341
+ ? "squad or task identity is unsafe"
342
+ : !path.isAbsolute(env.PI_SQUAD_SPEC_PATH!) || env.PI_SQUAD_SPEC_PATH!.includes("\0")
343
+ ? "canonical path is not an absolute native path"
344
+ : path.basename(env.PI_SQUAD_SPEC_PATH!) !== "spec.v1.json" || path.basename(path.dirname(env.PI_SQUAD_SPEC_PATH!)) !== "spec" || path.basename(path.dirname(path.dirname(env.PI_SQUAD_SPEC_PATH!))) !== env.PI_SQUAD_ID
345
+ ? "canonical path does not match the squad manifest layout"
346
+ : !HASH.test(env.PI_SQUAD_SPEC_SHA256!) || env.PI_SQUAD_SPEC_CHUNK_BYTES !== String(SPEC_CHUNK_BYTES) || !/^\d+$/.test(bytesText) || !Number.isSafeInteger(bytes) || bytes <= 0 || bytes > MAX_SPEC_BYTES
347
+ ? "hash, byte length, or chunk size is invalid"
348
+ : null;
349
+ if (invalidReason) { registerInvalidManifestGuard(pi, invalidReason); return true; }
350
+ const manifest = { squadId: env.PI_SQUAD_ID!, taskId: env.PI_SQUAD_TASK_ID!, path: env.PI_SQUAD_SPEC_PATH!, sha256: env.PI_SQUAD_SPEC_SHA256!, bytes };
351
+ const taskDir = path.join(path.dirname(path.dirname(manifest.path)), manifest.taskId); const statePath = path.join(taskDir, "spec-read-state.json");
352
+ const squad = (): Squad => { const raw = stableReadFile(manifest.path, "SPEC_UNSTABLE"); return { id: manifest.squadId, spec: { schemaVersion: 1, sha256: manifest.sha256, bytes: manifest.bytes, path: manifest.path, chunkBytes: SPEC_CHUNK_BYTES, chunkCount: chunkRanges(raw).length } } as Squad; };
353
+ const task = { id: manifest.taskId } as Task; const pending = new Map<string, { metadata: ChunkMetadata; text: string }>();
354
+ const complete = (): boolean => {
355
+ const currentSquad = squad();
356
+ if (validateTaskSpecAttestation(currentSquad, task, { verifyArtifacts: false })) return true;
357
+ const raw = stableReadFile(manifest.path, "SPEC_UNSTABLE");
358
+ if (raw.length !== manifest.bytes || sha256(raw) !== manifest.sha256) return false;
359
+ return withFileLock(statePath, () => {
360
+ const progress = loadValidProgress(statePath, manifest, raw);
361
+ const attestation = attestationPath(currentSquad, task);
362
+ if (progress.state !== "reading" || progress.records.length !== chunkRanges(raw).length || fs.existsSync(attestation)) return false;
363
+ writeJsonDurable(attestation, completeAttestationValue(manifest, raw, progress.records));
364
+ writeJsonDurable(statePath, { version: 1, state: "complete", squadId: manifest.squadId, taskId: manifest.taskId, specSha256: manifest.sha256, specBytes: manifest.bytes, chunkBytes: SPEC_CHUNK_BYTES, chunkCount: chunkRanges(raw).length, chunks: progress.records });
365
+ return validateTaskSpecAttestation(currentSquad, task, { verifyArtifacts: false });
366
+ });
367
+ };
368
+ const missingIndices = (): number[] => {
369
+ const raw = stableReadFile(manifest.path, "SPEC_UNSTABLE");
370
+ if (raw.length !== manifest.bytes || sha256(raw) !== manifest.sha256) return chunkRanges(raw).map((_, index) => index);
371
+ const count = chunkRanges(raw).length; const progress = loadValidProgress(statePath, manifest, raw);
372
+ if (progress.state === "complete" && !validateTaskSpecAttestation(squad(), task, { verifyArtifacts: false })) {
373
+ archiveInvalidProgress(attestationPath(squad(), task)); archiveInvalidProgress(statePath);
374
+ return Array.from({ length: count }, (_, index) => index);
375
+ }
376
+ const delivered = new Set(progress.records.map((chunk) => chunk.index));
377
+ return Array.from({ length: count }, (_, index) => index).filter((index) => !delivered.has(index));
378
+ };
379
+
380
+ pi.on("tool_call", (event: { toolName: string }) => {
381
+ try { if (event.toolName !== "squad_spec_read" && !complete()) return { block: true, reason: "Read every canonical squad spec chunk with squad_spec_read before using other tools." }; }
382
+ catch { if (event.toolName !== "squad_spec_read") return { block: true, reason: "Canonical squad spec state is invalid; all normal tools are blocked." }; }
383
+ });
384
+ pi.on("before_agent_start", (event: { systemPrompt: string }) => {
385
+ let missing: number[]; let bootstrapError: string | null = null;
386
+ try { missing = complete() ? [] : missingIndices(); }
387
+ catch (error) { missing = []; bootstrapError = (error as Error).message; }
388
+ const bootstrap = [
389
+ "You are a file-spec squad child. Nested squad orchestration is unavailable.",
390
+ `Squad ID: ${manifest.squadId}`,
391
+ `Task ID: ${manifest.taskId}`,
392
+ `Canonical spec SHA-256: ${manifest.sha256}`,
393
+ `Canonical spec bytes: ${manifest.bytes}`,
394
+ `Chunk bytes: ${SPEC_CHUNK_BYTES}`,
395
+ `Missing chunk indices: [${missing.join(",")}]`,
396
+ ...(bootstrapError ? [`Canonical bootstrap error: ${bootstrapError}`, "All normal tools remain blocked until canonical integrity is restored."] : ["Before any normal tool can run, call squad_spec_read for every missing chunk index. Exact tool-result delivery is durably attested."]),
397
+ ].join("\n");
398
+ return { systemPrompt: `${event.systemPrompt}\n\n${bootstrap}` };
399
+ });
400
+ pi.on("message_end", (event: ExtensionEvent) => {
401
+ if (event.type !== "message_end") return;
402
+ const message = event.message;
403
+ if (message.role !== "toolResult" || message.toolName !== "squad_spec_read" || message.isError) return;
404
+ const candidate = pending.get(message.toolCallId); if (!candidate) return; pending.delete(message.toolCallId);
405
+ if (message.content.length !== 2 || message.content[0]?.type !== "text" || message.content[1]?.type !== "text" || message.content[0].text !== JSON.stringify(candidate.metadata) || message.content[1].text !== candidate.text || JSON.stringify(message.details) !== JSON.stringify(candidate.metadata)) return;
406
+ const raw = stableReadFile(manifest.path, "SPEC_UNSTABLE"); if (raw.length !== manifest.bytes || sha256(raw) !== manifest.sha256) return; const ranges = chunkRanges(raw); const metadata = candidate.metadata; const range = ranges[metadata.index]; if (!range || candidate.text !== new TextDecoder("utf-8", { fatal: true }).decode(raw.subarray(range[0], range[1]))) return;
407
+ withFileLock(statePath, () => {
408
+ const currentSquad = squad();
409
+ if (validateTaskSpecAttestation(currentSquad, task, { verifyArtifacts: false })) return;
410
+ const progress = loadValidProgress(statePath, manifest, raw); const records = progress.records;
411
+ const record: ChunkRecord = { index: metadata.index, startByte: metadata.startByte, endByteExclusive: metadata.endByteExclusive, bytes: metadata.chunkBytes, sha256: metadata.chunkSha256, toolCallId: message.toolCallId, deliveredAt: new Date().toISOString() };
412
+ const existing = records.find((entry) => entry.index === record.index);
413
+ if (existing) {
414
+ if (existing.startByte !== record.startByte || existing.endByteExclusive !== record.endByteExclusive || existing.bytes !== record.bytes || existing.sha256 !== record.sha256) {
415
+ writeJsonDurable(statePath, { version: 1, state: "invalid", squadId: manifest.squadId, taskId: manifest.taskId, specSha256: manifest.sha256, specBytes: manifest.bytes, chunkBytes: SPEC_CHUNK_BYTES, chunkCount: ranges.length, chunks: records, conflict: record });
416
+ archiveInvalidProgress(statePath);
417
+ return;
418
+ }
419
+ if (progress.state === "reading" && records.length === ranges.length && !fs.existsSync(attestationPath(currentSquad, task))) {
420
+ writeJsonDurable(attestationPath(currentSquad, task), completeAttestationValue(manifest, raw, records));
421
+ writeJsonDurable(statePath, { version: 1, state: "complete", squadId: manifest.squadId, taskId: manifest.taskId, specSha256: manifest.sha256, specBytes: manifest.bytes, chunkBytes: SPEC_CHUNK_BYTES, chunkCount: ranges.length, chunks: records });
422
+ }
423
+ return;
424
+ }
425
+ records.push(record); records.sort((a, b) => a.index - b.index);
426
+ const full = records.length === ranges.length && records.every((entry, index) => entry.index === index && entry.startByte === ranges[index][0] && entry.endByteExclusive === ranges[index][1] && entry.sha256 === sha256(raw.subarray(ranges[index][0], ranges[index][1])));
427
+ writeJsonDurable(statePath, { version: 1, state: "reading", squadId: manifest.squadId, taskId: manifest.taskId, specSha256: manifest.sha256, specBytes: manifest.bytes, chunkBytes: SPEC_CHUNK_BYTES, chunkCount: ranges.length, chunks: records });
428
+ if (full) {
429
+ writeJsonDurable(attestationPath(currentSquad, task), completeAttestationValue(manifest, raw, records));
430
+ writeJsonDurable(statePath, { version: 1, state: "complete", squadId: manifest.squadId, taskId: manifest.taskId, specSha256: manifest.sha256, specBytes: manifest.bytes, chunkBytes: SPEC_CHUNK_BYTES, chunkCount: ranges.length, chunks: records });
431
+ }
432
+ });
433
+ });
434
+
435
+ pi.registerTool({
436
+ name: "squad_spec_read", label: "Read canonical squad spec", description: "Deliver one exact UTF-8-aligned canonical spec chunk. Available only inside a file-spec child.",
437
+ parameters: {
438
+ type: "object", additionalProperties: false, required: ["index"],
439
+ properties: { index: { type: "integer", minimum: 0 } },
440
+ } as TSchema,
441
+ async execute(toolCallId: string, params: { index: number }) {
442
+ const raw = stableReadFile(manifest.path, "SPEC_UNSTABLE"); if (raw.length !== manifest.bytes || sha256(raw) !== manifest.sha256) throw new Error("Canonical spec integrity failure");
443
+ const ranges = chunkRanges(raw); if (!Number.isInteger(params.index) || params.index < 0 || params.index >= ranges.length) throw new Error(`Chunk index ${params.index} out of range 0..${ranges.length - 1}`);
444
+ const [start, end] = ranges[params.index]; const chunk = raw.subarray(start, end); const text = new TextDecoder("utf-8", { fatal: true }).decode(chunk);
445
+ const metadata: ChunkMetadata = { version: 1, squadId: manifest.squadId, taskId: manifest.taskId, index: params.index, chunkCount: ranges.length, startByte: start, endByteExclusive: end, chunkBytes: chunk.length, chunkSha256: sha256(chunk), specBytes: raw.length, specSha256: manifest.sha256 };
446
+ pending.set(toolCallId, { metadata, text });
447
+ return { content: [{ type: "text" as const, text: JSON.stringify(metadata) }, { type: "text" as const, text }], details: metadata };
448
+ },
449
+ });
450
+ return true;
451
+ }