pi-squad 0.16.7 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/docs/behavioral-contract-exact-targeting-review-suspended-attention.md +204 -0
- package/docs/file-spec-and-full-read-attestation-contract.md +412 -0
- package/docs/fleet-bridge.md +79 -0
- package/package.json +2 -1
- package/src/agent-pool.ts +16 -4
- package/src/file-spec.ts +451 -0
- package/src/index.ts +132 -99
- package/src/panel/squad-widget.ts +20 -9
- package/src/protocol.ts +21 -0
- package/src/review.ts +8 -4
- package/src/scheduler.ts +86 -12
- package/src/store.ts +39 -2
- package/src/types.ts +5 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# pi-fleet ↔ pi-squad Bridge (cross-extension contract)
|
|
2
|
+
|
|
3
|
+
pi-squad orchestrates **what** gets done (task DAG, QA loop, advisor) with local
|
|
4
|
+
`pi --mode rpc` agents. pi-fleet orchestrates **where** work runs (remote tailnet
|
|
5
|
+
workers, bundles, baselines, budgets). This document defines how they compose —
|
|
6
|
+
and the independence guarantees that composition must never break.
|
|
7
|
+
|
|
8
|
+
## Independence contract (non-negotiable)
|
|
9
|
+
|
|
10
|
+
| Installed | Behavior |
|
|
11
|
+
|---|---|
|
|
12
|
+
| pi-fleet only | Everything works. The bridge global is published but never consumed — zero side effects (FleetManager stays lazy). |
|
|
13
|
+
| pi-squad only | Everything works. Squad feature-detects the bridge, finds nothing, and never offers/uses remote placement. Tasks requesting a `host` fail with an actionable error, not a crash. |
|
|
14
|
+
| Both | Remote placement unlocks (Phase A below). |
|
|
15
|
+
|
|
16
|
+
Rules:
|
|
17
|
+
- **No package dependency in either direction.** The only coupling is a
|
|
18
|
+
version-checked global (`globalThis.__piFleetBridge`) plus this document.
|
|
19
|
+
- Consumers must treat a missing global or `version !== 1` as "fleet absent".
|
|
20
|
+
- pi-fleet publishes at server-mode startup and unpublishes on
|
|
21
|
+
`session_shutdown` (ownership-checked, so a reload's new instance wins).
|
|
22
|
+
- Consumer exceptions are isolated inside the bridge's event dispatch — a
|
|
23
|
+
buggy consumer cannot break fleet event handling.
|
|
24
|
+
|
|
25
|
+
## Bridge API v1 (pi-fleet `src/server/bridge.ts`)
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
const bridge = (globalThis as Record<string, unknown>).__piFleetBridge as PiFleetBridgeV1 | undefined;
|
|
29
|
+
if (bridge?.version === 1) { /* fleet available */ }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- `spawnWorker({host, cwd, bundle?, fromBaseline?, maxCost?})` → `{instanceId, host, bundle, staleBaseline?}`
|
|
33
|
+
- `prompt(instanceId, message)` — fire-and-forget; observe via `onEvent`
|
|
34
|
+
- `rpc(instanceId, command, timeoutMs?)` — raw pi RPC passthrough (steer/abort/set_model/get_state…)
|
|
35
|
+
- `onEvent(instanceId, listener)` → unsubscribe — the worker's pi RPC event stream
|
|
36
|
+
- `abort(instanceId)` / `stop(instanceId)` / `status(instanceId)`
|
|
37
|
+
|
|
38
|
+
## Composition phases
|
|
39
|
+
|
|
40
|
+
**Phase 0 (shipped, prompt-level):** the squad supervisor skill teaches the main
|
|
41
|
+
session when to use squad (intra-repo parallelism) vs fleet (machine-bound work)
|
|
42
|
+
vs both side by side. Works with either extension alone.
|
|
43
|
+
|
|
44
|
+
**Phase A (planned, pi-squad side):** `host?` on squad agent entries →
|
|
45
|
+
`agents: { backend: { host: "ab-internal-10", fromBaseline: "api-repo" } }`.
|
|
46
|
+
Squad's AgentPool gains a bridge-backed executor: spawn via `spawnWorker`,
|
|
47
|
+
deliver the squad protocol prompt via `prompt`, map `onEvent` into the existing
|
|
48
|
+
AgentEvent pipeline (same events the local RPC children emit), steer via
|
|
49
|
+
`rpc({type:"steer"})`, kill via `stop`. Scheduler/monitor/advisor/QA operate on
|
|
50
|
+
the event stream and need no changes. Known problems to solve:
|
|
51
|
+
- **Skills**: `--skill` paths don't exist remotely — squad skills must ship in
|
|
52
|
+
the worker's bundle (publish a `squad` bundle) or be inlined into the
|
|
53
|
+
appended system prompt.
|
|
54
|
+
- **System prompt**: fleet spawn has no `--append-system-prompt` passthrough
|
|
55
|
+
yet; deliver the protocol as the first prompt message, or extend the spawn
|
|
56
|
+
frame (fleet-side change, additive).
|
|
57
|
+
- **File coordination**: `modifiedFiles`/sibling-conflict rules are meaningless
|
|
58
|
+
across machines. Remote squad tasks must deliver work as branch/patch
|
|
59
|
+
(fleet's `deliver` mechanism), never assume a shared working tree.
|
|
60
|
+
- **inheritContext**: session forks cannot cross machines. The remote
|
|
61
|
+
equivalent is `fromBaseline` (warm repo context, clone-on-spawn).
|
|
62
|
+
|
|
63
|
+
**Phase B (planned):** unify review — remote squad tasks route fleet
|
|
64
|
+
`task_done` into squad's `handleTaskCompleted`; QA `FAIL` verdict maps to
|
|
65
|
+
`remote_reject`; fleet's own awaiting-review is suppressed for squad-owned
|
|
66
|
+
instances.
|
|
67
|
+
|
|
68
|
+
**Phase C (planned):** budgets — `maxCost` per squad task; squad cost roll-up
|
|
69
|
+
includes remote workers.
|
|
70
|
+
|
|
71
|
+
## Division-of-labor cheat sheet
|
|
72
|
+
|
|
73
|
+
| Situation | Use |
|
|
74
|
+
|---|---|
|
|
75
|
+
| Parallel work inside this repo (backend+frontend+QA share the tree) | squad |
|
|
76
|
+
| Work on another machine/OS/dev-env/repo; blast-radius or cost isolation | fleet |
|
|
77
|
+
| Big feature here + deployment validation on the VM | both, side by side |
|
|
78
|
+
| Warm repo understanding for repeated remote tasks | fleet baselines |
|
|
79
|
+
| Stuck-agent rescue with a stronger model | squad advisor (local), orchestrator review (fleet) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-squad",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"description": "Multi-agent collaboration extension for pi — task decomposition, dependency management, parallel execution, TUI panel",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"files": [
|
|
25
25
|
"src",
|
|
26
|
+
"docs",
|
|
26
27
|
"README.md",
|
|
27
28
|
"LICENSE"
|
|
28
29
|
],
|
package/src/agent-pool.ts
CHANGED
|
@@ -146,8 +146,9 @@ export class AgentPool {
|
|
|
146
146
|
sessionDir?: string;
|
|
147
147
|
/** Fork the given session file so a new task inherits main-session context. */
|
|
148
148
|
forkSession?: { file: string; sessionDir: string };
|
|
149
|
+
spec?: { squadId: string; path: string; sha256: string; bytes: number; chunkBytes: number };
|
|
149
150
|
}): Promise<AgentProcess> {
|
|
150
|
-
const { taskId, agentDef, protocolOptions, cwd, skillPaths, resumeSession, sessionDir, forkSession } = options;
|
|
151
|
+
const { taskId, agentDef, protocolOptions, cwd, skillPaths, resumeSession, sessionDir, forkSession, spec } = options;
|
|
151
152
|
|
|
152
153
|
// Kill existing process for this task if any
|
|
153
154
|
if (this.agents.has(taskId)) {
|
|
@@ -161,7 +162,7 @@ export class AgentPool {
|
|
|
161
162
|
fs.writeFileSync(promptFile, systemPrompt, "utf-8");
|
|
162
163
|
|
|
163
164
|
// Build pi CLI args
|
|
164
|
-
const args = buildPiArgs(agentDef, promptFile, skillPaths, { resumeSession, sessionDir, forkSession });
|
|
165
|
+
const args = buildPiArgs(agentDef, promptFile, skillPaths, { resumeSession, sessionDir, forkSession }, Boolean(spec));
|
|
165
166
|
|
|
166
167
|
// Spawn pi process — set env var to prevent recursive squad extension loading
|
|
167
168
|
const invocation = getPiInvocation(["--mode", "rpc", ...args]);
|
|
@@ -169,7 +170,14 @@ export class AgentPool {
|
|
|
169
170
|
const proc = spawn(invocation.command, invocation.args, {
|
|
170
171
|
cwd,
|
|
171
172
|
stdio: ["pipe", "pipe", "pipe"],
|
|
172
|
-
env: { ...process.env, PI_SQUAD_CHILD: "1"
|
|
173
|
+
env: { ...process.env, PI_SQUAD_CHILD: "1", ...(spec ? {
|
|
174
|
+
PI_SQUAD_ID: spec.squadId,
|
|
175
|
+
PI_SQUAD_TASK_ID: taskId,
|
|
176
|
+
PI_SQUAD_SPEC_PATH: spec.path,
|
|
177
|
+
PI_SQUAD_SPEC_SHA256: spec.sha256,
|
|
178
|
+
PI_SQUAD_SPEC_BYTES: String(spec.bytes),
|
|
179
|
+
PI_SQUAD_SPEC_CHUNK_BYTES: String(spec.chunkBytes),
|
|
180
|
+
} : {}) },
|
|
173
181
|
});
|
|
174
182
|
|
|
175
183
|
const activity: AgentActivity = {
|
|
@@ -524,6 +532,7 @@ function buildPiArgs(
|
|
|
524
532
|
sessionDir?: string;
|
|
525
533
|
forkSession?: { file: string; sessionDir: string };
|
|
526
534
|
},
|
|
535
|
+
fileSpec = false,
|
|
527
536
|
): string[] {
|
|
528
537
|
const { resumeSession, sessionDir, forkSession } = sessionOptions;
|
|
529
538
|
if (resumeSession && forkSession) throw new Error("Cannot resume and fork a task session simultaneously");
|
|
@@ -547,7 +556,10 @@ function buildPiArgs(
|
|
|
547
556
|
}
|
|
548
557
|
|
|
549
558
|
if (agentDef.tools && agentDef.tools.length > 0) {
|
|
550
|
-
|
|
559
|
+
const tools = fileSpec && !agentDef.tools.includes("squad_spec_read")
|
|
560
|
+
? [...agentDef.tools, "squad_spec_read"]
|
|
561
|
+
: agentDef.tools;
|
|
562
|
+
args.push("--tools", tools.join(","));
|
|
551
563
|
}
|
|
552
564
|
|
|
553
565
|
for (const skillPath of skillPaths) {
|
package/src/file-spec.ts
ADDED
|
@@ -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
|
+
}
|