pi-squad 0.15.1 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,8 +41,21 @@ The planner agent reads your codebase and creates a task breakdown automatically
41
41
  2. A **live widget** appears above the editor showing task progress
42
42
  3. **Specialist agents** spawn as separate pi processes, working in parallel where dependencies allow
43
43
  4. QA agents can trigger **automatic rework loops** when they find bugs
44
- 5. On completion, pi receives a summary with each task's **complete, untruncated output**
45
- 6. Multiple squads can run concurrently across different projects
44
+ 5. When agents finish, the squad enters **`review`**, not `done`; main Pi receives every complete task output as untrusted review input
45
+ 6. Main Pi independently checks the original user contract, actual diff/source, verification commands, and integration/E2E, then records acceptance with `squad_review`
46
+ 7. Multiple squads can run concurrently across different projects
47
+
48
+ ### Mandatory Orchestrator Review Gate
49
+
50
+ Squad agents—including QA/reviewer agents—produce candidate work and evidence claims. They cannot mark a squad accepted. After all tasks finish:
51
+
52
+ - Persisted status becomes `review`, never directly `done`.
53
+ - A persistent `<squad_review_required>` system reminder tells main Pi to re-read the original conversation contract, inspect the actual diff/source, rerun verification independently, and run integration/E2E where applicable.
54
+ - Main Pi must call `squad_review` with requirement-by-requirement contract checks, diff review, actual command/result evidence, integration/E2E evidence, and issues.
55
+ - Only `pass` or `pass_with_issues` changes the squad to `done`; `fail` leaves it review-blocked for fixes and re-review.
56
+ - Pending review survives Pi restarts and is restored on the next session.
57
+
58
+ The completion report is explicitly labeled **untrusted and not yet accepted**. Main Pi must never merely relay it or ask whether verification should be run.
46
59
 
47
60
  ### No-Truncation Contract
48
61
 
@@ -114,7 +127,11 @@ Bundled agent definitions are copied to `~/.pi/squad/agents/` on first run. Edit
114
127
 
115
128
  ### Agent Collaboration
116
129
 
117
- **Chain context**: When task A completes, its output is injected into task B's system prompt. Downstream agents know what was built.
130
+ **Chain context**: When task A completes, its complete output is injected into downstream prompts across the full dependency-ancestor closure (ancestors first, diamond dependencies deduplicated), not only the final direct edge. Integration and QA tasks therefore receive the original contracts their inputs were built from.
131
+
132
+ **Completed-agent replies**: An `@agent` request aimed at an agent whose task already finished is answered immediately from that agent's durable task output. It is never queued for a nonexistent future spawn, and a blocker resolved this way does not escalate to the human.
133
+
134
+ **Report-only work**: Planning/review agents may complete with a substantive assistant artifact even when they needed no tool call. Their output still passes through mandatory independent orchestrator review.
118
135
 
119
136
  **Shared filesystem**: All agents work in the same project directory. Upstream agents create files, downstream agents read and modify them.
120
137
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squad",
3
- "version": "0.15.1",
3
+ "version": "0.16.1",
4
4
  "description": "Multi-agent collaboration extension for pi — task decomposition, dependency management, parallel execution, TUI panel",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ import { setupSquadWidget, 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
+ import { buildOrchestratorReviewGate, recordOrchestratorReview } from "./review.js";
29
30
 
30
31
  // ============================================================================
31
32
  // State
@@ -45,14 +46,6 @@ let uiCtx: import("@earendil-works/pi-coding-agent").ExtensionContext | null = n
45
46
  const widgetState: SquadWidgetState = { squadId: null, enabled: true };
46
47
  let widgetControls: { requestUpdate: () => void; dispose: () => void } | null = null;
47
48
 
48
- /** Reviewer instructions appended to squad-completed notifications —
49
- * makes the main session behave like the QA/verification agents do. */
50
- const REVIEW_INSTRUCTIONS =
51
- "Before reporting success to the user, REVIEW the work like a QA agent would: " +
52
- "(1) check task outputs for QA verdicts (## Verdict: PASS/FAIL) and verification evidence (commands + results); " +
53
- "(2) if a task claimed done without evidence, run its Verify command yourself; " +
54
- "(3) report to the user what was verified (with evidence) and flag anything unverified — don't just relay the summary.";
55
-
56
49
  /**
57
50
  * Resolve a model string (or null = session default) to its context window.
58
51
  * Reads uiCtx lazily so it always uses the live session's registry.
@@ -215,14 +208,33 @@ export default function (pi: ExtensionAPI) {
215
208
 
216
209
  // Inject squad awareness before each LLM call
217
210
  pi.on("before_agent_start", async (event, ctx) => {
218
- if (!squadEnabled) return;
211
+ // Review gates are project-wide and survive focus changes, new squads, and
212
+ // disabling normal squad operations. Unaccepted work must stay visible.
213
+ const pendingReviewGates = store.findActiveSquads()
214
+ .filter((s) => s.cwd === ctx.cwd && s.status === "review")
215
+ .map((s) => ({ squad: s, gate: buildOrchestratorReviewGate(s, store.loadAllTasks(s.id)) }));
216
+ if (!squadEnabled) {
217
+ if (pendingReviewGates.length === 0) return;
218
+ return { systemPrompt: event.systemPrompt + "\n\n" + pendingReviewGates.map(({ gate }) => gate).join("\n\n") };
219
+ }
219
220
 
220
221
  // When a squad is active, inject its status
221
222
  if (activeSquadId) {
222
223
  const squad = store.loadSquad(activeSquadId);
223
- if (!squad) return;
224
+ if (!squad) {
225
+ activeSquadId = null;
226
+ if (pendingReviewGates.length > 0) {
227
+ return { systemPrompt: event.systemPrompt + "\n\n" + pendingReviewGates.map(({ gate }) => gate).join("\n\n") };
228
+ }
229
+ return;
230
+ }
224
231
  const tasks = store.loadAllTasks(activeSquadId);
225
- if (tasks.length === 0) return;
232
+ if (tasks.length === 0) {
233
+ if (pendingReviewGates.length > 0) {
234
+ return { systemPrompt: event.systemPrompt + "\n\n" + pendingReviewGates.map(({ gate }) => gate).join("\n\n") };
235
+ }
236
+ return;
237
+ }
226
238
 
227
239
  const doneCount = tasks.filter((t) => t.status === "done").length;
228
240
  const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
@@ -241,6 +253,8 @@ export default function (pi: ExtensionAPI) {
241
253
  `Status: ${squad.status} | ${doneCount}/${tasks.length} tasks | $${totalCost.toFixed(2)}`,
242
254
  taskLines,
243
255
  `</squad_status>`,
256
+ ...(squad.status === "review" ? [buildOrchestratorReviewGate(squad, tasks)] : []),
257
+ ...pendingReviewGates.filter(({ squad: pending }) => pending.id !== squad.id).map(({ gate }) => gate),
244
258
  `You have an active squad. Use squad_message to talk to agents, squad_status for details, squad_modify to change tasks.`,
245
259
  `Do NOT poll squad_status in a loop or sleep-wait — the squad wakes you automatically on completion, failure, or escalation. Keep helping the user with other work, or end your turn and stay idle.`,
246
260
  ].join("\n");
@@ -267,7 +281,8 @@ export default function (pi: ExtensionAPI) {
267
281
  ].filter(Boolean).join("\n");
268
282
 
269
283
  return {
270
- systemPrompt: event.systemPrompt + "\n\n" + squadNudge,
284
+ systemPrompt: event.systemPrompt + "\n\n" + squadNudge +
285
+ (pendingReviewGates.length > 0 ? "\n\n" + pendingReviewGates.map(({ gate }) => gate).join("\n\n") : ""),
271
286
  };
272
287
  });
273
288
 
@@ -298,10 +313,10 @@ export default function (pi: ExtensionAPI) {
298
313
  "Providing tasks yourself makes you the planner — follow the planner rules (contract task first, final QA task, 3-7 tasks)",
299
314
  "Act on ⚠️ plan warnings in the response — fix with squad_modify or address at review",
300
315
  "After starting a squad: report the plan and END YOUR TURN — never poll squad_status or sleep-wait; squad events wake you automatically",
301
- "When the squad completes, review evidence like a QA agent before reporting success",
316
+ "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",
302
317
  ],
303
318
  parameters: Type.Object({
304
- goal: Type.String({ description: "What the squad should accomplish" }),
319
+ 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." }),
305
320
  agents: Type.Optional(
306
321
  Type.Record(
307
322
  Type.String(),
@@ -408,6 +423,7 @@ export default function (pi: ExtensionAPI) {
408
423
  `Status: ${context.status}`,
409
424
  `Elapsed: ${context.elapsed}`,
410
425
  `Cost: $${context.costs.total.toFixed(4)}`,
426
+ ...(context.status === "review" ? ["Acceptance: BLOCKED — independent main-orchestrator review required via squad_review"] : []),
411
427
  "",
412
428
  "Tasks:",
413
429
  taskLines,
@@ -417,6 +433,70 @@ export default function (pi: ExtensionAPI) {
417
433
  },
418
434
  });
419
435
 
436
+ // =========================================================================
437
+ // Tool: squad_review — mandatory main-orchestrator acceptance gate
438
+ // =========================================================================
439
+
440
+ pi.registerTool({
441
+ name: "squad_review",
442
+ label: "Record Independent Squad Review",
443
+ description: "Record the MAIN Pi/orchestrator's independent review of completed squad work against the original user contract. Call only after inspecting the actual diff/source and independently running verification plus integration/E2E where applicable. Squad reports and squad QA evidence are not sufficient.",
444
+ parameters: Type.Object({
445
+ squadId: Type.Optional(Type.String({ description: "Squad awaiting review (default: active/latest)" })),
446
+ verdict: Type.Union([
447
+ Type.Literal("pass"),
448
+ Type.Literal("pass_with_issues"),
449
+ Type.Literal("fail"),
450
+ ]),
451
+ contractChecks: Type.Array(Type.String(), { minItems: 1, description: "Requirement-by-requirement checks against the ORIGINAL user request and later clarifications; include observed result for each" }),
452
+ diffReview: Type.String({ description: "What you independently inspected in the actual diff/source, including scope and integration concerns" }),
453
+ verificationEvidence: Type.Array(Type.String(), { minItems: 1, description: "Commands/checks YOU ran and their actual results; do not copy squad claims" }),
454
+ integrationEvidence: Type.String({ description: "Integration/E2E result from the real target or production-like environment, or a precise reason it is not applicable/impossible and therefore unverified" }),
455
+ issues: Type.Array(Type.String(), { description: "Every discovered or remaining issue; required for fail/pass_with_issues" }),
456
+ }),
457
+
458
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
459
+ let id = params.squadId;
460
+ if (!id && activeSquadId && store.loadSquad(activeSquadId)?.status === "review") {
461
+ id = activeSquadId;
462
+ }
463
+ if (!id) {
464
+ id = store.listSquadsForProject(ctx.cwd)
465
+ .filter((s) => s.status === "review")
466
+ .sort((a, b) => b.created.localeCompare(a.created))[0]?.id;
467
+ }
468
+ if (!id) {
469
+ return { content: [{ type: "text" as const, text: "No squad is awaiting orchestrator review." }], details: undefined };
470
+ }
471
+
472
+ const squad = store.loadSquad(id);
473
+ if (!squad) {
474
+ return { content: [{ type: "text" as const, text: `Squad '${id}' not found.` }], details: undefined };
475
+ }
476
+
477
+ try {
478
+ recordOrchestratorReview(squad, {
479
+ verdict: params.verdict,
480
+ contractChecks: params.contractChecks,
481
+ diffReview: params.diffReview,
482
+ verificationEvidence: params.verificationEvidence,
483
+ integrationEvidence: params.integrationEvidence,
484
+ issues: params.issues,
485
+ });
486
+ } catch (error) {
487
+ return { content: [{ type: "text" as const, text: `Review rejected: ${(error as Error).message}` }], details: undefined };
488
+ }
489
+
490
+ store.saveSquad(squad);
491
+ forceWidgetUpdate();
492
+ const accepted = squad.status === "done";
493
+ const text = accepted
494
+ ? `Independent orchestrator review recorded for '${id}' (${params.verdict}). The squad is now accepted as done.`
495
+ : `Independent review FAILED for '${id}'. The squad remains review-required. Fix every issue, rerun verification/E2E, then submit a fresh squad_review.`;
496
+ return { content: [{ type: "text" as const, text }], details: undefined };
497
+ },
498
+ });
499
+
420
500
  // =========================================================================
421
501
  // Tool: squad_message
422
502
  // =========================================================================
@@ -518,16 +598,17 @@ export default function (pi: ExtensionAPI) {
518
598
  scheduler.onEvent((event: SchedulerEvent) => {
519
599
  forceWidgetUpdate();
520
600
  switch (event.type) {
521
- case "squad_completed": {
601
+ case "squad_review_required": {
522
602
  const tasks = store.loadAllTasks(squadId);
523
603
  const summary = buildCompletionSummary(tasks);
524
604
  const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
525
605
  const s = schedulers.get(squadId); if (s) s.updateContext();
526
- // followUp + triggerTurn: wake an idle main agent to review;
527
- // if it's mid-conversation with the human, deliver after it finishes
606
+ const squad = store.loadSquad(squadId)!;
607
+ // Wake the main agent into an explicit acceptance gate. Agent reports
608
+ // are untrusted inputs; only independent orchestrator evidence can pass it.
528
609
  pi.sendMessage({
529
- customType: "squad-completed",
530
- content: `[squad] Squad "${squadId}" completed all ${tasks.length} tasks.\n\nSummary:\n${summary}\n\nTotal cost: $${totalCost.toFixed(4)}\n\n${REVIEW_INSTRUCTIONS}`,
610
+ customType: "squad-review-required",
611
+ content: `[squad] TASK EXECUTION FINISHED for "${squadId}" WORK IS UNTRUSTED AND NOT YET ACCEPTED.\n\nSquad claims (review inputs only):\n${summary}\n\nTotal cost: $${totalCost.toFixed(4)}\n\n${buildOrchestratorReviewGate(squad, tasks)}`,
531
612
  display: true,
532
613
  }, { triggerTurn: true, deliverAs: "followUp" });
533
614
  schedulers.delete(squadId);
@@ -723,6 +804,24 @@ export default function (pi: ExtensionAPI) {
723
804
  }
724
805
  }
725
806
 
807
+ // Restore pending acceptance gates after a main-session restart. Review is
808
+ // persisted state, not a one-shot completion message that can be missed.
809
+ const pendingReviews = store.findActiveSquads()
810
+ .filter((s) => s.cwd === ctx.cwd && s.status === "review");
811
+ if (pendingReviews.length > 0) {
812
+ const squad = pendingReviews.sort((a, b) => b.created.localeCompare(a.created))[0];
813
+ activeSquadId = squad.id;
814
+ widgetState.squadId = squad.id;
815
+ widgetState.enabled = true;
816
+ widgetControls?.requestUpdate();
817
+ const tasks = store.loadAllTasks(squad.id);
818
+ pi.sendMessage({
819
+ customType: "squad-review-required",
820
+ content: `[squad] Restored mandatory orchestrator review for "${squad.id}" after session restart. The work remains untrusted and not accepted.\n\n${buildOrchestratorReviewGate(squad, tasks)}`,
821
+ display: true,
822
+ });
823
+ }
824
+
726
825
  // Register Ctrl+Q terminal input handler for panel toggle
727
826
  if (ctx.hasUI) {
728
827
  ctx.ui.onTerminalInput((data) => {
@@ -1008,7 +1107,7 @@ export default function (pi: ExtensionAPI) {
1008
1107
  const tasks = store.loadAllTasks(s.id);
1009
1108
  const done = tasks.filter((t) => t.status === "done").length;
1010
1109
  const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
1011
- const icon = s.status === "done" ? "✓" : s.status === "running" ? "⏳" : s.status === "failed" ? "✗" : "·";
1110
+ const icon = s.status === "done" ? "✓" : s.status === "running" ? "⏳" : s.status === "review" ? "◆" : s.status === "failed" ? "✗" : "·";
1012
1111
  return `${icon} ${s.id} [${s.status}] ${done}/${tasks.length} $${cost.toFixed(2)}`;
1013
1112
  }),
1014
1113
  ];
@@ -1318,7 +1417,7 @@ async function pickSquad(
1318
1417
  const tasks = store.loadAllTasks(s.id);
1319
1418
  const done = tasks.filter((t) => t.status === "done").length;
1320
1419
  const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
1321
- const icon = s.status === "done" ? "✓" : s.status === "running" ? "⏳" : s.status === "failed" ? "✗" : "·";
1420
+ const icon = s.status === "done" ? "✓" : s.status === "running" ? "⏳" : s.status === "review" ? "◆" : s.status === "failed" ? "✗" : "·";
1322
1421
  const project = showProject ? ` — ${s.cwd.split("/").pop()}` : "";
1323
1422
  return `${icon} ${s.id} [${s.status}] ${done}/${tasks.length} $${cost.toFixed(2)}${project}`;
1324
1423
  });
@@ -1563,7 +1662,7 @@ async function startSquad(
1563
1662
  // Update widget on every scheduler event
1564
1663
  forceWidgetUpdate();
1565
1664
  switch (event.type) {
1566
- case "squad_completed": {
1665
+ case "squad_review_required": {
1567
1666
  const tasks = store.loadAllTasks(squadId);
1568
1667
  const summary = buildCompletionSummary(tasks);
1569
1668
  const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
@@ -1573,15 +1672,16 @@ async function startSquad(
1573
1672
  if (completedSched) {
1574
1673
  completedSched.updateContext();
1575
1674
  }
1675
+ const squad = store.loadSquad(squadId)!;
1576
1676
 
1577
- // followUp + triggerTurn: wake an idle main agent to review; if it's
1578
- // mid-conversation with the human, deliver after it finishes
1677
+ // Wake the main agent into an explicit acceptance gate. Never frame
1678
+ // agent execution or squad QA as trusted completion.
1579
1679
  pi.sendMessage({
1580
- customType: "squad-completed",
1581
- content: `[squad] Squad "${squadId}" completed all ${tasks.length} tasks.\n\n` +
1582
- `Summary:\n${summary}\n\n` +
1680
+ customType: "squad-review-required",
1681
+ content: `[squad] TASK EXECUTION FINISHED for "${squadId}" WORK IS UNTRUSTED AND NOT YET ACCEPTED.\n\n` +
1682
+ `Squad claims (review inputs only):\n${summary}\n\n` +
1583
1683
  `Total cost: $${totalCost.toFixed(4)}\n\n` +
1584
- REVIEW_INSTRUCTIONS,
1684
+ buildOrchestratorReviewGate(squad, tasks),
1585
1685
  display: true,
1586
1686
  }, { triggerTurn: true, deliverAs: "followUp" });
1587
1687
 
@@ -79,6 +79,7 @@ export function setupSquadWidget(
79
79
 
80
80
  const sIcon = squad.status === "done" ? th.fg("success", "✓")
81
81
  : squad.status === "failed" ? th.fg("error", "✗")
82
+ : squad.status === "review" ? th.fg("warning", "◆")
82
83
  : th.fg("warning", "⏳");
83
84
 
84
85
  lines.push(
@@ -141,6 +142,8 @@ export function setupSquadWidget(
141
142
  ? th.fg("success", `✓ squad ${doneCount}/${tasks.length}`)
142
143
  : squad.status === "failed"
143
144
  ? th.fg("error", `✗ squad ${doneCount}/${tasks.length}`)
145
+ : squad.status === "review"
146
+ ? th.fg("warning", `◆ squad review required`)
144
147
  : th.fg("accent", `⏳ squad ${doneCount}/${tasks.length} $${totalCost.toFixed(2)}`);
145
148
 
146
149
  return { lines, cacheKey, statusText };
@@ -215,7 +218,7 @@ export function setupSquadWidget(
215
218
  return;
216
219
  }
217
220
  const squad = store.loadSquad(state.squadId);
218
- const isActive = squad && (squad.status === "running" || squad.status === "paused");
221
+ const isActive = squad && (squad.status === "running" || squad.status === "paused" || squad.status === "review");
219
222
  if (isActive && !durationTimer) {
220
223
  durationTimer = setInterval(() => render(), 5000);
221
224
  } else if (!isActive && durationTimer) {
package/src/plan-rules.ts CHANGED
@@ -53,6 +53,29 @@ function isQaLikeTask(task: PlanTaskInput): boolean {
53
53
  return /\b(qa|test|tests|testing|verif\w*|review|audit)\b/.test(hay);
54
54
  }
55
55
 
56
+ /** Task IDs explicitly named in a "depend on ..." Context sentence. */
57
+ function describedDependencies(task: PlanTaskInput, knownIds: Set<string>): string[] {
58
+ const refs = new Set<string>();
59
+ for (const clause of task.description.matchAll(/\bdepend(?:s|ing)?\s+on\b([^.;\n]*)/gi)) {
60
+ for (const quoted of clause[1].matchAll(/`([^`]+)`/g)) {
61
+ if (knownIds.has(quoted[1])) refs.add(quoted[1]);
62
+ }
63
+ }
64
+ return [...refs];
65
+ }
66
+
67
+ function dependencyClosure(task: PlanTaskInput, byId: Map<string, PlanTaskInput>): Set<string> {
68
+ const closure = new Set<string>();
69
+ const visit = (id: string): void => {
70
+ if (closure.has(id)) return;
71
+ closure.add(id);
72
+ const dependency = byId.get(id);
73
+ if (dependency) for (const ancestor of dependency.depends) visit(ancestor);
74
+ };
75
+ for (const dependency of task.depends) visit(dependency);
76
+ return closure;
77
+ }
78
+
56
79
  /**
57
80
  * Validate a plan's structure. Errors block squad creation;
58
81
  * warnings are returned to the plan author for correction.
@@ -125,8 +148,16 @@ export function validatePlan(tasks: PlanTaskInput[]): PlanValidation {
125
148
  for (const t of tasks) {
126
149
  if (!t.description || t.description.trim().length === 0) {
127
150
  warnings.push(`Task "${t.id}" has no description — the agent only gets the title.`);
128
- } else if (!/verif|test|check|curl|run\b|npm |pnpm |cargo |pytest|tsc\b/i.test(t.description)) {
129
- warnings.push(`Task "${t.id}" description has no Verify criterion — the agent won't know how to prove it's done.`);
151
+ } else {
152
+ if (!/verif|test|check|curl|run\b|npm |pnpm |cargo |pytest|tsc\b/i.test(t.description)) {
153
+ warnings.push(`Task "${t.id}" description has no Verify criterion — the agent won't know how to prove it's done.`);
154
+ }
155
+ const closure = dependencyClosure(t, byId);
156
+ for (const described of describedDependencies(t, ids)) {
157
+ if (!closure.has(described)) {
158
+ warnings.push(`Task "${t.id}" says it depends on "${described}", but that task is absent from its formal dependency closure.`);
159
+ }
160
+ }
130
161
  }
131
162
  }
132
163
 
package/src/protocol.ts CHANGED
@@ -90,9 +90,10 @@ function buildChainContext(task: Task, allTasks: Task[], squadId: string): strin
90
90
 
91
91
  const sections: string[] = [];
92
92
 
93
- for (const depId of task.depends) {
94
- const dep = allTasks.find((t) => t.id === depId);
95
- if (!dep || dep.status !== "done") continue;
93
+ // A downstream integration/QA task needs the contracts its direct inputs
94
+ // were built from, not only the last edge in the DAG. Walk the complete
95
+ // ancestor closure, ancestors first, and deduplicate diamond dependencies.
96
+ for (const dep of completedDependencyClosure(task, allTasks)) {
96
97
 
97
98
  let section = `## ${dep.id} (done by ${dep.agent})\n**${dep.title}**\n`;
98
99
  if (dep.output) {
@@ -119,6 +120,24 @@ ${sections.join("\n---\n\n")}
119
120
  `;
120
121
  }
121
122
 
123
+ function completedDependencyClosure(task: Task, allTasks: Task[]): Task[] {
124
+ const byId = new Map(allTasks.map((candidate) => [candidate.id, candidate]));
125
+ const seen = new Set<string>();
126
+ const ordered: Task[] = [];
127
+
128
+ const visit = (id: string): void => {
129
+ if (seen.has(id)) return;
130
+ seen.add(id);
131
+ const dependency = byId.get(id);
132
+ if (!dependency) return;
133
+ for (const ancestorId of dependency.depends) visit(ancestorId);
134
+ if (dependency.status === "done") ordered.push(dependency);
135
+ };
136
+
137
+ for (const dependencyId of task.depends) visit(dependencyId);
138
+ return ordered;
139
+ }
140
+
122
141
  // ============================================================================
123
142
  // Sibling Awareness
124
143
  // ============================================================================
package/src/review.ts ADDED
@@ -0,0 +1,107 @@
1
+ import type { Squad, Task } from "./types.js";
2
+
3
+ export type OrchestratorReviewVerdict = "pass" | "pass_with_issues" | "fail";
4
+
5
+ export interface OrchestratorReviewInput {
6
+ verdict: OrchestratorReviewVerdict;
7
+ contractChecks: string[];
8
+ diffReview: string;
9
+ verificationEvidence: string[];
10
+ integrationEvidence: string;
11
+ issues: string[];
12
+ }
13
+
14
+ /** Move a squad from agent execution into mandatory independent main-session review. */
15
+ export function beginOrchestratorReview(squad: Squad): void {
16
+ squad.status = "review";
17
+ squad.review = {
18
+ status: "pending",
19
+ requestedAt: new Date().toISOString(),
20
+ completedAt: null,
21
+ verdict: null,
22
+ contractChecks: [],
23
+ diffReview: "",
24
+ verificationEvidence: [],
25
+ integrationEvidence: "",
26
+ issues: [],
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Validate and record the orchestrator's independent review evidence.
32
+ * A failing review deliberately leaves the squad behind the review gate.
33
+ */
34
+ export function recordOrchestratorReview(squad: Squad, input: OrchestratorReviewInput): void {
35
+ if (squad.status !== "review" || !squad.review) {
36
+ throw new Error(`Squad '${squad.id}' is not awaiting orchestrator review`);
37
+ }
38
+
39
+ const contractChecks = cleanList(input.contractChecks);
40
+ const verificationEvidence = cleanList(input.verificationEvidence);
41
+ const issues = cleanList(input.issues);
42
+ const diffReview = input.diffReview.trim();
43
+ const integrationEvidence = input.integrationEvidence.trim();
44
+
45
+ if (contractChecks.length === 0) {
46
+ throw new Error("contractChecks must map the original user requirements to observed results");
47
+ }
48
+ if (!diffReview) {
49
+ throw new Error("diffReview must describe the independently inspected changes and scope");
50
+ }
51
+ if (verificationEvidence.length === 0) {
52
+ throw new Error("verificationEvidence must include commands/checks and their actual results");
53
+ }
54
+ if (!integrationEvidence) {
55
+ throw new Error("integrationEvidence must include E2E/integration results or a specific reason it is not applicable");
56
+ }
57
+ if ((input.verdict === "fail" || input.verdict === "pass_with_issues") && issues.length === 0) {
58
+ throw new Error(`${input.verdict} must list every actionable or remaining issue`);
59
+ }
60
+
61
+ squad.review = {
62
+ status: input.verdict === "fail" ? "failed" : "passed",
63
+ requestedAt: squad.review.requestedAt,
64
+ completedAt: new Date().toISOString(),
65
+ verdict: input.verdict,
66
+ contractChecks,
67
+ diffReview,
68
+ verificationEvidence,
69
+ integrationEvidence,
70
+ issues,
71
+ };
72
+
73
+ // Only an independently reviewed PASS can become done. A FAIL remains gated
74
+ // until fixes are made and the main orchestrator records a fresh review.
75
+ squad.status = input.verdict === "fail" ? "review" : "done";
76
+ }
77
+
78
+ /** Persistent system-prompt contract shown until squad_review accepts the work. */
79
+ export function buildOrchestratorReviewGate(squad: Squad, tasks: Task[]): string {
80
+ const delegatedPlan = tasks
81
+ .map((task) => `- ${task.id} (${task.agent}): ${task.title}\n ${task.description || "(no description)"}`)
82
+ .join("\n");
83
+
84
+ return `<squad_review_required>
85
+ UNTRUSTED CANDIDATE WORK — INDEPENDENT ORCHESTRATOR REVIEW IS MANDATORY.
86
+
87
+ 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.
88
+ Recorded squad goal (secondary reference): ${squad.goal}
89
+
90
+ Delegated plan (non-authoritative):
91
+ ${delegatedPlan}
92
+
93
+ Before reporting success, completion, or acceptance to the user, YOU (the main Pi/orchestrator) MUST:
94
+ 1. Reconstruct the original contract requirement-by-requirement from the conversation.
95
+ 2. Inspect the actual working-tree/commit diff and relevant source files yourself; check scope, integration points, error paths, regressions, and unintended changes.
96
+ 3. Independently run the original Verify commands and appropriate build/tests. Do not rely on pasted squad output.
97
+ 4. Run integration/E2E in the real target or production-like environment when the request affects runtime behavior. If genuinely not applicable or impossible, record the precise reason and mark it unverified.
98
+ 5. Fix discovered defects and repeat checks. Squad QA PASS does not override your findings.
99
+ 6. Call squad_review with contract checks, diff review, actual command/result evidence, integration/E2E evidence, and remaining issues.
100
+
101
+ Do not ask the user whether you should verify. Do not merely summarize the squad report. Until squad_review records PASS/PASS_WITH_ISSUES, the work is not accepted.
102
+ </squad_review_required>`;
103
+ }
104
+
105
+ function cleanList(values: string[]): string[] {
106
+ return values.map((value) => value.trim()).filter(Boolean);
107
+ }
package/src/router.ts CHANGED
@@ -47,12 +47,18 @@ export class Router {
47
47
  processMessage(taskId: string, fromAgent: string, text: string): void {
48
48
  // Parse @mentions
49
49
  const mentions = this.parseMentions(text, fromAgent);
50
+ let resolvedFromDurableOutput = false;
50
51
  for (const mention of mentions) {
51
- this.routeMention(taskId, fromAgent, mention.target, mention.message);
52
+ const resolved = this.routeMention(taskId, fromAgent, mention.target, mention.message);
53
+ // Only suppress escalation when the resolved mention itself expressed
54
+ // the blocker; an unrelated completed-agent FYI must not hide another block.
55
+ resolvedFromDurableOutput =
56
+ (resolved && this.isBlockSignal(mention.message)) || resolvedFromDurableOutput;
52
57
  }
53
58
 
54
- // Detect block signals
55
- if (this.isBlockSignal(text)) {
59
+ // Do not wake the human for a blocker we immediately resolved from a
60
+ // completed agent's durable task output.
61
+ if (this.isBlockSignal(text) && !resolvedFromDurableOutput) {
56
62
  for (const listener of this.escalationListeners) {
57
63
  listener(taskId, fromAgent, this.extractBlockReason(text));
58
64
  }
@@ -67,7 +73,7 @@ export class Router {
67
73
  fromAgent: string,
68
74
  targetAgent: string,
69
75
  message: string,
70
- ): void {
76
+ ): boolean {
71
77
  // Log the mention in the source task
72
78
  store.appendMessage(this.squadId, sourceTaskId, {
73
79
  ts: store.now(),
@@ -93,8 +99,41 @@ export class Router {
93
99
  to: targetAgent,
94
100
  text: message,
95
101
  });
96
- } else {
97
- // Target not running — queue for later
102
+ return false;
103
+ }
104
+
105
+ const targetTasks = store.loadAllTasks(this.squadId).filter((task) => task.agent === targetAgent);
106
+ const hasFutureRun = targetTasks.some((task) =>
107
+ task.status === "pending" || task.status === "blocked" || task.status === "suspended" || task.status === "in_progress",
108
+ );
109
+ if (!hasFutureRun) {
110
+ const completed = targetTasks.filter((task) => task.status === "done" && task.output);
111
+ if (completed.length > 0) {
112
+ const durableReply = [
113
+ `[squad] @${targetAgent} has completed and is no longer running. Durable completed output:`,
114
+ ...completed.map((task) => `\n## ${task.id}: ${task.title}\n${task.output}`),
115
+ ].join("\n");
116
+ const reply = {
117
+ ts: store.now(),
118
+ from: targetAgent,
119
+ type: "reply" as const,
120
+ to: fromAgent,
121
+ text: durableReply,
122
+ };
123
+ store.appendMessage(this.squadId, sourceTaskId, reply);
124
+ if (this.pool.isRunning(sourceTaskId)) {
125
+ this.pool.steer(sourceTaskId, durableReply);
126
+ } else {
127
+ this.pool.queueMessage(fromAgent, reply);
128
+ }
129
+ return true;
130
+ }
131
+ }
132
+
133
+ // Target has future work and may spawn again — queue for that run. If the
134
+ // target is terminal with no output, do not create an undeliverable queue;
135
+ // leave the blocker unresolved so it escalates to the main orchestrator.
136
+ if (hasFutureRun) {
98
137
  this.pool.queueMessage(targetAgent, {
99
138
  ts: store.now(),
100
139
  from: fromAgent,
@@ -103,6 +142,7 @@ export class Router {
103
142
  text: message,
104
143
  });
105
144
  }
145
+ return false;
106
146
  }
107
147
 
108
148
  /**
package/src/scheduler.ts CHANGED
@@ -19,6 +19,7 @@ import * as store from "./store.js";
19
19
  import { debug, logError } from "./logger.js";
20
20
  import { buildAgentSystemPrompt } from "./protocol.js";
21
21
  import { buildAdvisorConsultText, formatAdvisorSteerMessage, adviceNeedsHuman, type AdvisorConsultInput } from "./advisor.js";
22
+ import { beginOrchestratorReview } from "./review.js";
22
23
 
23
24
  // ============================================================================
24
25
  // Types
@@ -31,7 +32,7 @@ export type SchedulerEventType =
31
32
  | "task_blocked"
32
33
  | "task_unblocked"
33
34
  | "task_rework"
34
- | "squad_completed"
35
+ | "squad_review_required"
35
36
  | "squad_failed"
36
37
  | "escalation"
37
38
  | "activity";
@@ -621,15 +622,18 @@ export class Scheduler {
621
622
  const turnCount = event.data?.turnCount ?? 0;
622
623
  const toolCallCount = event.data?.toolCallCount ?? 0;
623
624
 
624
- // Agent must have done real work: at least 1 turn AND at least 1 tool call.
625
- // An agent that exits cleanly but with 0 turns/tools did nothing —
626
- // likely hit a rate limit or API error. Treat as crash, not success.
627
- const hadMeaningfulWork = turnCount > 0 && toolCallCount > 0;
625
+ // Tool use is strong evidence of work, but planning/review tasks can
626
+ // legitimately deliver a substantive report without calling a tool.
627
+ // Accept durable assistant output; the mandatory main-orchestrator
628
+ // review gate still decides whether the work satisfies the contract.
629
+ const hasSubstantiveOutput = store.loadMessages(this.squadId, event.taskId)
630
+ .some((message) => message.from === event.agentName && message.type === "text" && message.text.trim().length > 0);
631
+ const hadMeaningfulWork = turnCount > 0 && (toolCallCount > 0 || hasSubstantiveOutput);
628
632
  if (hadMeaningfulWork) {
629
633
  this.handleTaskCompleted(event.taskId).then(() => this.updateContext());
630
634
  } else {
631
- // Agent exited without doing real work (0 turns or 0 tool calls).
632
- // Common causes: rate limit, API error, resource pressure, crash.
635
+ // Agent exited without a completed turn, tool work, or substantive
636
+ // assistant artifact. Common causes: rate limit/API/resource failure.
633
637
  // Retry once before failing.
634
638
  const retryKey = `spawn-retry:${event.taskId}`;
635
639
  if (!this.spawnRetries.has(retryKey)) {
@@ -637,7 +641,7 @@ export class Scheduler {
637
641
  const stderr = event.data?.stderr || "";
638
642
  const reason = turnCount === 0
639
643
  ? `exited with 0 turns (likely rate limit or API error)`
640
- : `exited with ${turnCount} turns but 0 tool calls (no work done)`;
644
+ : `exited with ${turnCount} turns but no tool calls or substantive output`;
641
645
  logError("squad-scheduler", `Agent ${event.agentName} ${reason}, code=${exitCode}. Retrying in 2s... stderr: ${stderr}`);
642
646
  store.updateTaskStatus(this.squadId, event.taskId, "pending");
643
647
  store.appendMessage(this.squadId, event.taskId, {
@@ -996,9 +1000,12 @@ export class Scheduler {
996
1000
  );
997
1001
 
998
1002
  if (allDone) {
999
- squad.status = "done";
1003
+ // Agent execution is only a candidate result. It cannot become "done"
1004
+ // until the main Pi independently reviews it against the original contract.
1005
+ if (squad.status === "review") return;
1006
+ beginOrchestratorReview(squad);
1000
1007
  store.saveSquad(squad);
1001
- this.emit({ type: "squad_completed", squadId: this.squadId });
1008
+ this.emit({ type: "squad_review_required", squadId: this.squadId });
1002
1009
  } else if (anyFailed && !anyInProgress) {
1003
1010
  // All remaining tasks are blocked/failed with no way forward
1004
1011
  const blockedCount = tasks.filter((t) => t.status === "blocked").length;
@@ -152,23 +152,30 @@ Use `squad_modify` when:
152
152
  - **`pause`** / **`resume`**: Stop/restart the entire squad
153
153
  - **`cancel`**: Abort everything (user changed their mind)
154
154
 
155
- ## After Squad CompletesReviewing Completed Work
156
-
157
- When you receive `[squad] Squad completed`, you are the last line of review. Do NOT just relay the summary:
158
- 1. **Check verdicts**: scan task outputs for QA verdicts (`## Verdict: PASS/FAIL/PASS WITH ISSUES`) and note any `PASS WITH ISSUES` minor issues
159
- 2. **Check evidence**: each task's output should include verification evidence (commands + results). If a task claimed done without evidence, run its Verify command yourself (build, test, curl — whatever the task description specified)
160
- 3. **Spot-check integration**: individual tasks passing doesn't guarantee the integrated result works run the end-to-end check when one exists (app builds, server starts, main flow works)
161
- 4. **Report with evidence**: tell the user what was verified (with the commands/results), and explicitly flag anything unverified or concerning
162
- 5. Suggest next steps if applicable
163
-
164
- Example:
165
- > Squad finished all 4 tasks:
166
- > - API endpoints created at `/api/auth` and `/api/users`
167
- > - JWT middleware with RS256 validation
168
- > - 12 tests passing
169
- > - README updated with API docs
155
+ ## After Agents FinishMandatory Independent Orchestrator Review
156
+
157
+ Agent execution finishing is **not completion or acceptance**. When you receive `[squad] TASK EXECUTION FINISHED` / `<squad_review_required>`, every squad output—including QA PASS—is an untrusted claim. You are the independent acceptance authority.
158
+
159
+ You MUST do all of this before telling the user the work succeeded:
160
+ 1. **Re-read the original contract**: reconstruct every requirement, boundary, and later clarification from the user's main-session conversation. The squad goal/task plan is only a secondary aid and cannot narrow the original request.
161
+ 2. **Inspect reality, not reports**: read the actual working-tree/commit diff and relevant source. Check every changed line for correctness, scope, integration points, unintended changes, error paths, security, and regressions.
162
+ 3. **Independently verify**: run the original Verify commands, build, and relevant tests yourself. Do not count command output pasted by squad agents as your evidence.
163
+ 4. **Run integration/E2E**: individual task/QA passes do not prove the integrated system. Exercise the real user flow in the target or production-like environment whenever runtime behavior changed. If impossible or genuinely inapplicable, state exactly why and mark it unverified—never silently skip it.
164
+ 5. **Fix and repeat**: if you find defects, fix them (or route explicit rework), then rerun the affected checks. Squad QA does not overrule your findings.
165
+ 6. **Record the gate**: call `squad_review` with requirement-by-requirement contract checks, your diff review, actual command/result evidence, integration/E2E evidence, and all issues. Until that tool records PASS/PASS_WITH_ISSUES, the squad remains `review` rather than `done`.
166
+ 7. **Report only reviewed facts**: clearly separate what you personally verified, remaining issues, and anything unverified.
167
+
168
+ Never ask “Want me to run the tests/E2E?” after agents finish. Run required acceptance checks immediately. Never merely relay or paraphrase the squad summary.
169
+
170
+ Example after independent review:
171
+ > Independent orchestrator review complete against the original 7 acceptance criteria:
172
+ > - Inspected `git diff --stat` and the auth/router/test changes; no unrelated files changed
173
+ > - `npm test`: 42/42 passed; `npm run build`: passed
174
+ > - Production-like E2E: login → refresh → protected route → logout passed
175
+ > - Found and fixed a cookie-domain defect missed by squad QA; reran unit + E2E successfully
176
+ > - `squad_review`: PASS
170
177
  >
171
- > Total cost: $1.23. Want me to run the full test suite or deploy?
178
+ > Remaining unverified: none.
172
179
 
173
180
  ## Decision Framework
174
181
 
@@ -181,5 +188,5 @@ Example:
181
188
  | User says "tell the backend agent to..." | `squad_message` to that agent |
182
189
  | User says "add a task for..." | `squad_modify` with `add_task` |
183
190
  | User says "cancel/stop" | `squad_modify` with `cancel` |
184
- | Squad completes | Summarize results, suggest next steps |
191
+ | Agents finish | Independently review against original contract, inspect diff, run verification + integration/E2E, call `squad_review`; only then report acceptance |
185
192
  | Squad fails | Report what failed, offer options (retry, modify, cancel) |
package/src/store.ts CHANGED
@@ -269,11 +269,11 @@ export function listSquads(): string[] {
269
269
  }
270
270
 
271
271
  export function findActiveSquads(): Squad[] {
272
- // Includes "failed": failure is recoverable (resume resets failed tasks), so
273
- // failed squads must stay discoverable. All callers filter by status.
272
+ // Includes "failed" (recoverable) and "review" (main-orchestrator gate), so
273
+ // neither state disappears across session restarts. All callers filter by status.
274
274
  return listSquads()
275
275
  .map((id) => loadSquad(id))
276
- .filter((s): s is Squad => s !== null && (s.status === "running" || s.status === "paused" || s.status === "failed"));
276
+ .filter((s): s is Squad => s !== null && (s.status === "running" || s.status === "paused" || s.status === "failed" || s.status === "review"));
277
277
  }
278
278
 
279
279
  /** List squads filtered by project cwd. If no cwd, returns all. */
package/src/types.ts CHANGED
@@ -71,11 +71,24 @@ export const DEFAULT_SQUAD_SETTINGS: SquadSettings = {
71
71
  // Squad
72
72
  // ============================================================================
73
73
 
74
- export type SquadStatus = "planning" | "running" | "paused" | "done" | "failed";
74
+ export type SquadStatus = "planning" | "running" | "paused" | "review" | "done" | "failed";
75
+
76
+ export interface SquadReview {
77
+ status: "pending" | "passed" | "failed";
78
+ requestedAt: string;
79
+ completedAt: string | null;
80
+ verdict: "pass" | "pass_with_issues" | "fail" | null;
81
+ contractChecks: string[];
82
+ diffReview: string;
83
+ verificationEvidence: string[];
84
+ integrationEvidence: string;
85
+ issues: string[];
86
+ }
75
87
 
76
88
  export interface SquadConfig {
77
89
  maxConcurrency: number;
78
90
  autoUnblock: boolean;
91
+ /** @deprecated Independent main-orchestrator review is always required. */
79
92
  reviewOnComplete: boolean;
80
93
  /** Max rework attempts when QA fails a task (0 = no rework, just fail) */
81
94
  maxRetries: number;
@@ -84,7 +97,7 @@ export interface SquadConfig {
84
97
  export const DEFAULT_SQUAD_CONFIG: SquadConfig = {
85
98
  maxConcurrency: 2,
86
99
  autoUnblock: true,
87
- reviewOnComplete: false,
100
+ reviewOnComplete: true,
88
101
  maxRetries: 2,
89
102
  };
90
103
 
@@ -99,6 +112,8 @@ export interface Squad {
99
112
  /** Agent name → overrides. Keys must exist in .pi/squad/agents/ */
100
113
  agents: Record<string, SquadAgentEntry>;
101
114
  config: SquadConfig;
115
+ /** Mandatory independent main-session review; absent on legacy/running squads. */
116
+ review?: SquadReview;
102
117
  }
103
118
 
104
119
  // ============================================================================