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/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";
@@ -22,17 +22,25 @@ import { validatePlan, PLAN_STRUCTURE_RULES } from "./plan-rules.js";
22
22
  import { ADVISOR_SYSTEM_PROMPT, buildAdvisorConsultText, type AdvisorConsultInput } from "./advisor.js";
23
23
  import { completeSimple, type Message, type TextContent } from "@earendil-works/pi-ai";
24
24
  import { SquadPanel, type SquadPanelResult } from "./panel/squad-panel.js";
25
- import { setupSquadWidget, type SquadWidgetState } from "./panel/squad-widget.js";
25
+ import { setupSquadWidget, type SquadWidgetControls, type SquadWidgetState } from "./panel/squad-widget.js";
26
26
  import * as store from "./store.js";
27
27
  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 */
@@ -45,7 +53,15 @@ let overlayOpen = false;
45
53
  let uiCtx: import("@earendil-works/pi-coding-agent").ExtensionContext | null = null;
46
54
  /** Component-based widget state + controls */
47
55
  const widgetState: SquadWidgetState = { squadId: null, enabled: true };
48
- let widgetControls: { requestUpdate: () => void; dispose: () => void } | null = null;
56
+ let widgetControls: SquadWidgetControls | null = null;
57
+
58
+ /** Keep main-session focus, compact widget, and detail panel targeting identical. */
59
+ function focusSquad(squadId: string | null): void {
60
+ activeSquadId = squadId;
61
+ widgetState.squadId = squadId;
62
+ if (squadId) widgetState.enabled = true;
63
+ widgetControls?.refreshNow();
64
+ }
49
65
 
50
66
  /** Format completion against active work while retaining cancelled history. */
51
67
  function formatTaskProgress(tasks: Task[]): string {
@@ -189,11 +205,10 @@ function getActiveScheduler(): Scheduler | null {
189
205
 
190
206
  /** Restore the single focus/widget invariant after destructive exact cancellation. */
191
207
  function repairFocusAfterCancellation(cancelledId: string): void {
192
- if (activeSquadId === cancelledId || (activeSquadId !== null && !store.loadSquad(activeSquadId))) {
193
- activeSquadId = null;
194
- }
195
- widgetState.squadId = activeSquadId;
196
- widgetControls?.requestUpdate();
208
+ const nextFocus = activeSquadId === cancelledId || (activeSquadId !== null && !store.loadSquad(activeSquadId))
209
+ ? null
210
+ : activeSquadId;
211
+ focusSquad(nextFocus);
197
212
  }
198
213
 
199
214
  function activeSuspendedAttentionForProject(cwd: string): Array<{ squadId: string; attention: SuspendedStallAttention }> {
@@ -210,10 +225,7 @@ function ensureScheduler(pi: ExtensionAPI, squadId: string, skillPaths: string[]
210
225
  schedulers.set(squadId, scheduler);
211
226
  wireSchedulerEvents(pi, scheduler, squadId);
212
227
  }
213
- activeSquadId = squadId;
214
- widgetState.squadId = squadId;
215
- widgetState.enabled = true;
216
- widgetControls?.requestUpdate();
228
+ focusSquad(squadId);
217
229
  return scheduler;
218
230
  }
219
231
 
@@ -240,8 +252,8 @@ function resolveResumeSquad(cwd: string, explicitId?: string): Squad | null {
240
252
  // ============================================================================
241
253
 
242
254
  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;
255
+ // File-spec children load only the non-recursive reader and fail-closed guard.
256
+ if (process.env.PI_SQUAD_CHILD === "1") { registerChildSpecReader(pi); return; }
245
257
 
246
258
  // Wire main-session thinking lookup (needs `pi`, guarded against stale API)
247
259
  getMainSessionThinking = () => {
@@ -301,8 +313,7 @@ export default function (pi: ExtensionAPI) {
301
313
  if (activeSquadId) {
302
314
  const squad = store.loadSquad(activeSquadId);
303
315
  if (!squad) {
304
- activeSquadId = null;
305
- widgetState.squadId = null;
316
+ focusSquad(null);
306
317
  if (durablePrompts.length > 0) {
307
318
  return { systemPrompt: event.systemPrompt + "\n\n" + durablePrompts.join("\n\n") };
308
319
  }
@@ -327,9 +338,12 @@ export default function (pi: ExtensionAPI) {
327
338
  }).join("\n");
328
339
 
329
340
  const reviewPresentation = getReviewPresentation(squad);
341
+ const squadReference = squad.spec
342
+ ? `file spec sha256=${squad.spec.sha256} bytes=${squad.spec.bytes} path=${squad.spec.path}`
343
+ : squad.goal;
330
344
  const squadContext = [
331
345
  `<squad_status>`,
332
- `Squad: ${squad.id} — ${squad.goal}`,
346
+ `Squad: ${squad.id} — ${squadReference}`,
333
347
  `Status: ${squad.status} | ${formatTaskProgress(tasks)} | $${totalCost.toFixed(2)}`,
334
348
  ...(reviewPresentation ? [`Acceptance: ${reviewPresentation.label}`] : []),
335
349
  taskLines,
@@ -388,16 +402,18 @@ export default function (pi: ExtensionAPI) {
388
402
  PLAN_STRUCTURE_RULES.replace(/\n- /g, " ").replace(/^- /, ""),
389
403
  "Plans are validated on submission — structural errors are rejected, rule violations come back as warnings.",
390
404
  ].join(" "),
391
- promptSnippet: "squad({ goal, tasks?, agents? }): decompose complex work into parallel specialist agents → non-blocking, monitor via squad_status",
405
+ promptSnippet: "squad({ goal, tasks?, agents? } | { specFile, specSha256 }): start inline or canonical file-based squad → non-blocking",
392
406
  promptGuidelines: [
393
407
  "Use squad when work spans 2+ concerns (backend+frontend+tests+docs) or has natural parallelism",
408
+ "For large contracts, use only specFile + exact lowercase specSha256; never inline the same contract or large artifacts",
394
409
  "Skip squad for single-file changes, quick fixes, or anything one agent finishes in minutes",
395
410
  "Providing tasks yourself makes you the planner — follow the planner rules (contract task first, final QA task, 3-7 tasks)",
396
411
  "Act on ⚠️ plan warnings in the response — fix with squad_modify or address at review",
397
412
  "After starting a squad: report the plan and END YOUR TURN — never poll squad_status or sleep-wait; squad events wake you automatically",
398
413
  "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
414
  ],
400
- parameters: Type.Object({
415
+ parameters: Type.Union([
416
+ Type.Object({
401
417
  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
418
  agents: Type.Optional(
403
419
  Type.Record(
@@ -427,7 +443,12 @@ export default function (pi: ExtensionAPI) {
427
443
  maxConcurrency: Type.Optional(Type.Number({ description: "Max parallel agents (default: 2)" })),
428
444
  }),
429
445
  ),
430
- }),
446
+ }, { additionalProperties: false }),
447
+ Type.Object({
448
+ specFile: Type.String({ minLength: 1, description: "Path to a strict v1 squad specification JSON file" }),
449
+ specSha256: Type.String({ pattern: "^[a-f0-9]{64}$", description: "SHA-256 of the exact source bytes" }),
450
+ }, { additionalProperties: false }),
451
+ ]),
431
452
 
432
453
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
433
454
  if (!squadEnabled) return { content: [{ type: "text" as const, text: "Squad is disabled. Use /squad enable to re-enable." }], details: undefined };
@@ -442,13 +463,17 @@ export default function (pi: ExtensionAPI) {
442
463
  // cwd may differ (e.g. when a relative --session-dir was used).
443
464
  const rawSessionFile = ctx.sessionManager.getSessionFile();
444
465
  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);
466
+ let prepared: PreparedSpec | undefined;
467
+ let effective: InlineSquadStart;
468
+ if ("specFile" in params) {
469
+ prepared = prepareSpec(params.specFile, params.specSha256, ctx.cwd);
470
+ effective = { goal: prepared.spec.goal, agents: prepared.spec.agents, tasks: prepared.spec.tasks, config: prepared.spec.config };
471
+ } else {
472
+ effective = params;
449
473
  }
450
-
451
- return await startSquad(squadId, params, ctx.cwd, squadSkillPaths, pi, sessionFile);
474
+ const baseId = store.makeTaskId(effective.goal) || `squad-${prepared?.sha256.slice(0, 12)}`;
475
+ const squadId = store.squadExists(baseId) ? `${baseId}-${Date.now().toString(36)}` : baseId;
476
+ return await startSquad(squadId, effective, ctx.cwd, squadSkillPaths, pi, sessionFile, prepared);
452
477
  },
453
478
  });
454
479
 
@@ -562,6 +587,13 @@ export default function (pi: ExtensionAPI) {
562
587
  if (!squad) {
563
588
  return { content: [{ type: "text" as const, text: `Squad '${id}' not found.` }], details: undefined };
564
589
  }
590
+ const attestationScheduler = schedulers.get(id) ?? new Scheduler(id, squadSkillPaths, schedulerSpawnContext);
591
+ if (!schedulers.has(id)) { schedulers.set(id, attestationScheduler); wireSchedulerEvents(pi, attestationScheduler, id); }
592
+ const invalidAttestations = await attestationScheduler.auditSpecAttestations();
593
+ if (invalidAttestations.length > 0) {
594
+ void attestationScheduler.start();
595
+ return { content: [{ type: "text" as const, text: `Review rejected: invalid canonical spec attestation for task(s): ${invalidAttestations.join(", ")}. Work was reopened.` }], details: undefined };
596
+ }
565
597
 
566
598
  try {
567
599
  recordOrchestratorReview(squad, {
@@ -727,7 +759,7 @@ export default function (pi: ExtensionAPI) {
727
759
  if (!activeScheduler) {
728
760
  return { content: [{ type: "text" as const, text: `Squad '${squadId}' has no active scheduler. Use resume, add_task, or resume_task to reconstruct it.` }], details: undefined };
729
761
  }
730
- activeSquadId = squadId;
762
+ focusSquad(squadId);
731
763
 
732
764
  switch (params.action) {
733
765
  case "add_task": {
@@ -735,7 +767,11 @@ export default function (pi: ExtensionAPI) {
735
767
  return { content: [{ type: "text" as const, text: "Provide a task definition for add_task." }], details: undefined };
736
768
  }
737
769
  // Validate against the live squad: deps must exist, agent must exist
738
- const existing = store.loadAllTasks(activeSquadId);
770
+ const targetSquad = store.loadSquad(squadId)!;
771
+ if (targetSquad.spec && !isFileSpecTaskId(params.task.id)) {
772
+ 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 };
773
+ }
774
+ const existing = store.loadAllTasks(squadId);
739
775
  const existingIds = new Set(existing.map((t) => t.id));
740
776
  if (existingIds.has(params.task.id)) {
741
777
  return { content: [{ type: "text" as const, text: `Task id '${params.task.id}' already exists in this squad.` }], details: undefined };
@@ -758,6 +794,7 @@ export default function (pi: ExtensionAPI) {
758
794
  status: dependencies.every((dependency) => existing.find((candidate) => candidate.id === dependency)?.status === "done") ? "pending" : "blocked",
759
795
  depends: dependencies,
760
796
  ...(params.task.inheritContext ? { inheritContext: true } : {}),
797
+ ...(targetSquad.spec ? { fileSpecDelta: true } : {}),
761
798
  created: store.now(),
762
799
  started: null,
763
800
  completed: null,
@@ -824,7 +861,7 @@ export default function (pi: ExtensionAPI) {
824
861
  }
825
862
 
826
863
  case "pause": {
827
- const squad = store.loadSquad(activeSquadId);
864
+ const squad = store.loadSquad(squadId);
828
865
  if (squad) {
829
866
  squad.status = "paused";
830
867
  store.saveSquad(squad);
@@ -848,6 +885,13 @@ export default function (pi: ExtensionAPI) {
848
885
  pi.on("session_start", async (_event, ctx) => {
849
886
  uiCtx = ctx;
850
887
 
888
+ // A session replacement may not deliver shutdown first. Never carry a
889
+ // focused squad across projects, and never seed a new widget from stale state.
890
+ widgetControls?.dispose();
891
+ widgetControls = null;
892
+ const focused = activeSquadId ? store.loadSquad(activeSquadId) : null;
893
+ focusSquad(focused?.cwd === ctx.cwd ? activeSquadId : null);
894
+
851
895
  // Install component-based widget
852
896
  if (ctx.hasUI) {
853
897
  widgetControls = setupSquadWidget(ctx, widgetState);
@@ -874,6 +918,15 @@ export default function (pi: ExtensionAPI) {
874
918
  }
875
919
  }
876
920
 
921
+ // Audit file-spec evidence on restart. Review/running/failed work is reopened;
922
+ // an explicitly paused squad remains paused and suspended/cancelled tasks stay untouched.
923
+ for (const squad of store.listSquadsForProject(ctx.cwd).filter((candidate) => candidate.spec && ["running", "failed", "paused", "review"].includes(candidate.status))) {
924
+ let scheduler = schedulers.get(squad.id);
925
+ if (!scheduler) { scheduler = new Scheduler(squad.id, squadSkillPaths, schedulerSpawnContext); schedulers.set(squad.id, scheduler); wireSchedulerEvents(pi, scheduler, squad.id); }
926
+ const invalid = await scheduler.auditSpecAttestations();
927
+ if (invalid.length > 0 && squad.status !== "paused") await scheduler.start();
928
+ }
929
+
877
930
  // Reconstruct schedulers for explicit-suspension stalls without resuming any
878
931
  // task. Reconcile derives/persists a missing outbox record and emits only a
879
932
  // pending fingerprint; delivered attention remains durable and silent.
@@ -905,12 +958,7 @@ export default function (pi: ExtensionAPI) {
905
958
  }
906
959
  await scheduler.start();
907
960
  }
908
- if (pendingMailSquads.length > 0) {
909
- activeSquadId = pendingMailSquads[0].id;
910
- widgetState.squadId = activeSquadId;
911
- widgetState.enabled = true;
912
- widgetControls?.requestUpdate();
913
- }
961
+ if (pendingMailSquads.length > 0) focusSquad(pendingMailSquads[0].id);
914
962
 
915
963
  // Notify about paused squads only if they have real completed work
916
964
  const paused = store.findActiveSquads()
@@ -923,7 +971,7 @@ export default function (pi: ExtensionAPI) {
923
971
  if (done > 0) {
924
972
  pi.sendMessage({
925
973
  customType: "squad-paused",
926
- content: `[squad] Found paused squad "${squad.id}" (${squad.goal}) — ${formatTaskProgress(tasks)}. ` +
974
+ content: `[squad] Found paused squad "${squad.id}" (${squad.spec ? `file spec sha256=${squad.spec.sha256}` : squad.goal}) — ${formatTaskProgress(tasks)}. ` +
927
975
  `Use squad_modify with action "resume" to continue, or start a new squad.`,
928
976
  display: true,
929
977
  });
@@ -936,10 +984,7 @@ export default function (pi: ExtensionAPI) {
936
984
  .filter((s) => s.cwd === ctx.cwd && s.status === "review");
937
985
  if (pendingReviews.length > 0) {
938
986
  const squad = pendingReviews.sort((a, b) => b.created.localeCompare(a.created))[0];
939
- activeSquadId = squad.id;
940
- widgetState.squadId = squad.id;
941
- widgetState.enabled = true;
942
- widgetControls?.requestUpdate();
987
+ focusSquad(squad.id);
943
988
  const tasks = store.loadAllTasks(squad.id);
944
989
  pi.sendMessage({
945
990
  customType: "squad-review-required",
@@ -978,13 +1023,13 @@ export default function (pi: ExtensionAPI) {
978
1023
  });
979
1024
 
980
1025
  pi.on("session_shutdown", async () => {
1026
+ focusSquad(null);
981
1027
  widgetControls?.dispose();
982
1028
  widgetControls = null;
983
1029
  for (const [id, sched] of schedulers) {
984
1030
  await sched.stop();
985
1031
  }
986
1032
  schedulers.clear();
987
- activeSquadId = null;
988
1033
  uiCtx = null;
989
1034
  });
990
1035
 
@@ -1209,8 +1254,7 @@ export default function (pi: ExtensionAPI) {
1209
1254
 
1210
1255
  case "clear": {
1211
1256
  if (activeSquadId) schedulers.delete(activeSquadId);
1212
- activeSquadId = null;
1213
- widgetState.squadId = null;
1257
+ focusSquad(null);
1214
1258
  widgetControls?.dispose();
1215
1259
  ctx.ui.notify("Squad view cleared", "info");
1216
1260
  return;
@@ -1231,9 +1275,7 @@ export default function (pi: ExtensionAPI) {
1231
1275
  await sched.stop();
1232
1276
  }
1233
1277
  schedulers.clear();
1234
- activeSquadId = null;
1235
- widgetState.squadId = null;
1236
- widgetControls?.requestUpdate();
1278
+ focusSquad(null);
1237
1279
 
1238
1280
  let count = 0;
1239
1281
  for (const id of allSquadIds) {
@@ -1269,9 +1311,7 @@ export default function (pi: ExtensionAPI) {
1269
1311
  await sched.stop();
1270
1312
  }
1271
1313
  schedulers.clear();
1272
- activeSquadId = null;
1273
- widgetState.squadId = null;
1274
- widgetControls?.requestUpdate();
1314
+ focusSquad(null);
1275
1315
  let count = 0;
1276
1316
  for (const id of allSquadIds) {
1277
1317
  fs.rmSync(store.getSquadDir(id), { recursive: true, force: true });
@@ -1289,11 +1329,7 @@ export default function (pi: ExtensionAPI) {
1289
1329
  await sched.stop();
1290
1330
  schedulers.delete(squad.id);
1291
1331
  }
1292
- if (activeSquadId === squad.id) {
1293
- activeSquadId = null;
1294
- widgetState.squadId = null;
1295
- widgetControls?.requestUpdate();
1296
- }
1332
+ if (activeSquadId === squad.id) focusSquad(null);
1297
1333
  fs.rmSync(store.getSquadDir(squad.id), { recursive: true, force: true });
1298
1334
  ctx.ui.notify(`Deleted: ${squad.id}`, "info");
1299
1335
  }
@@ -1315,10 +1351,8 @@ export default function (pi: ExtensionAPI) {
1315
1351
  await sched.stop();
1316
1352
  }
1317
1353
  schedulers.clear();
1318
- activeSquadId = null;
1319
- widgetState.squadId = null;
1320
1354
  widgetState.enabled = false;
1321
- widgetControls?.requestUpdate();
1355
+ focusSquad(null);
1322
1356
  ctx.ui.notify("pi-squad disabled — all tools, widget, and system prompt injection stopped", "info");
1323
1357
  return;
1324
1358
  }
@@ -1590,13 +1624,9 @@ function activateSquadView(squadId: string, ctx: import("@earendil-works/pi-codi
1590
1624
  return;
1591
1625
  }
1592
1626
 
1593
- activeSquadId = squadId;
1594
-
1595
- // Update widget to show the new squad. The widget reads squadId on each
1596
- // render, so just updating the state and requesting a render is enough.
1597
- widgetState.squadId = squadId;
1598
- widgetState.enabled = true;
1599
- widgetControls?.requestUpdate();
1627
+ // Selection is one atomic focus operation: panel, widget, status, and tools
1628
+ // must all target this exact squad before control returns to the caller.
1629
+ focusSquad(squadId);
1600
1630
 
1601
1631
  // Compact notification — widget already shows full task details.
1602
1632
  // Avoid large multi-line notifications that can break TUI layout.
@@ -1700,6 +1730,9 @@ function openPanel(
1700
1730
  squadId: string,
1701
1731
  ): void {
1702
1732
  if (overlayOpen) return;
1733
+ // Opening details also establishes authoritative focus. This covers callers
1734
+ // that reconstructed or targeted a scheduler without going through selector UI.
1735
+ focusSquad(squadId);
1703
1736
  overlayOpen = true;
1704
1737
 
1705
1738
  // The promise resolves when the panel calls done()
@@ -1757,23 +1790,12 @@ function openPanel(
1757
1790
 
1758
1791
  async function startSquad(
1759
1792
  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
- },
1793
+ params: InlineSquadStart,
1773
1794
  cwd: string,
1774
1795
  skillPaths: string[],
1775
1796
  pi: ExtensionAPI,
1776
1797
  sessionFile: string | null = null,
1798
+ preparedSpec?: PreparedSpec,
1777
1799
  ) {
1778
1800
  let plan: PlannerOutput;
1779
1801
 
@@ -1791,7 +1813,8 @@ async function startSquad(
1791
1813
  // Validate agent names — remap unknown agents to fullstack
1792
1814
  for (const task of plan.tasks) {
1793
1815
  const agentDef = store.loadAgentDef(task.agent, cwd);
1794
- if (!agentDef) {
1816
+ if (!agentDef || agentDef.disabled) {
1817
+ if (preparedSpec) throw new Error(`SPEC_MALFORMED: assigned agent '${task.agent}' is missing or disabled`);
1795
1818
  const original = task.agent;
1796
1819
  task.agent = "fullstack";
1797
1820
  task.description = `[Note: agent "${original}" not found, remapped to fullstack]\n\n${task.description}`;
@@ -1809,7 +1832,7 @@ async function startSquad(
1809
1832
  }
1810
1833
 
1811
1834
  // Merge agent roster
1812
- const agents: Record<string, { model?: string; thinking?: string }> = { ...plan.agents };
1835
+ const agents: Record<string, SquadAgentEntry> = { ...plan.agents };
1813
1836
  if (params.agents) {
1814
1837
  for (const [name, entry] of Object.entries(params.agents)) {
1815
1838
  agents[name] = { ...agents[name], ...entry };
@@ -1829,6 +1852,8 @@ async function startSquad(
1829
1852
  const config: SquadConfig = {
1830
1853
  ...DEFAULT_SQUAD_CONFIG,
1831
1854
  ...(params.config?.maxConcurrency ? { maxConcurrency: params.config.maxConcurrency } : {}),
1855
+ ...(typeof params.config?.autoUnblock === "boolean" ? { autoUnblock: params.config.autoUnblock } : {}),
1856
+ ...(typeof params.config?.maxRetries === "number" ? { maxRetries: params.config.maxRetries } : {}),
1832
1857
  };
1833
1858
 
1834
1859
  const squad: Squad = {
@@ -1840,12 +1865,18 @@ async function startSquad(
1840
1865
  sessionFile,
1841
1866
  agents,
1842
1867
  config,
1868
+ ...(preparedSpec ? { spec: {
1869
+ schemaVersion: 1 as const,
1870
+ sha256: preparedSpec.sha256,
1871
+ bytes: preparedSpec.raw.length,
1872
+ path: path.join(store.getSquadDir(squadId), "spec", "spec.v1.json"),
1873
+ chunkBytes: 32768 as const,
1874
+ chunkCount: chunkRanges(preparedSpec.raw).length,
1875
+ } } : {}),
1843
1876
  };
1844
1877
 
1845
- store.saveSquad(squad);
1846
-
1847
- // Create task files
1848
- for (const taskDef of plan.tasks) {
1878
+ // Materialize task state in memory so file squads can publish spec+squad+tasks atomically.
1879
+ const initialTasks: Task[] = plan.tasks.map((taskDef) => {
1849
1880
  const task: Task = {
1850
1881
  id: taskDef.id,
1851
1882
  title: taskDef.title,
@@ -1861,21 +1892,20 @@ async function startSquad(
1861
1892
  error: null,
1862
1893
  usage: { inputTokens: 0, outputTokens: 0, cost: 0, turns: 0 },
1863
1894
  };
1864
- // Note: unknown dependency references are hard validation errors above,
1865
- // so blocked tasks here always have resolvable deps.
1895
+ return task;
1896
+ });
1866
1897
 
1867
- store.createTask(squadId, task);
1898
+ if (preparedSpec) store.publishFileSquad(squad, initialTasks, preparedSpec.raw);
1899
+ else {
1900
+ store.saveSquad(squad);
1901
+ for (const task of initialTasks) store.createTask(squadId, task);
1868
1902
  }
1869
1903
 
1870
1904
  // Start scheduler
1871
1905
  const scheduler = new Scheduler(squadId, skillPaths, schedulerSpawnContext);
1872
1906
  schedulers.set(squadId, scheduler);
1873
- activeSquadId = squadId;
1874
-
1875
- // Activate widget for this squad
1876
- widgetState.squadId = squadId;
1877
- widgetState.enabled = true;
1878
- widgetControls?.requestUpdate();
1907
+ // Activate panel/widget/tool focus as one invariant.
1908
+ focusSquad(squadId);
1879
1909
 
1880
1910
  // Wire up completion/escalation notifications to main agent.
1881
1911
  wireSchedulerEvents(pi, scheduler, squadId);
@@ -1888,13 +1918,16 @@ async function startSquad(
1888
1918
  logError("squad", `Scheduler start error: ${(err as Error).message}`);
1889
1919
  });
1890
1920
 
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");
1921
+ // Build response. File mode returns only the bounded descriptor; the canonical
1922
+ // contract is never reflected back into the main model's transport.
1923
+ const taskSummary = preparedSpec
1924
+ ? `Canonical spec: ${squad.spec!.path}\nSHA-256: ${squad.spec!.sha256}\nBytes: ${squad.spec!.bytes}\nTasks: ${plan.tasks.length}`
1925
+ : plan.tasks
1926
+ .map((t) => {
1927
+ const deps = t.depends.length > 0 ? ` (depends: ${t.depends.join(", ")})` : "";
1928
+ return `${t.id} → ${t.agent}: ${t.title}${deps}`;
1929
+ })
1930
+ .join("\n");
1898
1931
 
1899
1932
  return {
1900
1933
  content: [
@@ -43,6 +43,12 @@ export interface SquadWidgetState {
43
43
  enabled: boolean;
44
44
  }
45
45
 
46
+ export interface SquadWidgetControls {
47
+ requestUpdate: () => void;
48
+ refreshNow: () => void;
49
+ dispose: () => void;
50
+ }
51
+
46
52
  /**
47
53
  * Set up the squad widget. Returns control functions.
48
54
  *
@@ -53,11 +59,8 @@ export interface SquadWidgetState {
53
59
  export function setupSquadWidget(
54
60
  ctx: { ui: { setWidget: Function; setStatus: Function; [key: string]: any }; hasUI?: boolean },
55
61
  state: SquadWidgetState,
56
- ): {
57
- requestUpdate: () => void;
58
- dispose: () => void;
59
- } {
60
- if (!ctx.hasUI) return { requestUpdate: () => {}, dispose: () => {} };
62
+ ): SquadWidgetControls {
63
+ if (!ctx.hasUI) return { requestUpdate: () => {}, refreshNow: () => {}, dispose: () => {} };
61
64
 
62
65
  let durationTimer: ReturnType<typeof setInterval> | null = null;
63
66
  let renderTimer: ReturnType<typeof setTimeout> | null = null;
@@ -269,9 +272,14 @@ export function setupSquadWidget(
269
272
  }
270
273
  }
271
274
 
275
+ function refreshNow(): void {
276
+ if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; }
277
+ render();
278
+ manageDurationTimer();
279
+ }
280
+
272
281
  // Initial render
273
- render();
274
- manageDurationTimer();
282
+ refreshNow();
275
283
 
276
284
  return {
277
285
  requestUpdate(): void {
@@ -279,14 +287,17 @@ export function setupSquadWidget(
279
287
  if (renderTimer) return;
280
288
  renderTimer = setTimeout(() => {
281
289
  renderTimer = null;
282
- render();
283
- manageDurationTimer();
290
+ refreshNow();
284
291
  }, 50);
285
292
  },
293
+ refreshNow,
286
294
  dispose(): void {
287
295
  if (durationTimer) { clearInterval(durationTimer); durationTimer = null; }
288
296
  if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; }
289
297
  lastCacheKey = "";
298
+ currentLines = [];
299
+ widgetInstalled = false;
300
+ widgetComponent = null;
290
301
  ctx.ui.setWidget("squad-tasks", undefined);
291
302
  ctx.ui.setStatus("squad", undefined);
292
303
  },
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