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
package/src/scheduler.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { AgentPool, type AgentEvent } from "./agent-pool.js";
|
|
|
16
16
|
import { Monitor } from "./monitor.js";
|
|
17
17
|
import { Router } from "./router.js";
|
|
18
18
|
import * as store from "./store.js";
|
|
19
|
+
import { isFileSpecTaskId, validateCanonicalSpec, validateTaskSpecAttestation } from "./file-spec.js";
|
|
19
20
|
import { debug, logError } from "./logger.js";
|
|
20
21
|
import { buildAgentSystemPrompt } from "./protocol.js";
|
|
21
22
|
import { buildAdvisorConsultText, formatAdvisorSteerMessage, adviceNeedsHuman, type AdvisorConsultInput } from "./advisor.js";
|
|
@@ -211,6 +212,29 @@ export class Scheduler {
|
|
|
211
212
|
}
|
|
212
213
|
}
|
|
213
214
|
|
|
215
|
+
/** Revalidate durable file-spec completion evidence after parent restart/review recovery. */
|
|
216
|
+
async auditSpecAttestations(): Promise<string[]> {
|
|
217
|
+
const squad = store.loadSquad(this.squadId); if (!squad?.spec) return [];
|
|
218
|
+
const tasks = store.loadAllTasks(this.squadId);
|
|
219
|
+
if (!validateCanonicalSpec(squad)) {
|
|
220
|
+
const quarantined = tasks.filter((task) => task.status !== "cancelled");
|
|
221
|
+
for (const task of quarantined) {
|
|
222
|
+
if (task.status === "done") await this.invalidateDescendants(task.id);
|
|
223
|
+
if (task.status === "in_progress" || task.status === "done") store.updateTaskStatus(this.squadId, task.id, "pending", { completed: null, output: null, error: "Canonical spec integrity failure" });
|
|
224
|
+
else store.updateTaskStatus(this.squadId, task.id, task.status, { error: "Canonical spec integrity failure" });
|
|
225
|
+
}
|
|
226
|
+
return quarantined.map((task) => task.id);
|
|
227
|
+
}
|
|
228
|
+
const invalid = tasks.filter((task) => task.status === "done" && !validateTaskSpecAttestation(squad, task));
|
|
229
|
+
for (const task of invalid) {
|
|
230
|
+
store.updateTaskStatus(this.squadId, task.id, "pending", { completed: null, output: null, error: "Missing or invalid canonical spec read attestation" });
|
|
231
|
+
store.appendMessage(this.squadId, task.id, { ts: store.now(), from: "system", type: "status", text: "Completion invalidated after attestation audit" });
|
|
232
|
+
await this.invalidateDescendants(task.id);
|
|
233
|
+
}
|
|
234
|
+
if (invalid.length > 0 && squad.status === "review") { squad.status = "running"; delete squad.review; store.saveSquad(squad); }
|
|
235
|
+
return invalid.map((task) => task.id);
|
|
236
|
+
}
|
|
237
|
+
|
|
214
238
|
/** Get references for external use */
|
|
215
239
|
getPool(): AgentPool {
|
|
216
240
|
return this.pool;
|
|
@@ -308,6 +332,12 @@ export class Scheduler {
|
|
|
308
332
|
if (!this.running) return;
|
|
309
333
|
const squad = store.loadSquad(this.squadId);
|
|
310
334
|
if (!squad) return;
|
|
335
|
+
// Canonical integrity is a scheduling precondition, not merely a completion check.
|
|
336
|
+
if (squad.spec && !validateCanonicalSpec(squad)) {
|
|
337
|
+
await this.auditSpecAttestations();
|
|
338
|
+
debug("squad-scheduler", "reconcile: canonical spec integrity failure — scheduling quarantined");
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
311
341
|
|
|
312
342
|
const tasks = store.loadAllTasks(this.squadId);
|
|
313
343
|
|
|
@@ -387,8 +417,8 @@ export class Scheduler {
|
|
|
387
417
|
}
|
|
388
418
|
|
|
389
419
|
const squad = store.loadSquad(this.squadId);
|
|
390
|
-
if (!squad || squad.status !== "running") {
|
|
391
|
-
debug("squad-scheduler", `scheduleReadyTasks: squad status=${squad?.status}
|
|
420
|
+
if (!squad || squad.status !== "running" || (squad.spec && !validateCanonicalSpec(squad))) {
|
|
421
|
+
debug("squad-scheduler", `scheduleReadyTasks: squad unavailable, inactive, or canonical integrity invalid; status=${squad?.status}`);
|
|
392
422
|
return;
|
|
393
423
|
}
|
|
394
424
|
|
|
@@ -536,6 +566,7 @@ export class Scheduler {
|
|
|
536
566
|
},
|
|
537
567
|
cwd: squad.cwd,
|
|
538
568
|
skillPaths: this.skillPaths,
|
|
569
|
+
...(squad.spec ? { spec: { squadId: squad.id, path: squad.spec.path, sha256: squad.spec.sha256, bytes: squad.spec.bytes, chunkBytes: squad.spec.chunkBytes } } : {}),
|
|
539
570
|
...(resumeSession
|
|
540
571
|
? { resumeSession }
|
|
541
572
|
: forkSessionFile
|
|
@@ -684,16 +715,25 @@ export class Scheduler {
|
|
|
684
715
|
entries: TaskMailboxEntry[],
|
|
685
716
|
legacySeed?: { messages: TaskMessage[]; output: string | null },
|
|
686
717
|
): string {
|
|
687
|
-
const
|
|
718
|
+
const fileSpec = store.loadSquad(this.squadId)?.spec;
|
|
719
|
+
const lines = fileSpec
|
|
688
720
|
? [
|
|
689
|
-
|
|
690
|
-
|
|
721
|
+
`${resumed ? "Resume" : "Start"} file-spec squad task ${task.id}.`,
|
|
722
|
+
`Canonical spec: sha256=${fileSpec.sha256} bytes=${fileSpec.bytes} chunks=${fileSpec.chunkCount}.`,
|
|
723
|
+
"Use squad_spec_read to receive every canonical chunk before normal tools or completion.",
|
|
724
|
+
...(task.fileSpecDelta ? ["", `Dynamic task delta: ${task.title}`, task.description] : []),
|
|
725
|
+
...(resumed ? ["Continue from this task's durable Pi session and existing read coverage."] : []),
|
|
691
726
|
]
|
|
692
|
-
:
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
727
|
+
: resumed
|
|
728
|
+
? [
|
|
729
|
+
`Resume your existing task: ${task.title}`,
|
|
730
|
+
"Continue from the durable Pi session context. Do not restart the task from scratch.",
|
|
731
|
+
]
|
|
732
|
+
: [
|
|
733
|
+
`Your task: ${task.title}`,
|
|
734
|
+
"",
|
|
735
|
+
task.description || "",
|
|
736
|
+
];
|
|
697
737
|
|
|
698
738
|
if (legacySeed) {
|
|
699
739
|
const pendingIds = new Set(entries.map((entry) => entry.id));
|
|
@@ -854,8 +894,17 @@ export class Scheduler {
|
|
|
854
894
|
}
|
|
855
895
|
|
|
856
896
|
case "agent_settled": {
|
|
857
|
-
const
|
|
897
|
+
const settledTask = store.loadTask(this.squadId, event.taskId);
|
|
898
|
+
const status = settledTask?.status;
|
|
858
899
|
if (status === "cancelled" || status === "suspended") return;
|
|
900
|
+
const settledSquad = store.loadSquad(this.squadId);
|
|
901
|
+
if (settledTask && settledSquad && !validateTaskSpecAttestation(settledSquad, settledTask)) {
|
|
902
|
+
store.updateTaskStatus(this.squadId, event.taskId, "pending", { completed: null, output: null, error: "Canonical squad spec was not fully delivered; read all chunks with squad_spec_read" });
|
|
903
|
+
store.appendMessage(this.squadId, event.taskId, { ts: store.now(), from: "system", type: "status", text: "Completion rejected: missing or invalid spec-read-attestation; reopening same task session" });
|
|
904
|
+
if (this.running) void this.reconcile();
|
|
905
|
+
this.updateContext();
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
859
908
|
// A mailbox entry not acknowledged by Pi outranks this run's candidate
|
|
860
909
|
// completion. Reopen the same session so accepted-at-least-once delivery
|
|
861
910
|
// occurs before the task can become done.
|
|
@@ -913,7 +962,12 @@ export class Scheduler {
|
|
|
913
962
|
|
|
914
963
|
// Extract output from last messages
|
|
915
964
|
const messages = store.loadMessages(this.squadId, taskId);
|
|
965
|
+
const squad = store.loadSquad(this.squadId);
|
|
966
|
+
const rejectedAt = squad?.spec
|
|
967
|
+
? messages.reduce((last, message, index) => message.from === "system" && message.type === "status" && message.text.startsWith("Completion rejected: missing or invalid spec-read-attestation") ? index : last, -1)
|
|
968
|
+
: -1;
|
|
916
969
|
const agentMessages = messages
|
|
970
|
+
.slice(rejectedAt + 1)
|
|
917
971
|
.filter((m) => m.from === task.agent && (m.type === "text" || m.type === "done"));
|
|
918
972
|
const output = agentMessages.map((m) => m.text).join("\n");
|
|
919
973
|
|
|
@@ -1117,6 +1171,11 @@ export class Scheduler {
|
|
|
1117
1171
|
|
|
1118
1172
|
// Create rework task for the original agent
|
|
1119
1173
|
const reworkId = `${originalId}-fix-${fixN}`;
|
|
1174
|
+
const retestId = `${task.id}-retest-${fixN}`;
|
|
1175
|
+
if (squad.spec && (!isFileSpecTaskId(reworkId) || !isFileSpecTaskId(retestId))) {
|
|
1176
|
+
this.handleTaskFailed(task.id, `Generated file-spec rework IDs exceed the safe task-ID contract: ${reworkId}, ${retestId}`);
|
|
1177
|
+
return true;
|
|
1178
|
+
}
|
|
1120
1179
|
const reworkTask: Task = {
|
|
1121
1180
|
id: reworkId,
|
|
1122
1181
|
title: `Fix: ${implTask.title} (attempt ${fixN})`,
|
|
@@ -1125,6 +1184,7 @@ export class Scheduler {
|
|
|
1125
1184
|
status: "pending",
|
|
1126
1185
|
depends: [],
|
|
1127
1186
|
...(implTask.inheritContext ? { inheritContext: true } : {}),
|
|
1187
|
+
...(squad.spec ? { fileSpecDelta: true } : {}),
|
|
1128
1188
|
created: store.now(),
|
|
1129
1189
|
started: null,
|
|
1130
1190
|
completed: null,
|
|
@@ -1138,7 +1198,6 @@ export class Scheduler {
|
|
|
1138
1198
|
store.createTask(this.squadId, reworkTask);
|
|
1139
1199
|
|
|
1140
1200
|
// Create retest task for QA
|
|
1141
|
-
const retestId = `${task.id}-retest-${fixN}`;
|
|
1142
1201
|
const retestTask: Task = {
|
|
1143
1202
|
id: retestId,
|
|
1144
1203
|
title: `Re-test: ${implTask.title} (after fix ${fixN})`,
|
|
@@ -1146,6 +1205,7 @@ export class Scheduler {
|
|
|
1146
1205
|
agent: task.agent,
|
|
1147
1206
|
status: "blocked",
|
|
1148
1207
|
depends: [reworkId],
|
|
1208
|
+
...(squad.spec ? { fileSpecDelta: true } : {}),
|
|
1149
1209
|
created: store.now(),
|
|
1150
1210
|
started: null,
|
|
1151
1211
|
completed: null,
|
|
@@ -1227,6 +1287,18 @@ export class Scheduler {
|
|
|
1227
1287
|
private checkSquadCompletion(tasks: Task[], squad: Squad): void {
|
|
1228
1288
|
if (tasks.length === 0) return;
|
|
1229
1289
|
|
|
1290
|
+
const invalidDone = tasks.filter((task) => task.status === "done" && !validateTaskSpecAttestation(squad, task));
|
|
1291
|
+
if (invalidDone.length > 0) {
|
|
1292
|
+
for (const task of invalidDone) {
|
|
1293
|
+
store.updateTaskStatus(this.squadId, task.id, "pending", { completed: null, output: null, error: "Missing or invalid canonical spec read attestation" });
|
|
1294
|
+
store.appendMessage(this.squadId, task.id, { ts: store.now(), from: "system", type: "status", text: "Completion invalidated: canonical spec attestation is missing or invalid" });
|
|
1295
|
+
void this.invalidateDescendants(task.id);
|
|
1296
|
+
}
|
|
1297
|
+
if (squad.status === "review") { squad.status = "running"; delete squad.review; store.saveSquad(squad); }
|
|
1298
|
+
if (this.running) void this.reconcile();
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1230
1302
|
const relevant = tasks.filter((task) => task.status !== "cancelled");
|
|
1231
1303
|
const allDone = relevant.every((task) => task.status === "done");
|
|
1232
1304
|
const anyFailed = relevant.some((task) => task.status === "failed");
|
|
@@ -1617,6 +1689,8 @@ export class Scheduler {
|
|
|
1617
1689
|
if (!task) throw new Error(`Task not found: ${taskId}`);
|
|
1618
1690
|
if (task.status === "done") return;
|
|
1619
1691
|
if (task.status === "cancelled") throw new Error(`Task '${taskId}' is cancelled; resume it before marking it done.`);
|
|
1692
|
+
const squad = store.loadSquad(this.squadId);
|
|
1693
|
+
if (squad && !validateTaskSpecAttestation(squad, task)) throw new Error(`Task '${taskId}' cannot complete: missing or invalid canonical spec read attestation.`);
|
|
1620
1694
|
|
|
1621
1695
|
if (this.pool.isRunning(taskId)) {
|
|
1622
1696
|
await this.pool.kill(taskId);
|
package/src/store.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import * as os from "node:os";
|
|
12
|
-
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
13
13
|
import type {
|
|
14
14
|
AgentDef,
|
|
15
15
|
KnowledgeEntry,
|
|
@@ -314,13 +314,50 @@ export function saveSquad(squad: Squad): void {
|
|
|
314
314
|
writeJsonAtomic(getSquadFilePath(squad.id), squad);
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
/** Atomically publish a new file-spec squad, canonical bytes and initial tasks as one directory rename. */
|
|
318
|
+
export function publishFileSquad(squad: Squad, tasks: Task[], canonicalBytes: Buffer): void {
|
|
319
|
+
if (!squad.spec) throw new Error("PUBLISH_FAILED: file squad is missing spec metadata");
|
|
320
|
+
const root = path.resolve(getSquadRoot()); ensureDir(root);
|
|
321
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/.test(squad.id)) throw new Error(`PUBLISH_FAILED: unsafe squad id ${squad.id}`);
|
|
322
|
+
const finalDir = path.resolve(getSquadDir(squad.id));
|
|
323
|
+
if (path.dirname(finalDir) !== root) throw new Error(`PUBLISH_FAILED: squad destination escapes root`);
|
|
324
|
+
const expectedSpecPath = path.join(finalDir, "spec", "spec.v1.json");
|
|
325
|
+
if (path.resolve(squad.spec.path) !== expectedSpecPath || canonicalBytes.length !== squad.spec.bytes || createHash("sha256").update(canonicalBytes).digest("hex") !== squad.spec.sha256) throw new Error("PUBLISH_FAILED: canonical bytes or destination do not match spec metadata");
|
|
326
|
+
const taskIds = new Set<string>();
|
|
327
|
+
for (const task of tasks) {
|
|
328
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(task.id) || taskIds.has(task.id)) throw new Error(`PUBLISH_FAILED: unsafe or duplicate task id ${task.id}`);
|
|
329
|
+
taskIds.add(task.id);
|
|
330
|
+
}
|
|
331
|
+
if (fs.existsSync(finalDir)) throw new Error(`PUBLISH_FAILED: squad ${squad.id} already exists`);
|
|
332
|
+
const stagingDir = path.join(root, `${squad.id}.creating.${randomUUID()}`);
|
|
333
|
+
fs.mkdirSync(stagingDir, { mode: 0o700 });
|
|
334
|
+
try {
|
|
335
|
+
const writeDurable = (filePath: string, content: string): void => { const fd = fs.openSync(filePath, "wx", 0o600); try { fs.writeFileSync(fd, content); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } };
|
|
336
|
+
const specDir = path.join(stagingDir, "spec"); fs.mkdirSync(specDir, { mode: 0o700 });
|
|
337
|
+
const specPath = path.join(specDir, "spec.v1.json"); const specFd = fs.openSync(specPath, "wx", 0o600);
|
|
338
|
+
try { fs.writeFileSync(specFd, canonicalBytes); fs.fsyncSync(specFd); } finally { fs.closeSync(specFd); }
|
|
339
|
+
try { fs.chmodSync(specPath, 0o400); } catch { /* Windows/best effort */ }
|
|
340
|
+
for (const task of tasks) {
|
|
341
|
+
const taskDir = path.join(stagingDir, task.id); fs.mkdirSync(taskDir, { mode: 0o700 });
|
|
342
|
+
writeDurable(path.join(taskDir, "task.json"), JSON.stringify(task, null, 2) + "\n");
|
|
343
|
+
}
|
|
344
|
+
writeDurable(path.join(stagingDir, "squad.json"), JSON.stringify(squad, null, 2) + "\n");
|
|
345
|
+
for (const dir of [specDir, stagingDir]) { try { const fd = fs.openSync(dir, fs.constants.O_RDONLY); try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } } catch { /* directory fsync unavailable */ } }
|
|
346
|
+
fs.renameSync(stagingDir, finalDir);
|
|
347
|
+
try { const fd = fs.openSync(root, fs.constants.O_RDONLY); try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } } catch { /* directory fsync unavailable */ }
|
|
348
|
+
} catch (error) {
|
|
349
|
+
try { fs.rmSync(stagingDir, { recursive: true, force: true }); } catch { /* preserve original failure */ }
|
|
350
|
+
throw new Error(`PUBLISH_FAILED: ${(error as Error).message}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
317
354
|
export function listSquads(): string[] {
|
|
318
355
|
const root = getSquadRoot();
|
|
319
356
|
if (!fs.existsSync(root)) return [];
|
|
320
357
|
return fs
|
|
321
358
|
.readdirSync(root)
|
|
322
359
|
.filter((entry) => {
|
|
323
|
-
if (entry === "agents" || entry === "memory.jsonl") return false;
|
|
360
|
+
if (entry === "agents" || entry === "memory.jsonl" || entry.includes(".creating.")) return false;
|
|
324
361
|
const squadFile = path.join(root, entry, "squad.json");
|
|
325
362
|
return fs.existsSync(squadFile);
|
|
326
363
|
});
|
package/src/types.ts
CHANGED
|
@@ -123,6 +123,8 @@ export interface Squad {
|
|
|
123
123
|
/** Agent name → overrides. Keys must exist in .pi/squad/agents/ */
|
|
124
124
|
agents: Record<string, SquadAgentEntry>;
|
|
125
125
|
config: SquadConfig;
|
|
126
|
+
/** Immutable canonical contract for file-based squads; absent for legacy inline squads. */
|
|
127
|
+
spec?: { schemaVersion: 1; sha256: string; bytes: number; path: string; chunkBytes: 32768; chunkCount: number };
|
|
126
128
|
/** Mandatory independent main-session review; absent while rework is running. */
|
|
127
129
|
review?: SquadReview;
|
|
128
130
|
/** Completed prior review attempts retained as same-squad audit evidence. */
|
|
@@ -170,6 +172,8 @@ export interface Task {
|
|
|
170
172
|
usage: TaskUsage;
|
|
171
173
|
/** Durable Pi context for this task. Absent until its first process creates a session. */
|
|
172
174
|
session?: TaskSession;
|
|
175
|
+
/** Dynamic task delta created after immutable file-spec publication. */
|
|
176
|
+
fileSpecDelta?: boolean;
|
|
173
177
|
/** If this is a rework task, the original task ID it's fixing */
|
|
174
178
|
retryOf?: string;
|
|
175
179
|
/** How many times this task chain has been retried */
|
|
@@ -298,7 +302,7 @@ export interface SupervisorResult {
|
|
|
298
302
|
// ============================================================================
|
|
299
303
|
|
|
300
304
|
export interface PlannerOutput {
|
|
301
|
-
agents: Record<string,
|
|
305
|
+
agents: Record<string, SquadAgentEntry>;
|
|
302
306
|
tasks: Array<{
|
|
303
307
|
id: string;
|
|
304
308
|
title: string;
|