pi-squad 0.16.7 → 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.
package/src/index.ts CHANGED
@@ -14,7 +14,7 @@ import * as path from "node:path";
14
14
  import * as fs from "node:fs";
15
15
  import { Type } from "typebox";
16
16
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
- import type { Squad, Task, SquadConfig, PlannerOutput, SuspendedStallAttention } from "./types.js";
17
+ import type { Squad, Task, SquadConfig, SquadAgentEntry, PlannerOutput, SuspendedStallAttention } from "./types.js";
18
18
  import { DEFAULT_SQUAD_CONFIG, THINKING_LEVELS } from "./types.js";
19
19
  import { Scheduler, formatSuspendedStallAttention, type SchedulerEvent, type SchedulerSpawnContext } from "./scheduler.js";
20
20
  import { runPlanner } from "./planner.js";
@@ -28,11 +28,19 @@ import { debug, logError } from "./logger.js";
28
28
  import { buildCompletionSummary, buildFailureSummary } from "./report.js";
29
29
  import { buildOrchestratorReviewGate, recordOrchestratorReview } from "./review.js";
30
30
  import { formatSuspendedAttention, getReviewPresentation } from "./presentation.js";
31
+ import { prepareSpec, chunkRanges, isFileSpecTaskId, registerChildSpecReader, type PreparedSpec } from "./file-spec.js";
31
32
 
32
33
  // ============================================================================
33
34
  // State
34
35
  // ============================================================================
35
36
 
37
+ interface InlineSquadStart {
38
+ goal: string;
39
+ agents?: Record<string, SquadAgentEntry>;
40
+ tasks?: Array<{ id: string; title: string; description?: string; agent: string; depends?: string[]; inheritContext?: boolean }>;
41
+ config?: { maxConcurrency?: number; autoUnblock?: boolean; maxRetries?: number };
42
+ }
43
+
36
44
  /** Master switch — when false, all squad tools, hooks, and widget are disabled */
37
45
  let squadEnabled = true;
38
46
  /** Registry of all running schedulers — supports multiple concurrent squads */
@@ -240,8 +248,8 @@ function resolveResumeSquad(cwd: string, explicitId?: string): Squad | null {
240
248
  // ============================================================================
241
249
 
242
250
  export default function (pi: ExtensionAPI) {
243
- // Don't load in child agent processes (prevent recursive squad-in-squad)
244
- if (process.env.PI_SQUAD_CHILD === "1") return;
251
+ // File-spec children load only the non-recursive reader and fail-closed guard.
252
+ if (process.env.PI_SQUAD_CHILD === "1") { registerChildSpecReader(pi); return; }
245
253
 
246
254
  // Wire main-session thinking lookup (needs `pi`, guarded against stale API)
247
255
  getMainSessionThinking = () => {
@@ -327,9 +335,12 @@ export default function (pi: ExtensionAPI) {
327
335
  }).join("\n");
328
336
 
329
337
  const reviewPresentation = getReviewPresentation(squad);
338
+ const squadReference = squad.spec
339
+ ? `file spec sha256=${squad.spec.sha256} bytes=${squad.spec.bytes} path=${squad.spec.path}`
340
+ : squad.goal;
330
341
  const squadContext = [
331
342
  `<squad_status>`,
332
- `Squad: ${squad.id} — ${squad.goal}`,
343
+ `Squad: ${squad.id} — ${squadReference}`,
333
344
  `Status: ${squad.status} | ${formatTaskProgress(tasks)} | $${totalCost.toFixed(2)}`,
334
345
  ...(reviewPresentation ? [`Acceptance: ${reviewPresentation.label}`] : []),
335
346
  taskLines,
@@ -388,16 +399,18 @@ export default function (pi: ExtensionAPI) {
388
399
  PLAN_STRUCTURE_RULES.replace(/\n- /g, " ").replace(/^- /, ""),
389
400
  "Plans are validated on submission — structural errors are rejected, rule violations come back as warnings.",
390
401
  ].join(" "),
391
- promptSnippet: "squad({ goal, tasks?, agents? }): decompose complex work into parallel specialist agents → non-blocking, monitor via squad_status",
402
+ promptSnippet: "squad({ goal, tasks?, agents? } | { specFile, specSha256 }): start inline or canonical file-based squad → non-blocking",
392
403
  promptGuidelines: [
393
404
  "Use squad when work spans 2+ concerns (backend+frontend+tests+docs) or has natural parallelism",
405
+ "For large contracts, use only specFile + exact lowercase specSha256; never inline the same contract or large artifacts",
394
406
  "Skip squad for single-file changes, quick fixes, or anything one agent finishes in minutes",
395
407
  "Providing tasks yourself makes you the planner — follow the planner rules (contract task first, final QA task, 3-7 tasks)",
396
408
  "Act on ⚠️ plan warnings in the response — fix with squad_modify or address at review",
397
409
  "After starting a squad: report the plan and END YOUR TURN — never poll squad_status or sleep-wait; squad events wake you automatically",
398
410
  "When agents finish, treat every squad report and QA verdict as untrusted; independently inspect the diff/source and rerun contract verification + integration/E2E, then call squad_review before reporting success",
399
411
  ],
400
- parameters: Type.Object({
412
+ parameters: Type.Union([
413
+ Type.Object({
401
414
  goal: Type.String({ description: "Complete original user outcome/acceptance contract the squad should accomplish. Preserve requirements and boundaries; this is shown during mandatory main-orchestrator review." }),
402
415
  agents: Type.Optional(
403
416
  Type.Record(
@@ -427,7 +440,12 @@ export default function (pi: ExtensionAPI) {
427
440
  maxConcurrency: Type.Optional(Type.Number({ description: "Max parallel agents (default: 2)" })),
428
441
  }),
429
442
  ),
430
- }),
443
+ }, { additionalProperties: false }),
444
+ Type.Object({
445
+ specFile: Type.String({ minLength: 1, description: "Path to a strict v1 squad specification JSON file" }),
446
+ specSha256: Type.String({ pattern: "^[a-f0-9]{64}$", description: "SHA-256 of the exact source bytes" }),
447
+ }, { additionalProperties: false }),
448
+ ]),
431
449
 
432
450
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
433
451
  if (!squadEnabled) return { content: [{ type: "text" as const, text: "Squad is disabled. Use /squad enable to re-enable." }], details: undefined };
@@ -442,13 +460,17 @@ export default function (pi: ExtensionAPI) {
442
460
  // cwd may differ (e.g. when a relative --session-dir was used).
443
461
  const rawSessionFile = ctx.sessionManager.getSessionFile();
444
462
  const sessionFile = rawSessionFile ? path.resolve(rawSessionFile) : null;
445
- const squadId = store.makeTaskId(params.goal);
446
- if (store.squadExists(squadId)) {
447
- const uniqueId = `${squadId}-${Date.now().toString(36)}`;
448
- return await startSquad(uniqueId, params, ctx.cwd, squadSkillPaths, pi, sessionFile);
463
+ let prepared: PreparedSpec | undefined;
464
+ let effective: InlineSquadStart;
465
+ if ("specFile" in params) {
466
+ prepared = prepareSpec(params.specFile, params.specSha256, ctx.cwd);
467
+ effective = { goal: prepared.spec.goal, agents: prepared.spec.agents, tasks: prepared.spec.tasks, config: prepared.spec.config };
468
+ } else {
469
+ effective = params;
449
470
  }
450
-
451
- return await startSquad(squadId, params, ctx.cwd, squadSkillPaths, pi, sessionFile);
471
+ const baseId = store.makeTaskId(effective.goal) || `squad-${prepared?.sha256.slice(0, 12)}`;
472
+ const squadId = store.squadExists(baseId) ? `${baseId}-${Date.now().toString(36)}` : baseId;
473
+ return await startSquad(squadId, effective, ctx.cwd, squadSkillPaths, pi, sessionFile, prepared);
452
474
  },
453
475
  });
454
476
 
@@ -562,6 +584,13 @@ export default function (pi: ExtensionAPI) {
562
584
  if (!squad) {
563
585
  return { content: [{ type: "text" as const, text: `Squad '${id}' not found.` }], details: undefined };
564
586
  }
587
+ const attestationScheduler = schedulers.get(id) ?? new Scheduler(id, squadSkillPaths, schedulerSpawnContext);
588
+ if (!schedulers.has(id)) { schedulers.set(id, attestationScheduler); wireSchedulerEvents(pi, attestationScheduler, id); }
589
+ const invalidAttestations = await attestationScheduler.auditSpecAttestations();
590
+ if (invalidAttestations.length > 0) {
591
+ void attestationScheduler.start();
592
+ return { content: [{ type: "text" as const, text: `Review rejected: invalid canonical spec attestation for task(s): ${invalidAttestations.join(", ")}. Work was reopened.` }], details: undefined };
593
+ }
565
594
 
566
595
  try {
567
596
  recordOrchestratorReview(squad, {
@@ -735,7 +764,11 @@ export default function (pi: ExtensionAPI) {
735
764
  return { content: [{ type: "text" as const, text: "Provide a task definition for add_task." }], details: undefined };
736
765
  }
737
766
  // Validate against the live squad: deps must exist, agent must exist
738
- const existing = store.loadAllTasks(activeSquadId);
767
+ const targetSquad = store.loadSquad(squadId)!;
768
+ if (targetSquad.spec && !isFileSpecTaskId(params.task.id)) {
769
+ return { content: [{ type: "text" as const, text: `Invalid file-spec task id '${params.task.id}'. Use 1..64 lowercase letters/digits with internal hyphens.` }], details: undefined };
770
+ }
771
+ const existing = store.loadAllTasks(squadId);
739
772
  const existingIds = new Set(existing.map((t) => t.id));
740
773
  if (existingIds.has(params.task.id)) {
741
774
  return { content: [{ type: "text" as const, text: `Task id '${params.task.id}' already exists in this squad.` }], details: undefined };
@@ -758,6 +791,7 @@ export default function (pi: ExtensionAPI) {
758
791
  status: dependencies.every((dependency) => existing.find((candidate) => candidate.id === dependency)?.status === "done") ? "pending" : "blocked",
759
792
  depends: dependencies,
760
793
  ...(params.task.inheritContext ? { inheritContext: true } : {}),
794
+ ...(targetSquad.spec ? { fileSpecDelta: true } : {}),
761
795
  created: store.now(),
762
796
  started: null,
763
797
  completed: null,
@@ -824,7 +858,7 @@ export default function (pi: ExtensionAPI) {
824
858
  }
825
859
 
826
860
  case "pause": {
827
- const squad = store.loadSquad(activeSquadId);
861
+ const squad = store.loadSquad(squadId);
828
862
  if (squad) {
829
863
  squad.status = "paused";
830
864
  store.saveSquad(squad);
@@ -874,6 +908,15 @@ export default function (pi: ExtensionAPI) {
874
908
  }
875
909
  }
876
910
 
911
+ // Audit file-spec evidence on restart. Review/running/failed work is reopened;
912
+ // an explicitly paused squad remains paused and suspended/cancelled tasks stay untouched.
913
+ for (const squad of store.listSquadsForProject(ctx.cwd).filter((candidate) => candidate.spec && ["running", "failed", "paused", "review"].includes(candidate.status))) {
914
+ let scheduler = schedulers.get(squad.id);
915
+ if (!scheduler) { scheduler = new Scheduler(squad.id, squadSkillPaths, schedulerSpawnContext); schedulers.set(squad.id, scheduler); wireSchedulerEvents(pi, scheduler, squad.id); }
916
+ const invalid = await scheduler.auditSpecAttestations();
917
+ if (invalid.length > 0 && squad.status !== "paused") await scheduler.start();
918
+ }
919
+
877
920
  // Reconstruct schedulers for explicit-suspension stalls without resuming any
878
921
  // task. Reconcile derives/persists a missing outbox record and emits only a
879
922
  // pending fingerprint; delivered attention remains durable and silent.
@@ -923,7 +966,7 @@ export default function (pi: ExtensionAPI) {
923
966
  if (done > 0) {
924
967
  pi.sendMessage({
925
968
  customType: "squad-paused",
926
- content: `[squad] Found paused squad "${squad.id}" (${squad.goal}) — ${formatTaskProgress(tasks)}. ` +
969
+ content: `[squad] Found paused squad "${squad.id}" (${squad.spec ? `file spec sha256=${squad.spec.sha256}` : squad.goal}) — ${formatTaskProgress(tasks)}. ` +
927
970
  `Use squad_modify with action "resume" to continue, or start a new squad.`,
928
971
  display: true,
929
972
  });
@@ -1757,23 +1800,12 @@ function openPanel(
1757
1800
 
1758
1801
  async function startSquad(
1759
1802
  squadId: string,
1760
- params: {
1761
- goal: string;
1762
- agents?: Record<string, { model?: string; thinking?: string }>;
1763
- tasks?: Array<{
1764
- id: string;
1765
- title: string;
1766
- description?: string;
1767
- agent: string;
1768
- depends?: string[];
1769
- inheritContext?: boolean;
1770
- }>;
1771
- config?: { maxConcurrency?: number };
1772
- },
1803
+ params: InlineSquadStart,
1773
1804
  cwd: string,
1774
1805
  skillPaths: string[],
1775
1806
  pi: ExtensionAPI,
1776
1807
  sessionFile: string | null = null,
1808
+ preparedSpec?: PreparedSpec,
1777
1809
  ) {
1778
1810
  let plan: PlannerOutput;
1779
1811
 
@@ -1791,7 +1823,8 @@ async function startSquad(
1791
1823
  // Validate agent names — remap unknown agents to fullstack
1792
1824
  for (const task of plan.tasks) {
1793
1825
  const agentDef = store.loadAgentDef(task.agent, cwd);
1794
- if (!agentDef) {
1826
+ if (!agentDef || agentDef.disabled) {
1827
+ if (preparedSpec) throw new Error(`SPEC_MALFORMED: assigned agent '${task.agent}' is missing or disabled`);
1795
1828
  const original = task.agent;
1796
1829
  task.agent = "fullstack";
1797
1830
  task.description = `[Note: agent "${original}" not found, remapped to fullstack]\n\n${task.description}`;
@@ -1809,7 +1842,7 @@ async function startSquad(
1809
1842
  }
1810
1843
 
1811
1844
  // Merge agent roster
1812
- const agents: Record<string, { model?: string; thinking?: string }> = { ...plan.agents };
1845
+ const agents: Record<string, SquadAgentEntry> = { ...plan.agents };
1813
1846
  if (params.agents) {
1814
1847
  for (const [name, entry] of Object.entries(params.agents)) {
1815
1848
  agents[name] = { ...agents[name], ...entry };
@@ -1829,6 +1862,8 @@ async function startSquad(
1829
1862
  const config: SquadConfig = {
1830
1863
  ...DEFAULT_SQUAD_CONFIG,
1831
1864
  ...(params.config?.maxConcurrency ? { maxConcurrency: params.config.maxConcurrency } : {}),
1865
+ ...(typeof params.config?.autoUnblock === "boolean" ? { autoUnblock: params.config.autoUnblock } : {}),
1866
+ ...(typeof params.config?.maxRetries === "number" ? { maxRetries: params.config.maxRetries } : {}),
1832
1867
  };
1833
1868
 
1834
1869
  const squad: Squad = {
@@ -1840,12 +1875,18 @@ async function startSquad(
1840
1875
  sessionFile,
1841
1876
  agents,
1842
1877
  config,
1878
+ ...(preparedSpec ? { spec: {
1879
+ schemaVersion: 1 as const,
1880
+ sha256: preparedSpec.sha256,
1881
+ bytes: preparedSpec.raw.length,
1882
+ path: path.join(store.getSquadDir(squadId), "spec", "spec.v1.json"),
1883
+ chunkBytes: 32768 as const,
1884
+ chunkCount: chunkRanges(preparedSpec.raw).length,
1885
+ } } : {}),
1843
1886
  };
1844
1887
 
1845
- store.saveSquad(squad);
1846
-
1847
- // Create task files
1848
- for (const taskDef of plan.tasks) {
1888
+ // Materialize task state in memory so file squads can publish spec+squad+tasks atomically.
1889
+ const initialTasks: Task[] = plan.tasks.map((taskDef) => {
1849
1890
  const task: Task = {
1850
1891
  id: taskDef.id,
1851
1892
  title: taskDef.title,
@@ -1861,10 +1902,13 @@ async function startSquad(
1861
1902
  error: null,
1862
1903
  usage: { inputTokens: 0, outputTokens: 0, cost: 0, turns: 0 },
1863
1904
  };
1864
- // Note: unknown dependency references are hard validation errors above,
1865
- // so blocked tasks here always have resolvable deps.
1905
+ return task;
1906
+ });
1866
1907
 
1867
- store.createTask(squadId, task);
1908
+ if (preparedSpec) store.publishFileSquad(squad, initialTasks, preparedSpec.raw);
1909
+ else {
1910
+ store.saveSquad(squad);
1911
+ for (const task of initialTasks) store.createTask(squadId, task);
1868
1912
  }
1869
1913
 
1870
1914
  // Start scheduler
@@ -1888,13 +1932,16 @@ async function startSquad(
1888
1932
  logError("squad", `Scheduler start error: ${(err as Error).message}`);
1889
1933
  });
1890
1934
 
1891
- // Build response
1892
- const taskSummary = plan.tasks
1893
- .map((t) => {
1894
- const deps = t.depends.length > 0 ? ` (depends: ${t.depends.join(", ")})` : "";
1895
- return `${t.id} → ${t.agent}: ${t.title}${deps}`;
1896
- })
1897
- .join("\n");
1935
+ // Build response. File mode returns only the bounded descriptor; the canonical
1936
+ // contract is never reflected back into the main model's transport.
1937
+ const taskSummary = preparedSpec
1938
+ ? `Canonical spec: ${squad.spec!.path}\nSHA-256: ${squad.spec!.sha256}\nBytes: ${squad.spec!.bytes}\nTasks: ${plan.tasks.length}`
1939
+ : plan.tasks
1940
+ .map((t) => {
1941
+ const deps = t.depends.length > 0 ? ` (depends: ${t.depends.join(", ")})` : "";
1942
+ return `${t.id} → ${t.agent}: ${t.title}${deps}`;
1943
+ })
1944
+ .join("\n");
1898
1945
 
1899
1946
  return {
1900
1947
  content: [
package/src/protocol.ts CHANGED
@@ -311,6 +311,27 @@ export function buildAgentSystemPrompt(options: ProtocolBuildOptions): string {
311
311
  const { squadId, squad, task, agentDef, modifiedFiles, queuedMessages } = options;
312
312
  const allTasks = loadAllTasks(squadId);
313
313
 
314
+ if (squad.spec) {
315
+ const manifest = [
316
+ "# File-spec squad bootstrap",
317
+ `Squad ID: ${squad.id}`,
318
+ `Task ID: ${task.id}`,
319
+ `Spec SHA-256: ${squad.spec.sha256}`,
320
+ `Spec bytes: ${squad.spec.bytes}`,
321
+ `Chunk count: ${squad.spec.chunkCount}`,
322
+ "Read every canonical chunk with squad_spec_read before using normal tools or completing the task.",
323
+ ].join("\n");
324
+ const fileSections = [
325
+ manifest,
326
+ buildAgentIdentity(agentDef),
327
+ ...(task.fileSpecDelta ? [buildTaskSection(task), buildReworkContext(task, squadId)] : []),
328
+ buildChainContext(task, allTasks, squadId),
329
+ buildKnowledgeSection(squadId),
330
+ buildQueuedMessages(queuedMessages),
331
+ ].filter((section) => section.length > 0);
332
+ return fileSections.join("\n---\n\n");
333
+ }
334
+
314
335
  const sections = [
315
336
  buildSquadProtocol(task.agent, agentDef, squad),
316
337
  buildAgentIdentity(agentDef),
package/src/review.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import * as path from "node:path";
1
2
  import type { Squad, Task } from "./types.js";
2
3
 
3
4
  export type OrchestratorReviewVerdict = "pass" | "pass_with_issues" | "fail";
@@ -99,15 +100,18 @@ export function buildOrchestratorReviewGate(squad: Squad, tasks: Task[]): string
99
100
  const reviewAction = failed
100
101
  ? `This candidate was rejected. Do not submit another verdict yet. Start concrete rework in this same exact squad with squad_modify and squadId: "${squad.id}"; the failed evidence remains immutable history. After rework settles, a fresh REVIEW PENDING gate will require a new independent review.`
101
102
  : `This candidate is awaiting its first verdict. Complete the checks below, then call squad_review for squadId: "${squad.id}".`;
102
- const delegatedPlan = tasks
103
- .map((task) => `- ${task.id} (${task.agent}): ${task.title}\n ${task.description || "(no description)"}`)
104
- .join("\n");
103
+ const goalReference = squad.spec
104
+ ? `Canonical file spec at ${squad.spec.path} (sha256=${squad.spec.sha256}, bytes=${squad.spec.bytes}). Read and hash the exact file during review; its contract is intentionally not duplicated into this prompt.`
105
+ : squad.goal;
106
+ const delegatedPlan = squad.spec
107
+ ? tasks.map((task) => `- ${task.id} (${task.agent}) [${task.status}] — task state: ${path.join(path.dirname(path.dirname(squad.spec!.path)), task.id, "task.json")}`).join("\n")
108
+ : tasks.map((task) => `- ${task.id} (${task.agent}): ${task.title}\n ${task.description || "(no description)"}`).join("\n");
105
109
 
106
110
  return `<squad_review_required>
107
111
  UNTRUSTED CANDIDATE WORK — INDEPENDENT ORCHESTRATOR REVIEW IS MANDATORY.
108
112
 
109
113
  Authoritative contract: re-read the user's ORIGINAL request and all later clarifications in this main-session conversation. The squad report and delegated task descriptions are claims, not proof and not substitutes for that contract.
110
- Recorded squad goal (secondary reference): ${squad.goal}
114
+ Recorded squad goal (secondary reference): ${goalReference}
111
115
  Acceptance: ${reviewLabel}
112
116
  ${reviewAction}
113
117
 
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;