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/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}, skipping`);
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 lines = resumed
718
+ const fileSpec = store.loadSquad(this.squadId)?.spec;
719
+ const lines = fileSpec
688
720
  ? [
689
- `Resume your existing task: ${task.title}`,
690
- "Continue from the durable Pi session context. Do not restart the task from scratch.",
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
- `Your task: ${task.title}`,
694
- "",
695
- task.description || "",
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 status = store.loadTask(this.squadId, event.taskId)?.status;
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, { model?: string; thinking?: string }>;
305
+ agents: Record<string, SquadAgentEntry>;
302
306
  tasks: Array<{
303
307
  id: string;
304
308
  title: string;