pi-squad 0.5.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squad",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Multi-agent collaboration extension for pi — task decomposition, dependency management, parallel execution, TUI panel",
5
5
  "type": "module",
6
6
  "pi": {
@@ -8,7 +8,7 @@
8
8
  "src/index.ts"
9
9
  ],
10
10
  "skills": [
11
- "src/skills/supervisor"
11
+ "src/skills/squad-supervisor"
12
12
  ]
13
13
  },
14
14
  "files": [
package/src/agent-pool.ts CHANGED
@@ -11,6 +11,7 @@ import * as os from "node:os";
11
11
  import * as path from "node:path";
12
12
  import type { AgentDef, AgentActivity, Task, TaskMessage } from "./types.js";
13
13
  import { buildAgentSystemPrompt, type ProtocolBuildOptions } from "./protocol.js";
14
+ import { debug, logError } from "./logger.js";
14
15
 
15
16
  // ============================================================================
16
17
  // Types
@@ -165,7 +166,7 @@ export class AgentPool {
165
166
 
166
167
  // Spawn pi process — set env var to prevent recursive squad extension loading
167
168
  const invocation = getPiInvocation(["--mode", "rpc", ...args]);
168
- console.error(`[squad-pool] spawn ${agentDef.name}: ${invocation.command} ${invocation.args.slice(0, 3).join(" ")} ...`);
169
+ debug("squad-pool", `spawn ${agentDef.name}: ${invocation.command} ${invocation.args.slice(0, 3).join(" ")} ...`);
169
170
  const proc = spawn(invocation.command, invocation.args, {
170
171
  cwd,
171
172
  stdio: ["pipe", "pipe", "pipe"],
@@ -214,7 +215,7 @@ export class AgentPool {
214
215
  proc.on("exit", (code, signal) => {
215
216
  // Log diagnostic info for debugging spawn failures
216
217
  if (code !== 0 && code !== null) {
217
- console.error(`[squad-pool] ${agentDef.name} exited: code=${code} signal=${signal} pid=${proc.pid} stdoutLines=${stdoutLines} stderr=${stderr.slice(0, 300) || "(empty)"}`);
218
+ logError("squad-pool", `${agentDef.name} exited: code=${code} signal=${signal} pid=${proc.pid} stdoutLines=${stdoutLines} stderr=${stderr.slice(0, 300) || "(empty)"}`);
218
219
  }
219
220
  // Clean up agents map so getRunningAgents() doesn't count dead processes
220
221
  this.agents.delete(taskId);
@@ -377,7 +378,7 @@ export class AgentPool {
377
378
  // Pi RPC mode emits agent_end when the agent loop finishes.
378
379
  // The RPC process stays alive waiting for more commands,
379
380
  // so we need to explicitly kill it and emit our own agent_end.
380
- console.error(`[squad-pool] agent_end from RPC: ${agent.agentName} (task: ${agent.taskId})`);
381
+ debug("squad-pool", `agent_end from RPC: ${agent.agentName} (task: ${agent.taskId})`);
381
382
  // Mark the guard to prevent double-emit from proc.on("exit")
382
383
  const guardFn = (agent as any)._agentEndEmitted;
383
384
  if (guardFn) guardFn();
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ import { runPlanner } from "./planner.js";
21
21
  import { SquadPanel, type SquadPanelResult } from "./panel/squad-panel.js";
22
22
  import { setupSquadWidget, type SquadWidgetState } from "./panel/squad-widget.js";
23
23
  import * as store from "./store.js";
24
+ import { debug, logError } from "./logger.js";
24
25
 
25
26
  // ============================================================================
26
27
  // State
@@ -348,15 +349,14 @@ export default function (pi: ExtensionAPI) {
348
349
  switch (event.type) {
349
350
  case "squad_completed": {
350
351
  const tasks = store.loadAllTasks(squadId);
351
- const summary = tasks
352
- .filter((t) => t.status === "done")
353
- .map((t) => `- ${t.id} (${t.agent}): ${t.output?.slice(0, 150) || "done"}`)
354
- .join("\n");
355
352
  const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
356
353
  const s = schedulers.get(squadId); if (s) s.updateContext();
354
+ const overview = store.loadOverview(squadId);
357
355
  pi.sendMessage({
358
356
  customType: "squad-completed",
359
- content: `[squad] Squad "${squadId}" completed all ${tasks.length} tasks.\n\nSummary:\n${summary}\n\nTotal cost: $${totalCost.toFixed(4)}`,
357
+ content: `[squad] Squad "${squadId}" completed all ${tasks.length} tasks.\n\n` +
358
+ (overview ? `## Squad Overview\n\n${overview}\n\n` : "") +
359
+ `Total cost: $${totalCost.toFixed(4)}`,
360
360
  display: true,
361
361
  });
362
362
  schedulers.delete(squadId);
@@ -367,9 +367,12 @@ export default function (pi: ExtensionAPI) {
367
367
  const tasks = store.loadAllTasks(squadId);
368
368
  const failed = tasks.filter((t) => t.status === "failed");
369
369
  const done = tasks.filter((t) => t.status === "done");
370
+ const overview = store.loadOverview(squadId);
370
371
  pi.sendMessage({
371
372
  customType: "squad-failed",
372
- content: `[squad] Squad "${squadId}" has stalled. ${done.length}/${tasks.length} done, ${failed.length} failed.\nFailed: ${failed.map((t) => `${t.id}: ${t.error?.slice(0, 100)}`).join("; ")}`,
373
+ content: `[squad] Squad "${squadId}" has stalled. ${done.length}/${tasks.length} done, ${failed.length} failed.\n` +
374
+ `Failed: ${failed.map((t) => `${t.id}: ${t.error?.slice(0, 100)}`).join("; ")}` +
375
+ (overview ? `\n\n## Squad Overview\n\n${overview}` : ""),
373
376
  display: true,
374
377
  }, { triggerTurn: true });
375
378
  forceWidgetUpdate();
@@ -389,7 +392,7 @@ export default function (pi: ExtensionAPI) {
389
392
 
390
393
  const resumeSched = schedulers.get(squadId)!;
391
394
  resumeSched.resume().catch((err) => {
392
- console.error(`[squad] Resume error: ${(err as Error).message}`);
395
+ logError("squad", `Resume error: ${(err as Error).message}`);
393
396
  });
394
397
 
395
398
  const tasks = store.loadAllTasks(squadId);
@@ -440,7 +443,7 @@ export default function (pi: ExtensionAPI) {
440
443
  case "resume_task": {
441
444
  if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }] };
442
445
  activeScheduler.resumeTask(params.taskId).catch((err) => {
443
- console.error(`[squad] Resume task error: ${(err as Error).message}`);
446
+ logError("squad", `Resume task error: ${(err as Error).message}`);
444
447
  });
445
448
  return { content: [{ type: "text" as const, text: `Task '${params.taskId}' resumed.` }] };
446
449
  }
@@ -1213,6 +1216,31 @@ async function startSquad(
1213
1216
 
1214
1217
  store.saveSquad(squad);
1215
1218
 
1219
+ // Create initial OVERVIEW.md with squad goal, task plan, and design contract
1220
+ const planLines = [
1221
+ `# Squad: ${params.goal}`,
1222
+ ``,
1223
+ `**Created:** ${squad.created}`,
1224
+ `**Tasks:** ${plan.tasks.length}`,
1225
+ ``,
1226
+ `## Task Plan`,
1227
+ ``,
1228
+ ...plan.tasks.map((t) => {
1229
+ const deps = t.depends.length > 0 ? ` (after: ${t.depends.join(", ")})` : "";
1230
+ return `- **${t.id}** → ${t.agent}: ${t.title}${deps}`;
1231
+ }),
1232
+ ``,
1233
+ ];
1234
+
1235
+ // Extract design contract from task descriptions — API paths, schemas,
1236
+ // ports, file conventions — so parallel agents share a single source of truth.
1237
+ const contractLines = buildDesignContract(plan.tasks, params.goal);
1238
+ if (contractLines.length > 0) {
1239
+ planLines.push(...contractLines);
1240
+ }
1241
+
1242
+ store.appendOverview(squadId, planLines.join("\n"));
1243
+
1216
1244
  // Create task files
1217
1245
  for (const taskDef of plan.tasks) {
1218
1246
  const task: Task = {
@@ -1258,12 +1286,8 @@ async function startSquad(
1258
1286
  // Update widget on every scheduler event
1259
1287
  forceWidgetUpdate();
1260
1288
  switch (event.type) {
1261
- case "squad_completed": {
1289
+ case "squad_completed": {
1262
1290
  const tasks = store.loadAllTasks(squadId);
1263
- const summary = tasks
1264
- .filter((t) => t.status === "done")
1265
- .map((t) => `- ${t.id} (${t.agent}): ${t.output?.slice(0, 150) || "done"}`)
1266
- .join("\n");
1267
1291
  const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
1268
1292
 
1269
1293
  // Final context update before clearing scheduler
@@ -1272,12 +1296,17 @@ async function startSquad(
1272
1296
  completedSched.updateContext();
1273
1297
  }
1274
1298
 
1275
- // Use sendMessage with display:truevisible to user but doesn't
1276
- // pollute LLM message history. No triggerTurn needed for completions.
1299
+ // Load the full overview document contains the narrative of
1300
+ // each task's output, decisions, issues, and files modified.
1301
+ const overview = store.loadOverview(squadId);
1302
+
1303
+ // Send into LLM context (not display-only) so the main agent
1304
+ // knows exactly what happened. No triggerTurn — user decides
1305
+ // what to do next.
1277
1306
  pi.sendMessage({
1278
1307
  customType: "squad-completed",
1279
1308
  content: `[squad] Squad "${squadId}" completed all ${tasks.length} tasks.\n\n` +
1280
- `Summary:\n${summary}\n\n` +
1309
+ (overview ? `## Squad Overview\n\n${overview}\n\n` : "") +
1281
1310
  `Total cost: $${totalCost.toFixed(4)}`,
1282
1311
  display: true,
1283
1312
  });
@@ -1292,14 +1321,14 @@ async function startSquad(
1292
1321
  const tasks = store.loadAllTasks(squadId);
1293
1322
  const failed = tasks.filter((t) => t.status === "failed");
1294
1323
  const done = tasks.filter((t) => t.status === "done");
1324
+ const overview = store.loadOverview(squadId);
1295
1325
 
1296
- // Squad failed — notify user. Use triggerTurn so the agent can
1297
- // suggest next steps (retry, modify, cancel).
1298
1326
  pi.sendMessage({
1299
1327
  customType: "squad-failed",
1300
1328
  content: `[squad] Squad "${squadId}" has stalled. ` +
1301
1329
  `${done.length}/${tasks.length} tasks done, ${failed.length} failed.\n` +
1302
1330
  `Failed: ${failed.map((t) => `${t.id}: ${t.error?.slice(0, 100)}`).join("; ")}\n` +
1331
+ (overview ? `\n## Squad Overview\n\n${overview}\n\n` : "\n") +
1303
1332
  `Use squad_status for details or squad_modify to adjust.`,
1304
1333
  display: true,
1305
1334
  }, { triggerTurn: true });
@@ -1327,7 +1356,7 @@ async function startSquad(
1327
1356
  // We must return immediately so the main agent's turn completes
1328
1357
  // and the user regains interactive control.
1329
1358
  scheduler.start().catch((err) => {
1330
- console.error(`[squad] Scheduler start error: ${(err as Error).message}`);
1359
+ logError("squad", `Scheduler start error: ${(err as Error).message}`);
1331
1360
  });
1332
1361
 
1333
1362
  // Build response
@@ -1360,3 +1389,99 @@ function getSquadSkillPaths(skillsDir: string): string[] {
1360
1389
  .map((d) => path.join(skillsDir, d.name))
1361
1390
  .filter((dir) => fs.existsSync(path.join(dir, "SKILL.md")));
1362
1391
  }
1392
+
1393
+ /**
1394
+ * Extract a design contract from task descriptions.
1395
+ *
1396
+ * Scans all tasks for API paths, ports, file names, data schemas, and
1397
+ * shared conventions. Produces a "## Design Contract" section in the
1398
+ * OVERVIEW.md so that ALL agents (including parallel ones) share a
1399
+ * single source of truth before they start working.
1400
+ */
1401
+ function buildDesignContract(
1402
+ tasks: Array<{ id: string; title: string; description: string; agent: string; depends: string[] }>,
1403
+ goal: string,
1404
+ ): string[] {
1405
+ const lines: string[] = [];
1406
+
1407
+ // Collect API routes mentioned in any task description
1408
+ const apiRoutes: string[] = [];
1409
+ const ports: string[] = [];
1410
+ const files: string[] = [];
1411
+ const schemas: string[] = [];
1412
+
1413
+ const allText = tasks.map((t) => `${t.title}\n${t.description}`).join("\n") + "\n" + goal;
1414
+
1415
+ // Extract API paths like GET /foo, POST /bar/:id
1416
+ const routePattern = /\b(GET|POST|PUT|PATCH|DELETE|HEAD)\s+(\/[\w\/:]+)/gi;
1417
+ for (const match of allText.matchAll(routePattern)) {
1418
+ const route = `${match[1].toUpperCase()} ${match[2]}`;
1419
+ if (!apiRoutes.includes(route)) apiRoutes.push(route);
1420
+ }
1421
+
1422
+ // Extract port numbers
1423
+ const portPattern = /\b(?:port|PORT|Port)\s*[=:]?\s*(\d{4,5})\b/gi;
1424
+ for (const match of allText.matchAll(portPattern)) {
1425
+ if (!ports.includes(match[1])) ports.push(match[1]);
1426
+ }
1427
+
1428
+ // Extract key file names mentioned (e.g., server.js, index.html)
1429
+ const filePattern = /\b([\w-]+\.(js|ts|json|html|css|sh|mjs|cjs))\b/gi;
1430
+ for (const match of allText.matchAll(filePattern)) {
1431
+ const f = match[1];
1432
+ if (!files.includes(f) && !['package.json'].includes(f)) files.push(f);
1433
+ }
1434
+
1435
+ // Extract data schemas like {field, field, field}
1436
+ const schemaPattern = /\{([a-zA-Z_][\w,\s\[\]?]+)\}/g;
1437
+ for (const match of allText.matchAll(schemaPattern)) {
1438
+ const fields = match[1].trim();
1439
+ if (fields.includes(',') && fields.length > 5 && fields.length < 200) {
1440
+ if (!schemas.includes(fields)) schemas.push(fields);
1441
+ }
1442
+ }
1443
+
1444
+ // Only emit a contract section if we found shared design elements
1445
+ if (apiRoutes.length === 0 && ports.length === 0 && schemas.length === 0) {
1446
+ return [];
1447
+ }
1448
+
1449
+ lines.push(`## Design Contract`);
1450
+ lines.push(``);
1451
+ lines.push(`> **All agents MUST follow these specifications.** Do not invent`);
1452
+ lines.push(`> alternative paths, ports, or schemas. If you need to deviate,`);
1453
+ lines.push(`> document the reason in your output.`);
1454
+ lines.push(``);
1455
+
1456
+ if (ports.length > 0) {
1457
+ lines.push(`### Server`);
1458
+ lines.push(`- Port: **${ports.join(", ")}**`);
1459
+ lines.push(``);
1460
+ }
1461
+
1462
+ if (apiRoutes.length > 0) {
1463
+ lines.push(`### API Endpoints`);
1464
+ for (const r of apiRoutes) {
1465
+ lines.push(`- \`${r}\``);
1466
+ }
1467
+ lines.push(``);
1468
+ }
1469
+
1470
+ if (schemas.length > 0) {
1471
+ lines.push(`### Data Schemas`);
1472
+ for (const s of schemas) {
1473
+ lines.push(`- \`{ ${s} }\``);
1474
+ }
1475
+ lines.push(``);
1476
+ }
1477
+
1478
+ if (files.length > 0) {
1479
+ lines.push(`### Key Files`);
1480
+ for (const f of files) {
1481
+ lines.push(`- \`${f}\``);
1482
+ }
1483
+ lines.push(``);
1484
+ }
1485
+
1486
+ return lines;
1487
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * logger.ts — Squad debug logging to file, NOT stderr.
3
+ *
4
+ * Writing to stderr (console.error) corrupts the TUI because it bypasses
5
+ * the TUI's differential renderer. All squad debug output goes to a log
6
+ * file instead: ~/.pi/squad/debug.log
7
+ *
8
+ * Set PI_SQUAD_DEBUG=1 to enable logging. Without it, logs are silent.
9
+ */
10
+
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import * as os from "node:os";
14
+
15
+ const LOG_DIR = path.join(os.homedir(), ".pi", "squad");
16
+ const LOG_FILE = path.join(LOG_DIR, "debug.log");
17
+ const DEBUG = process.env.PI_SQUAD_DEBUG === "1";
18
+
19
+ /** Max log file size before rotation (2MB) */
20
+ const MAX_LOG_SIZE = 2 * 1024 * 1024;
21
+
22
+ let rotationChecked = false;
23
+
24
+ function ensureLogDir(): void {
25
+ try {
26
+ fs.mkdirSync(LOG_DIR, { recursive: true });
27
+ } catch { /* ignore */ }
28
+ }
29
+
30
+ function rotateIfNeeded(): void {
31
+ if (rotationChecked) return;
32
+ rotationChecked = true;
33
+ try {
34
+ const stat = fs.statSync(LOG_FILE);
35
+ if (stat.size > MAX_LOG_SIZE) {
36
+ const backup = LOG_FILE + ".old";
37
+ try { fs.unlinkSync(backup); } catch { /* ignore */ }
38
+ fs.renameSync(LOG_FILE, backup);
39
+ }
40
+ } catch { /* file doesn't exist yet, fine */ }
41
+ }
42
+
43
+ /**
44
+ * Log a debug message to ~/.pi/squad/debug.log.
45
+ * Only writes when PI_SQUAD_DEBUG=1 is set. Silent otherwise.
46
+ */
47
+ export function debug(prefix: string, ...args: unknown[]): void {
48
+ if (!DEBUG) return;
49
+ write(prefix, ...args);
50
+ }
51
+
52
+ /**
53
+ * Always log to ~/.pi/squad/debug.log regardless of PI_SQUAD_DEBUG.
54
+ * Use for errors and critical events that should never be lost.
55
+ */
56
+ export function logError(prefix: string, ...args: unknown[]): void {
57
+ write(prefix, ...args);
58
+ }
59
+
60
+ function write(prefix: string, ...args: unknown[]): void {
61
+ try {
62
+ ensureLogDir();
63
+ rotateIfNeeded();
64
+ const ts = new Date().toISOString();
65
+ const msg = args.map(a => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
66
+ fs.appendFileSync(LOG_FILE, `[${ts}] [${prefix}] ${msg}\n`);
67
+ } catch { /* never throw from logging */ }
68
+ }
@@ -1,15 +1,17 @@
1
1
  /**
2
- * squad-widget.ts — Simple string[] widget for squad status.
2
+ * squad-widget.ts — Compact widget for squad status above the editor.
3
3
  *
4
- * Uses the simple setWidget(key, string[]) API which pi-tui handles
5
- * reliably. Component-based widgets with variable height cause TUI
6
- * layout corruption.
4
+ * Uses a component factory for setWidget so it can access terminal width
5
+ * and truncate every line to prevent Text wrapping. Without truncation,
6
+ * long lines wrap inside the Text component, causing unpredictable
7
+ * widget height changes that corrupt the TUI diff renderer.
7
8
  *
8
9
  * Updates are pushed by calling requestUpdate() which rebuilds the
9
- * string[] and calls setWidget() again.
10
+ * lines and calls setWidget() again.
10
11
  */
11
12
 
12
- import { truncateToWidth } from "@mariozechner/pi-tui";
13
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
14
+ import type { Component, TUI } from "@mariozechner/pi-tui";
13
15
  import type { Theme } from "@mariozechner/pi-coding-agent";
14
16
  import type { TaskStatus } from "../types.js";
15
17
  import * as store from "../store.js";
@@ -41,7 +43,10 @@ export interface SquadWidgetState {
41
43
 
42
44
  /**
43
45
  * Set up the squad widget. Returns control functions.
44
- * Uses simple string[] setWidget — no component factory.
46
+ *
47
+ * Uses a component factory so we can access TUI.terminal.columns and
48
+ * truncate every line, preventing Text word-wrapping that would cause
49
+ * unpredictable widget height changes.
45
50
  */
46
51
  export function setupSquadWidget(
47
52
  ctx: { ui: { setWidget: Function; setStatus: Function; [key: string]: any }; hasUI?: boolean },
@@ -56,22 +61,16 @@ export function setupSquadWidget(
56
61
  let renderTimer: ReturnType<typeof setTimeout> | null = null;
57
62
  /** Cache key to skip redundant setWidget calls */
58
63
  let lastCacheKey = "";
64
+ /** Last built lines — the factory re-uses these on each TUI render */
65
+ let currentLines: string[] = [];
59
66
 
60
- function render(): void {
61
- if (!state.enabled || !state.squadId) {
62
- ctx.ui.setWidget("squad-tasks", undefined);
63
- ctx.ui.setStatus("squad", undefined);
64
- return;
65
- }
67
+ function buildLines(): { lines: string[]; cacheKey: string; statusText: string } | null {
68
+ if (!state.enabled || !state.squadId) return null;
66
69
 
67
70
  const th = ctx.ui.theme;
68
71
  const tasks = store.loadAllTasks(state.squadId);
69
72
  const squad = store.loadSquad(state.squadId);
70
- if (!squad || tasks.length === 0) {
71
- ctx.ui.setWidget("squad-tasks", undefined);
72
- ctx.ui.setStatus("squad", undefined);
73
- return;
74
- }
73
+ if (!squad || tasks.length === 0) return null;
75
74
 
76
75
  const lines: string[] = [];
77
76
  const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
@@ -136,19 +135,77 @@ export function setupSquadWidget(
136
135
  lines.push(` ${th.fg("dim", ` +${tasks.length - maxVisible} more · ^q detail`)}`);
137
136
  }
138
137
 
139
- // Skip if nothing changed (avoid redundant setWidget calls that cause flicker)
140
138
  const cacheKey = `${squad.status}:${tasks.map(t => `${t.id}=${t.status}:${t.usage.turns}`).join(",")}`;
141
- if (cacheKey === lastCacheKey) return;
142
- lastCacheKey = cacheKey;
143
139
 
144
- ctx.ui.setWidget("squad-tasks", lines);
145
-
146
- // Update status bar
147
140
  const statusText = squad.status === "done"
148
141
  ? th.fg("success", `✓ squad ${doneCount}/${tasks.length}`)
149
142
  : squad.status === "failed"
150
143
  ? th.fg("error", `✗ squad ${doneCount}/${tasks.length}`)
151
144
  : th.fg("accent", `⏳ squad ${doneCount}/${tasks.length} $${totalCost.toFixed(2)}`);
145
+
146
+ return { lines, cacheKey, statusText };
147
+ }
148
+
149
+ /**
150
+ * Widget component that renders pre-built lines truncated to terminal width.
151
+ * Each line is guaranteed to fit on exactly one terminal row — no wrapping.
152
+ * This keeps the widget height deterministic (= lines.length) so the TUI
153
+ * diff renderer never sees unexpected height changes.
154
+ */
155
+ class SquadWidgetComponent implements Component {
156
+ lines: string[] = [];
157
+
158
+ invalidate(): void { /* stateless — lines are rebuilt externally */ }
159
+
160
+ render(width: number): string[] {
161
+ // Truncate every line to terminal width so Text wrapping cannot
162
+ // add extra rows. One input line → exactly one output line.
163
+ return this.lines.map(line => {
164
+ const truncated = truncateToWidth(line, width);
165
+ // Pad to full width to prevent leftover characters from previous renders
166
+ const vw = visibleWidth(truncated);
167
+ const pad = Math.max(0, width - vw);
168
+ return truncated + " ".repeat(pad);
169
+ });
170
+ }
171
+ }
172
+
173
+ /** Persistent widget component instance — survives across setWidget calls */
174
+ let widgetComponent: SquadWidgetComponent | null = null;
175
+ let widgetInstalled = false;
176
+
177
+ function render(): void {
178
+ const result = buildLines();
179
+ if (!result) {
180
+ ctx.ui.setWidget("squad-tasks", undefined);
181
+ ctx.ui.setStatus("squad", undefined);
182
+ widgetInstalled = false;
183
+ return;
184
+ }
185
+
186
+ const { lines, cacheKey, statusText } = result;
187
+
188
+ // Skip if nothing changed (avoid redundant setWidget calls)
189
+ if (cacheKey === lastCacheKey) return;
190
+ lastCacheKey = cacheKey;
191
+
192
+ currentLines = lines;
193
+
194
+ if (!widgetInstalled) {
195
+ // Install the component factory once. On subsequent updates we just
196
+ // mutate widgetComponent.lines and requestRender — no setWidget churn.
197
+ widgetComponent = new SquadWidgetComponent();
198
+ widgetComponent.lines = lines;
199
+
200
+ const comp = widgetComponent;
201
+ ctx.ui.setWidget("squad-tasks", (_tui: TUI, _theme: Theme) => comp);
202
+ widgetInstalled = true;
203
+ } else if (widgetComponent) {
204
+ // Update lines in-place — the next TUI render picks them up.
205
+ // No setWidget call needed, avoiding component tree rebuild.
206
+ widgetComponent.lines = lines;
207
+ }
208
+
152
209
  ctx.ui.setStatus("squad", statusText);
153
210
  }
154
211
 
package/src/planner.ts CHANGED
@@ -33,6 +33,14 @@ export async function runPlanner(options: PlannerOptions): Promise<PlannerOutput
33
33
  const plannerDef = loadAgentDef("planner", cwd);
34
34
  const allAgents = loadAllAgentDefs(cwd).filter((a) => a.name !== "planner" && !a.disabled);
35
35
 
36
+ if (allAgents.length === 0) {
37
+ throw new Error(
38
+ "No squad agents found. Agent definitions should be in ~/.pi/squad/agents/. " +
39
+ "This usually means the extension failed to load on first run (check /reload for errors). " +
40
+ "Try: /reload, then run the squad command again."
41
+ );
42
+ }
43
+
36
44
  const agentList = allAgents
37
45
  .map((a) => `- **${a.name}** (${a.role}): ${a.description} [tags: ${a.tags.join(", ")}]`)
38
46
  .join("\n");
@@ -250,6 +258,13 @@ Respond with a JSON object (and nothing else outside the JSON):
250
258
  - Include a final QA/verification task if there are user-facing changes
251
259
  - Keep descriptions actionable — agent should know exactly what to build
252
260
  - Don't over-decompose — 3-7 tasks is usually right for most goals
261
+
262
+ ## Shared Contracts
263
+ - When tasks share an interface (e.g., API endpoints, database schema, data formats), create a design/architecture task first that defines the contract, and make consuming tasks depend on it
264
+ - If tasks run in parallel, they MUST depend on a shared spec task that defines the interface between them
265
+ - Frontend tasks should depend on backend API tasks — the frontend agent needs a running API to test against
266
+ - In each task description, specify the exact API paths, data schemas, and conventions that the agent should follow or create
267
+
253
268
  `;
254
269
  }
255
270
 
package/src/protocol.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { AgentDef, KnowledgeEntry, Squad, Task, TaskMessage } from "./types.js";
14
- import { loadAllKnowledge, loadAllTasks, loadMessages } from "./store.js";
14
+ import { loadAllKnowledge, loadAllTasks, loadMessages, loadOverview } from "./store.js";
15
15
 
16
16
  // ============================================================================
17
17
  // Squad Protocol (injected into every agent)
@@ -54,7 +54,9 @@ The squad system will detect this and route help.
54
54
  ## Rules
55
55
  - Stay focused on YOUR task — don't do work assigned to other agents
56
56
  - Read the dependency outputs below — don't redo completed work
57
+ - **Follow the Design Contract in the Squad Progress Document** — use the exact API paths, ports, schemas, and file names specified. Do NOT invent alternatives
57
58
  - Check the modified files list — coordinate before editing shared files
59
+ - When creating APIs, clearly document all endpoints, request/response shapes, and status codes in your completion output
58
60
  - Ask for help if stuck — don't spin for more than a few minutes
59
61
  - Verify your work before claiming done
60
62
  `;
@@ -114,6 +116,20 @@ ${sections.join("\n---\n\n")}
114
116
  `;
115
117
  }
116
118
 
119
+ // ============================================================================
120
+ // Squad Progress Overview
121
+ // ============================================================================
122
+
123
+ export function buildOverviewSection(squadId: string): string {
124
+ const content = loadOverview(squadId);
125
+ if (!content.trim()) return "";
126
+
127
+ return `# Squad Progress Document
128
+
129
+ ${content.trim()}
130
+ `;
131
+ }
132
+
117
133
  // ============================================================================
118
134
  // Sibling Awareness
119
135
  // ============================================================================
@@ -293,6 +309,7 @@ export function buildAgentSystemPrompt(options: ProtocolBuildOptions): string {
293
309
  buildTaskSection(task),
294
310
  buildReworkContext(task, squadId),
295
311
  buildChainContext(task, allTasks, squadId),
312
+ buildOverviewSection(squadId),
296
313
  buildSiblingAwareness(task, allTasks, modifiedFiles),
297
314
  buildKnowledgeSection(squadId),
298
315
  buildQueuedMessages(queuedMessages),
package/src/scheduler.ts CHANGED
@@ -14,6 +14,7 @@ import { AgentPool, type AgentEvent } from "./agent-pool.js";
14
14
  import { Monitor } from "./monitor.js";
15
15
  import { Router } from "./router.js";
16
16
  import * as store from "./store.js";
17
+ import { debug, logError } from "./logger.js";
17
18
  import { buildAgentSystemPrompt } from "./protocol.js";
18
19
 
19
20
  // ============================================================================
@@ -183,13 +184,13 @@ export class Scheduler {
183
184
  /** Find and spawn ready tasks up to concurrency limit */
184
185
  private async scheduleReadyTasks(): Promise<void> {
185
186
  if (!this.running) {
186
- console.error("[squad-scheduler] scheduleReadyTasks: not running, skipping");
187
+ debug("squad-scheduler", "scheduleReadyTasks: not running, skipping");
187
188
  return;
188
189
  }
189
190
 
190
191
  const squad = store.loadSquad(this.squadId);
191
192
  if (!squad || squad.status !== "running") {
192
- console.error(`[squad-scheduler] scheduleReadyTasks: squad status=${squad?.status}, skipping`);
193
+ debug("squad-scheduler", `scheduleReadyTasks: squad status=${squad?.status}, skipping`);
193
194
  return;
194
195
  }
195
196
 
@@ -197,22 +198,22 @@ export class Scheduler {
197
198
  const runningCount = this.pool.getRunningAgents().length;
198
199
  const available = squad.config.maxConcurrency - runningCount;
199
200
 
200
- console.error(`[squad-scheduler] scheduleReadyTasks: ${tasks.length} tasks, ${runningCount} running, ${available} slots`);
201
+ debug("squad-scheduler", `scheduleReadyTasks: ${tasks.length} tasks, ${runningCount} running, ${available} slots`);
201
202
 
202
203
  if (available <= 0) {
203
- console.error("[squad-scheduler] scheduleReadyTasks: no available slots");
204
+ debug("squad-scheduler", "scheduleReadyTasks: no available slots");
204
205
  return;
205
206
  }
206
207
 
207
208
  const ready = this.getReadyTasks(tasks);
208
- console.error(`[squad-scheduler] scheduleReadyTasks: ${ready.length} ready tasks: ${ready.map(t => t.id).join(", ")}`);
209
+ debug("squad-scheduler", `scheduleReadyTasks: ${ready.length} ready tasks: ${ready.map(t => t.id).join(", ")}`);
209
210
  const toSpawn = ready.slice(0, available);
210
211
 
211
212
  for (const task of toSpawn) {
212
213
  try {
213
214
  await this.spawnAgentForTask(task, squad);
214
215
  } catch (error) {
215
- console.error(`[squad-scheduler] Failed to spawn ${task.id}: ${(error as Error).message}`);
216
+ logError("squad-scheduler", `Failed to spawn ${task.id}: ${(error as Error).message}`);
216
217
  // MUST fail the task — otherwise it stays in_progress forever
217
218
  // with no process (zombie state)
218
219
  this.handleTaskFailed(task.id, `Spawn failed: ${(error as Error).message}`);
@@ -393,7 +394,7 @@ export class Scheduler {
393
394
  if (!this.spawnRetries.has(retryKey)) {
394
395
  this.spawnRetries.add(retryKey);
395
396
  const stderr = event.data?.stderr || "";
396
- console.error(`[squad-scheduler] Agent ${event.agentName} died instantly (code ${exitCode}). Retrying in 2s... stderr: ${stderr.slice(0, 200)}`);
397
+ logError("squad-scheduler", `Agent ${event.agentName} died instantly (code ${exitCode}). Retrying in 2s... stderr: ${stderr.slice(0, 200)}`);
397
398
  store.updateTaskStatus(this.squadId, event.taskId, "pending");
398
399
  store.appendMessage(this.squadId, event.taskId, {
399
400
  ts: store.now(),
@@ -456,6 +457,10 @@ export class Scheduler {
456
457
  text: "Task completed",
457
458
  });
458
459
 
460
+ // Reload task after status update so overview has output, completed time, etc.
461
+ const updatedTask = store.loadTask(this.squadId, taskId) || task;
462
+ this.appendTaskOverview(updatedTask, taskId, messages);
463
+
459
464
  this.emit({
460
465
  type: "task_completed",
461
466
  squadId: this.squadId,
@@ -470,7 +475,7 @@ export class Scheduler {
470
475
 
471
476
  if (!reworkCreated) {
472
477
  // Normal flow: auto-unblock dependents
473
- console.error(`[squad-scheduler] handleTaskCompleted: ${taskId} done, auto-unblocking dependents`);
478
+ debug("squad-scheduler", `handleTaskCompleted: ${taskId} done, auto-unblocking dependents`);
474
479
  this.autoUnblock(taskId);
475
480
 
476
481
  // If this is a passing retest, also unblock dependents of the ORIGINAL
@@ -485,19 +490,19 @@ export class Scheduler {
485
490
  rootId = root.retryOf;
486
491
  root = allTasks.find((t) => t.id === rootId);
487
492
  }
488
- console.error(`[squad-scheduler] Retest passed — also unblocking dependents of original: ${rootId}`);
493
+ debug("squad-scheduler", `Retest passed — also unblocking dependents of original: ${rootId}`);
489
494
  this.autoUnblock(rootId);
490
495
  }
491
496
  }
492
497
 
493
498
  // Schedule next ready tasks (may spawn new agents)
494
- console.error(`[squad-scheduler] handleTaskCompleted: scheduling next ready tasks`);
499
+ debug("squad-scheduler", `handleTaskCompleted: scheduling next ready tasks`);
495
500
  await this.scheduleReadyTasks();
496
501
 
497
502
  // Re-check squad completion with fresh data AFTER scheduling
498
503
  const freshTasks = store.loadAllTasks(this.squadId);
499
504
  const freshSquad = store.loadSquad(this.squadId);
500
- console.error(`[squad-scheduler] handleTaskCompleted: final check — tasks: ${freshTasks.map(t => `${t.id}:${t.status}`).join(", ")}`);
505
+ debug("squad-scheduler", `handleTaskCompleted: final check — tasks: ${freshTasks.map(t => `${t.id}:${t.status}`).join(", ")}`);
501
506
  if (freshSquad) {
502
507
  this.checkSquadCompletion(freshTasks, freshSquad);
503
508
  }
@@ -620,7 +625,7 @@ export class Scheduler {
620
625
  const originalId = implTask.retryOf || implTask.id;
621
626
 
622
627
  if (retryCount >= squad.config.maxRetries) {
623
- console.error(`[squad-scheduler] Retry limit reached for ${originalId} (${retryCount}/${squad.config.maxRetries})`);
628
+ debug("squad-scheduler", `Retry limit reached for ${originalId} (${retryCount}/${squad.config.maxRetries})`);
624
629
  this.emit({
625
630
  type: "escalation",
626
631
  squadId: this.squadId,
@@ -689,7 +694,7 @@ export class Scheduler {
689
694
  message: `QA found issues in ${implTask.id}. Rework attempt ${fixN}.`,
690
695
  });
691
696
 
692
- console.error(`[squad-scheduler] Rework: ${reworkId} (${implTask.agent}) + retest ${retestId} (${task.agent})`);
697
+ debug("squad-scheduler", `Rework: ${reworkId} (${implTask.agent}) + retest ${retestId} (${task.agent})`);
693
698
  createdAny = true;
694
699
  }
695
700
 
@@ -931,6 +936,89 @@ export class Scheduler {
931
936
  .map((p: any) => p.text);
932
937
  return textParts.length > 0 ? textParts.join("\n") : null;
933
938
  }
939
+
940
+ // =========================================================================
941
+ // Overview Document — append task summary after completion
942
+ // =========================================================================
943
+
944
+ /**
945
+ * Build a concise markdown summary of a completed task and append it
946
+ * to the squad's OVERVIEW.md. This gives subsequent agents a narrative
947
+ * understanding of what was accomplished, issues hit, and decisions made.
948
+ */
949
+ private appendTaskOverview(
950
+ task: Task,
951
+ taskId: string,
952
+ messages: import("./types.js").TaskMessage[],
953
+ ): void {
954
+ try {
955
+ const lines: string[] = [];
956
+
957
+ // Header
958
+ lines.push(`## ${task.id}: ${task.title}`);
959
+ lines.push(``);
960
+ lines.push(`**Agent:** ${task.agent} | **Status:** ${task.status}`);
961
+ if (task.started && task.completed) {
962
+ const dur = new Date(task.completed).getTime() - new Date(task.started).getTime();
963
+ lines.push(`**Duration:** ${formatElapsed(dur)}`);
964
+ }
965
+ lines.push(``);
966
+
967
+ // Output
968
+ if (task.output) {
969
+ lines.push(`### Output`);
970
+ lines.push(task.output.slice(0, 800));
971
+ if (task.output.length > 800) lines.push(`... (truncated)`);
972
+ lines.push(``);
973
+ }
974
+
975
+ // Issues / errors encountered
976
+ const errors = messages.filter((m) => m.type === "error" && m.from !== "system");
977
+ if (errors.length > 0) {
978
+ lines.push(`### Issues Encountered`);
979
+ for (const err of errors.slice(-5)) {
980
+ lines.push(`- ${err.text.split("\n")[0].slice(0, 200)}`);
981
+ }
982
+ lines.push(``);
983
+ }
984
+
985
+ // Decisions — scan agent text for decision-like language
986
+ const decisionPatterns = /\b(decided|chose|approach|instead of|trade-?off|opted|going with|switched to|using .+ because)\b/i;
987
+ const agentTexts = messages.filter((m) => m.from === task.agent && m.type === "text");
988
+ const decisions: string[] = [];
989
+ for (const msg of agentTexts) {
990
+ for (const line of msg.text.split("\n")) {
991
+ if (decisionPatterns.test(line) && line.trim().length > 10) {
992
+ decisions.push(line.trim().slice(0, 200));
993
+ }
994
+ }
995
+ }
996
+ if (decisions.length > 0) {
997
+ lines.push(`### Decisions`);
998
+ for (const d of decisions.slice(-5)) {
999
+ lines.push(`- ${d}`);
1000
+ }
1001
+ lines.push(``);
1002
+ }
1003
+
1004
+ // Files modified
1005
+ const activity = this.pool.getActivity(taskId);
1006
+ if (activity && activity.modifiedFiles.size > 0) {
1007
+ lines.push(`### Files Modified`);
1008
+ for (const f of Array.from(activity.modifiedFiles).slice(0, 15)) {
1009
+ lines.push(`- ${f}`);
1010
+ }
1011
+ if (activity.modifiedFiles.size > 15) {
1012
+ lines.push(`- ... and ${activity.modifiedFiles.size - 15} more`);
1013
+ }
1014
+ lines.push(``);
1015
+ }
1016
+
1017
+ store.appendOverview(this.squadId, lines.join("\n"));
1018
+ } catch (err) {
1019
+ logError("squad-scheduler", `Failed to append overview for ${taskId}: ${(err as Error).message}`);
1020
+ }
1021
+ }
934
1022
  }
935
1023
 
936
1024
  function formatElapsed(ms: number): string {
package/src/store.ts CHANGED
@@ -405,6 +405,32 @@ export function loadAllKnowledge(squadId: string): KnowledgeEntry[] {
405
405
  ].sort((a, b) => a.ts.localeCompare(b.ts));
406
406
  }
407
407
 
408
+ // ============================================================================
409
+ // Overview Document (shared squad summary)
410
+ // ============================================================================
411
+
412
+ export function getOverviewPath(squadId: string): string {
413
+ return path.join(getSquadDir(squadId), "OVERVIEW.md");
414
+ }
415
+
416
+ export function loadOverview(squadId: string): string {
417
+ try {
418
+ return fs.readFileSync(getOverviewPath(squadId), "utf-8");
419
+ } catch {
420
+ return "";
421
+ }
422
+ }
423
+
424
+ export function appendOverview(squadId: string, section: string): void {
425
+ const filePath = getOverviewPath(squadId);
426
+ ensureDir(path.dirname(filePath));
427
+ const existing = loadOverview(squadId);
428
+ const content = existing
429
+ ? existing.trimEnd() + "\n\n---\n\n" + section.trim() + "\n"
430
+ : section.trim() + "\n";
431
+ fs.writeFileSync(filePath, content, "utf-8");
432
+ }
433
+
408
434
  // ============================================================================
409
435
  // Rework Helpers
410
436
  // ============================================================================