pi-squad 0.16.4 → 0.16.6

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
@@ -52,8 +52,10 @@ Squad agents—including QA/reviewer agents—produce candidate work and evidenc
52
52
  - Persisted status becomes `review`, never directly `done`.
53
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
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.
55
+ - Only `pass` or `pass_with_issues` changes the squad to `done`; `fail` leaves it review-blocked and cannot be overwritten by another verdict.
56
+ - Failed review is reworked in the **same authoritative squad**: use `squad_modify` with that `squadId` and `add_task` or `resume_task` (or `resume` when interrupted work exists). These operations reconstruct the scheduler after restart. `/squad resume <squad-id>` provides the same resume path.
57
+ - When rework begins, the failed attempt moves to `reviewHistory`, the squad returns to `running`, and its evidence remains auditable. After every rework task settles, a fresh pending review becomes the active gate and `squad_review` is required again.
58
+ - Pending and failed review gates survive Pi restarts and are restored on the next session. A separate squad never links to, remediates, or accepts the failed gate.
57
59
 
58
60
  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.
59
61
 
@@ -188,6 +190,7 @@ Full overlay with task list, live activity preview, and scrollable message view.
188
190
  | Command | Description |
189
191
  |---|---|
190
192
  | `/squad select` | Pick a squad to view |
193
+ | `/squad resume [squad-id]` | Reconstruct and resume an exact paused/failed/failed-review squad |
191
194
  | `/squad list` | List project squads |
192
195
  | `/squad all` | List all squads |
193
196
  | `/squad agents` | Manage agent definitions |
@@ -206,7 +209,11 @@ Full overlay with task list, live activity preview, and scrollable message view.
206
209
  | `squad` | Start a squad with goal + optional tasks/config |
207
210
  | `squad_status` | Check progress, costs, task states |
208
211
  | `squad_message` | Durably message an exact task; completed tasks reopen on their original session |
209
- | `squad_modify` | Add/cancel/complete/pause/resume tasks or squads |
212
+ | `squad_modify` | Add/cancel/complete/pause/resume tasks or squads, or replace a task's dependencies with `set_dependencies`; accepts `squadId` for exact same-squad failed-review rework |
213
+
214
+ Dependency repair uses top-level `taskId` and `depends`, for example `squad_modify({ action: "set_dependencies", taskId: "publish", depends: ["build"] })`. The replacement is validated atomically (known IDs, no self-reference, duplicates, or cycles) and is allowed only while the task is not running or done.
215
+
216
+ `cancel_task` is refused while any non-cancelled task directly depends on the target. Update every listed dependent explicitly with `set_dependencies`, then retry cancellation. Cancellation never cascades to dependents and never rewrites their dependency lists automatically; cancelled tasks remain visible in squad history and can be revived only with explicit `resume_task`.
210
217
 
211
218
  The main agent sees available agents in its system prompt and squad state when a squad is active.
212
219
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squad",
3
- "version": "0.16.4",
3
+ "version": "0.16.6",
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
@@ -46,6 +46,16 @@ let uiCtx: import("@earendil-works/pi-coding-agent").ExtensionContext | null = n
46
46
  const widgetState: SquadWidgetState = { squadId: null, enabled: true };
47
47
  let widgetControls: { requestUpdate: () => void; dispose: () => void } | null = null;
48
48
 
49
+ /** Format completion against active work while retaining cancelled history. */
50
+ function formatTaskProgress(tasks: Task[]): string {
51
+ const done = tasks.filter((task) => task.status === "done").length;
52
+ const cancelled = tasks.filter((task) => task.status === "cancelled").length;
53
+ const active = tasks.length - cancelled;
54
+ return cancelled > 0
55
+ ? `${done}/${active} active tasks done · ${cancelled} cancelled · ${tasks.length} total`
56
+ : `${done}/${tasks.length} tasks done`;
57
+ }
58
+
49
59
  /**
50
60
  * Resolve a model string (or null = session default) to its context window.
51
61
  * Reads uiCtx lazily so it always uses the live session's registry.
@@ -176,6 +186,38 @@ function getActiveScheduler(): Scheduler | null {
176
186
  return schedulers.get(activeSquadId) || null;
177
187
  }
178
188
 
189
+ /** Reconstruct and focus one exact persisted squad without creating/linking another. */
190
+ function ensureScheduler(pi: ExtensionAPI, squadId: string, skillPaths: string[]): Scheduler {
191
+ let scheduler = schedulers.get(squadId);
192
+ if (!scheduler) {
193
+ scheduler = new Scheduler(squadId, skillPaths, schedulerSpawnContext);
194
+ schedulers.set(squadId, scheduler);
195
+ wireSchedulerEvents(pi, scheduler, squadId);
196
+ }
197
+ activeSquadId = squadId;
198
+ widgetState.squadId = squadId;
199
+ widgetState.enabled = true;
200
+ widgetControls?.requestUpdate();
201
+ return scheduler;
202
+ }
203
+
204
+ function isResumeCandidate(squad: Squad): boolean {
205
+ return squad.status === "paused" || squad.status === "failed" ||
206
+ (squad.status === "review" && squad.review?.status === "failed") ||
207
+ store.loadAllTasks(squad.id).some((task) => task.status === "suspended" || task.status === "failed");
208
+ }
209
+
210
+ function resolveResumeSquad(cwd: string, explicitId?: string): Squad | null {
211
+ if (explicitId) return store.loadSquad(explicitId);
212
+ if (activeSquadId) {
213
+ const active = store.loadSquad(activeSquadId);
214
+ if (active?.cwd === cwd && isResumeCandidate(active)) return active;
215
+ }
216
+ return store.listSquadsForProject(cwd)
217
+ .filter(isResumeCandidate)
218
+ .sort((a, b) => b.created.localeCompare(a.created))[0] ?? null;
219
+ }
220
+
179
221
 
180
222
  // ============================================================================
181
223
  // Extension Entry
@@ -236,11 +278,10 @@ export default function (pi: ExtensionAPI) {
236
278
  return;
237
279
  }
238
280
 
239
- const doneCount = tasks.filter((t) => t.status === "done").length;
240
281
  const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
241
282
 
242
283
  const taskLines = tasks.map((t) => {
243
- const icon = t.status === "done" ? "✓" : t.status === "in_progress" ? "⏳" : t.status === "failed" ? "✗" : t.status === "blocked" ? "◻" : "·";
284
+ const icon = t.status === "done" ? "✓" : t.status === "in_progress" ? "⏳" : t.status === "failed" ? "✗" : t.status === "blocked" ? "◻" : t.status === "cancelled" ? "⊘" : "·";
244
285
  let line = ` ${icon} ${t.id} (${t.agent}) [${t.status}]`;
245
286
  if (t.output) line += ` — ${t.output}`;
246
287
  if (t.error) line += ` ERROR: ${t.error}`;
@@ -250,7 +291,7 @@ export default function (pi: ExtensionAPI) {
250
291
  const squadContext = [
251
292
  `<squad_status>`,
252
293
  `Squad: ${squad.id} — ${squad.goal}`,
253
- `Status: ${squad.status} | ${doneCount}/${tasks.length} tasks | $${totalCost.toFixed(2)}`,
294
+ `Status: ${squad.status} | ${formatTaskProgress(tasks)} | $${totalCost.toFixed(2)}`,
254
295
  taskLines,
255
296
  `</squad_status>`,
256
297
  ...(squad.status === "review" ? [buildOrchestratorReviewGate(squad, tasks)] : []),
@@ -411,6 +452,7 @@ export default function (pi: ExtensionAPI) {
411
452
  task.status === "in_progress" ? "⏳" :
412
453
  task.status === "blocked" ? "◻" :
413
454
  task.status === "failed" ? "✗" :
455
+ task.status === "cancelled" ? "⊘" :
414
456
  "·";
415
457
  let line = `${icon} ${taskId} (${task.agent}) — ${task.title} [${task.status}]`;
416
458
  if (task.blockedBy?.length) line += ` blocked by: ${task.blockedBy.join(", ")}`;
@@ -418,9 +460,11 @@ export default function (pi: ExtensionAPI) {
418
460
  })
419
461
  .join("\n");
420
462
 
463
+ const durableTasks = store.loadAllTasks(id!);
421
464
  const summary = [
422
465
  `Squad: ${id}`,
423
466
  `Status: ${context.status}`,
467
+ `Progress: ${formatTaskProgress(durableTasks)}`,
424
468
  `Elapsed: ${context.elapsed}`,
425
469
  `Cost: $${context.costs.total.toFixed(4)}`,
426
470
  ...(context.status === "review" ? ["Acceptance: BLOCKED — independent main-orchestrator review required via squad_review"] : []),
@@ -560,11 +604,13 @@ export default function (pi: ExtensionAPI) {
560
604
  pi.registerTool({
561
605
  name: "squad_modify",
562
606
  label: "Squad Modify",
563
- description: "Modify the running squad: add_task, cancel_task, complete_task (mark done + schedule dependents), pause, resume (also recovers failed squads), cancel (entire squad).",
607
+ description: "Modify one exact squad: add_task, set_dependencies (top-level depends), cancel_task, complete_task (mark done + schedule dependents), pause, resume_task, resume (including failed-review rework), cancel. Task actions reconstruct the persisted scheduler after restart when needed.",
564
608
  parameters: Type.Object({
609
+ squadId: Type.Optional(Type.String({ description: "Exact squad to modify (recommended for failed-review rework; default: focused/recoverable project squad)" })),
565
610
  action: Type.Union(
566
611
  [
567
612
  Type.Literal("add_task"),
613
+ Type.Literal("set_dependencies"),
568
614
  Type.Literal("cancel_task"),
569
615
  Type.Literal("pause_task"),
570
616
  Type.Literal("resume_task"),
@@ -576,6 +622,7 @@ export default function (pi: ExtensionAPI) {
576
622
  { description: "Action to perform" },
577
623
  ),
578
624
  taskId: Type.Optional(Type.String({ description: "Task ID for task-specific actions" })),
625
+ depends: Type.Optional(Type.Array(Type.String(), { description: "Complete replacement dependency list; required at top level for set_dependencies" })),
579
626
  output: Type.Optional(Type.String({ description: "Result summary for complete_task (what was accomplished)" })),
580
627
  task: Type.Optional(
581
628
  Type.Object({
@@ -590,45 +637,36 @@ export default function (pi: ExtensionAPI) {
590
637
  }),
591
638
 
592
639
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
593
- // Resume can work without an active scheduler — it recreates one from disk
594
640
  if (params.action === "resume") {
595
- // Find a squad to resume: use activeSquadId or find the latest paused one
596
- const squadId = activeSquadId || store.findActiveSquads()
597
- .filter((s) => s.cwd === ctx.cwd && (s.status === "paused" || s.status === "failed"))
598
- .sort((a, b) => b.created.localeCompare(a.created))[0]?.id;
599
-
600
- if (!squadId) {
601
- return { content: [{ type: "text" as const, text: "No paused or failed squad found to resume." }], details: undefined };
641
+ const squad = resolveResumeSquad(ctx.cwd, params.squadId);
642
+ if (!squad) {
643
+ const text = params.squadId
644
+ ? `Squad '${params.squadId}' not found.`
645
+ : "No paused, failed, or failed-review squad found to resume.";
646
+ return { content: [{ type: "text" as const, text }], details: undefined };
602
647
  }
603
-
604
- // Create a fresh scheduler if needed
605
- if (!schedulers.has(squadId)) {
606
- const scheduler = new Scheduler(squadId, squadSkillPaths, schedulerSpawnContext);
607
- schedulers.set(squadId, scheduler);
608
- activeSquadId = squadId;
609
-
610
- // Activate widget
611
- widgetState.squadId = squadId;
612
- widgetState.enabled = true;
613
- widgetControls?.requestUpdate();
614
-
615
- wireSchedulerEvents(pi, scheduler, squadId);
648
+ const resumeSched = ensureScheduler(pi, squad.id, squadSkillPaths);
649
+ try {
650
+ await resumeSched.resume();
651
+ } catch (err) {
652
+ return { content: [{ type: "text" as const, text: `Resume failed: ${(err as Error).message}` }], details: undefined };
616
653
  }
617
-
618
- const resumeSched = schedulers.get(squadId)!;
619
- resumeSched.resume().catch((err) => {
620
- logError("squad", `Resume error: ${(err as Error).message}`);
621
- });
622
-
623
- const tasks = store.loadAllTasks(squadId);
624
- const done = tasks.filter(t => t.status === "done").length;
625
- return { content: [{ type: "text" as const, text: `Squad "${squadId}" resumed (${done}/${tasks.length} done). Agents restarting in background.` }], details: undefined };
654
+ const tasks = store.loadAllTasks(squad.id);
655
+ return { content: [{ type: "text" as const, text: `Squad "${squad.id}" resumed (${formatTaskProgress(tasks)}). Agents restarting in background.` }], details: undefined };
626
656
  }
627
657
 
628
- const activeScheduler = getActiveScheduler();
629
- if (!activeScheduler || !activeSquadId) {
630
- return { content: [{ type: "text" as const, text: "No active squad. Use squad_modify with action 'resume' to resume a paused squad, or start a new one with the squad tool." }], details: undefined };
658
+ const squadId = params.squadId || activeSquadId;
659
+ if (!squadId || !store.loadSquad(squadId)) {
660
+ return { content: [{ type: "text" as const, text: params.squadId ? `Squad '${params.squadId}' not found.` : "No active squad. Provide squadId, select the squad, or start a new one." }], details: undefined };
661
+ }
662
+ let activeScheduler = schedulers.get(squadId) || null;
663
+ if (!activeScheduler && (params.action === "add_task" || params.action === "set_dependencies" || params.action === "cancel_task" || params.action === "resume_task" || params.action === "complete_task" || params.action === "pause_task")) {
664
+ activeScheduler = ensureScheduler(pi, squadId, squadSkillPaths);
631
665
  }
666
+ if (!activeScheduler) {
667
+ 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 };
668
+ }
669
+ activeSquadId = squadId;
632
670
 
633
671
  switch (params.action) {
634
672
  case "add_task": {
@@ -645,17 +683,19 @@ export default function (pi: ExtensionAPI) {
645
683
  if (badDeps.length > 0) {
646
684
  return { content: [{ type: "text" as const, text: `Unknown dependency task(s): ${badDeps.join(", ")}. Existing tasks: ${[...existingIds].join(", ")}` }], details: undefined };
647
685
  }
648
- if (!store.loadAgentDef(params.task.agent, ctx.cwd)) {
649
- const available = store.loadAllAgentDefs(ctx.cwd).filter((a) => !a.disabled).map((a) => a.name).join(", ");
686
+ const targetCwd = store.loadSquad(squadId)!.cwd;
687
+ if (!store.loadAgentDef(params.task.agent, targetCwd)) {
688
+ const available = store.loadAllAgentDefs(targetCwd).filter((a) => !a.disabled).map((a) => a.name).join(", ");
650
689
  return { content: [{ type: "text" as const, text: `Unknown agent '${params.task.agent}'. Available: ${available}` }], details: undefined };
651
690
  }
691
+ const dependencies = params.task.depends || [];
652
692
  const task: Task = {
653
693
  id: params.task.id,
654
694
  title: params.task.title,
655
695
  description: params.task.description || "",
656
696
  agent: params.task.agent,
657
- status: "pending",
658
- depends: params.task.depends || [],
697
+ status: dependencies.every((dependency) => existing.find((candidate) => candidate.id === dependency)?.status === "done") ? "pending" : "blocked",
698
+ depends: dependencies,
659
699
  ...(params.task.inheritContext ? { inheritContext: true } : {}),
660
700
  created: store.now(),
661
701
  started: null,
@@ -664,14 +704,32 @@ export default function (pi: ExtensionAPI) {
664
704
  error: null,
665
705
  usage: { inputTokens: 0, outputTokens: 0, cost: 0, turns: 0 },
666
706
  };
667
- store.createTask(activeSquadId, task);
668
- activeScheduler.updateContext();
669
- return { content: [{ type: "text" as const, text: `Task '${task.id}' added.` }], details: undefined };
707
+ try {
708
+ await activeScheduler.addTask(task);
709
+ } catch (err) {
710
+ return { content: [{ type: "text" as const, text: `add_task failed: ${(err as Error).message}` }], details: undefined };
711
+ }
712
+ return { content: [{ type: "text" as const, text: `Task '${task.id}' added to squad '${squadId}'.` }], details: undefined };
713
+ }
714
+
715
+ case "set_dependencies": {
716
+ if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
717
+ if (!params.depends) return { content: [{ type: "text" as const, text: "Provide top-level depends for set_dependencies (use [] to remove all dependencies)." }], details: undefined };
718
+ try {
719
+ await activeScheduler.setDependencies(params.taskId, params.depends);
720
+ } catch (err) {
721
+ return { content: [{ type: "text" as const, text: `set_dependencies failed: ${(err as Error).message}` }], details: undefined };
722
+ }
723
+ return { content: [{ type: "text" as const, text: `Task '${params.taskId}' dependencies updated in squad '${squadId}'.` }], details: undefined };
670
724
  }
671
725
 
672
726
  case "cancel_task": {
673
727
  if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
674
- await activeScheduler.cancelTask(params.taskId);
728
+ try {
729
+ await activeScheduler.cancelTask(params.taskId);
730
+ } catch (err) {
731
+ return { content: [{ type: "text" as const, text: `cancel_task failed: ${(err as Error).message}` }], details: undefined };
732
+ }
675
733
  return { content: [{ type: "text" as const, text: `Task '${params.taskId}' cancelled.` }], details: undefined };
676
734
  }
677
735
 
@@ -683,10 +741,12 @@ export default function (pi: ExtensionAPI) {
683
741
 
684
742
  case "resume_task": {
685
743
  if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
686
- activeScheduler.resumeTask(params.taskId).catch((err) => {
687
- logError("squad", `Resume task error: ${(err as Error).message}`);
688
- });
689
- return { content: [{ type: "text" as const, text: `Task '${params.taskId}' resumed.` }], details: undefined };
744
+ try {
745
+ await activeScheduler.resumeTask(params.taskId);
746
+ } catch (err) {
747
+ return { content: [{ type: "text" as const, text: `resume_task failed: ${(err as Error).message}` }], details: undefined };
748
+ }
749
+ return { content: [{ type: "text" as const, text: `Task '${params.taskId}' resumed in squad '${squadId}'.` }], details: undefined };
690
750
  }
691
751
 
692
752
  case "complete_task": {
@@ -767,7 +827,7 @@ export default function (pi: ExtensionAPI) {
767
827
  // occur after the mailbox-first write but before the squad/task is reopened.
768
828
  const pendingMailSquads = store.listSquadsForProject(ctx.cwd)
769
829
  .filter((squad) => store.loadAllTasks(squad.id)
770
- .some((task) => store.loadPendingTaskMessages(squad.id, task.id).length > 0))
830
+ .some((task) => task.status !== "cancelled" && store.loadPendingTaskMessages(squad.id, task.id).length > 0))
771
831
  .sort((a, b) => b.created.localeCompare(a.created));
772
832
  for (const squad of pendingMailSquads) {
773
833
  let scheduler = schedulers.get(squad.id);
@@ -796,7 +856,7 @@ export default function (pi: ExtensionAPI) {
796
856
  if (done > 0) {
797
857
  pi.sendMessage({
798
858
  customType: "squad-paused",
799
- content: `[squad] Found paused squad "${squad.id}" (${squad.goal}) — ${done}/${tasks.length} done. ` +
859
+ content: `[squad] Found paused squad "${squad.id}" (${squad.goal}) — ${formatTaskProgress(tasks)}. ` +
800
860
  `Use squad_modify with action "resume" to continue, or start a new squad.`,
801
861
  display: true,
802
862
  });
@@ -866,12 +926,13 @@ export default function (pi: ExtensionAPI) {
866
926
  // =========================================================================
867
927
 
868
928
  pi.registerCommand("squad", {
869
- description: "Browse, select, and manage squads. Usage: /squad [list|all|select|agents|msg|widget|panel|cancel|clear]",
929
+ description: "Browse, select, and manage squads. Usage: /squad [list|all|select|resume|agents|msg|widget|panel|cancel|clear]",
870
930
  getArgumentCompletions: (prefix) => {
871
931
  const subs = [
872
932
  { value: "list", label: "list", description: "List squads for current project" },
873
933
  { value: "all", label: "all", description: "List all squads, select to activate" },
874
934
  { value: "select", label: "select", description: "Pick a squad to view (interactive)" },
935
+ { value: "resume", label: "resume", description: "Resume an exact paused/failed/failed-review squad" },
875
936
  { value: "agents", label: "agents", description: "List, view, or edit agent definitions" },
876
937
  { value: "defaults", label: "defaults", description: "Default model/thinking for agents (follow main session, pi default, or fixed)" },
877
938
  { value: "advisor", label: "advisor", description: "Advisor-first rescue for stuck agents (on/off, model, limits)" },
@@ -941,6 +1002,24 @@ export default function (pi: ExtensionAPI) {
941
1002
  return;
942
1003
  }
943
1004
 
1005
+ case "resume": {
1006
+ const squad = resolveResumeSquad(ctx.cwd, parts[1]);
1007
+ if (!squad) {
1008
+ ctx.ui.notify(parts[1] ? `Squad '${parts[1]}' not found` : "No paused, failed, or failed-review squad found", "warning");
1009
+ return;
1010
+ }
1011
+ const scheduler = ensureScheduler(pi, squad.id, squadSkillPaths);
1012
+ try {
1013
+ await scheduler.resume();
1014
+ } catch (error) {
1015
+ ctx.ui.notify(`Resume failed: ${(error as Error).message}`, "error");
1016
+ return;
1017
+ }
1018
+ const tasks = store.loadAllTasks(squad.id);
1019
+ ctx.ui.notify(`Resumed: ${squad.id} (${formatTaskProgress(tasks)})`, "info");
1020
+ return;
1021
+ }
1022
+
944
1023
  case "widget": {
945
1024
  widgetState.enabled = !widgetState.enabled;
946
1025
  if (widgetState.enabled) {
@@ -1112,10 +1191,9 @@ export default function (pi: ExtensionAPI) {
1112
1191
  "🗑 Delete ALL squads",
1113
1192
  ...squads.map((s) => {
1114
1193
  const tasks = store.loadAllTasks(s.id);
1115
- const done = tasks.filter((t) => t.status === "done").length;
1116
1194
  const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
1117
1195
  const icon = s.status === "done" ? "✓" : s.status === "running" ? "⏳" : s.status === "review" ? "◆" : s.status === "failed" ? "✗" : "·";
1118
- return `${icon} ${s.id} [${s.status}] ${done}/${tasks.length} $${cost.toFixed(2)}`;
1196
+ return `${icon} ${s.id} [${s.status}] ${formatTaskProgress(tasks)} $${cost.toFixed(2)}`;
1119
1197
  }),
1120
1198
  ];
1121
1199
 
@@ -1398,7 +1476,7 @@ export default function (pi: ExtensionAPI) {
1398
1476
  activateSquadView(direct.id, ctx);
1399
1477
  return;
1400
1478
  }
1401
- ctx.ui.notify(`Unknown: /squad ${sub}. Try: list, all, select, agents, defaults, msg, widget, panel, cancel, clear, cleanup`, "warning");
1479
+ ctx.ui.notify(`Unknown: /squad ${sub}. Try: list, all, select, resume, agents, defaults, msg, widget, panel, cancel, clear, cleanup`, "warning");
1402
1480
  }
1403
1481
  },
1404
1482
  });
@@ -1422,11 +1500,10 @@ async function pickSquad(
1422
1500
 
1423
1501
  const options = squads.map((s) => {
1424
1502
  const tasks = store.loadAllTasks(s.id);
1425
- const done = tasks.filter((t) => t.status === "done").length;
1426
1503
  const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
1427
1504
  const icon = s.status === "done" ? "✓" : s.status === "running" ? "⏳" : s.status === "review" ? "◆" : s.status === "failed" ? "✗" : "·";
1428
1505
  const project = showProject ? ` — ${s.cwd.split("/").pop()}` : "";
1429
- return `${icon} ${s.id} [${s.status}] ${done}/${tasks.length} $${cost.toFixed(2)}${project}`;
1506
+ return `${icon} ${s.id} [${s.status}] ${formatTaskProgress(tasks)} $${cost.toFixed(2)}${project}`;
1430
1507
  });
1431
1508
 
1432
1509
  const choice = await ctx.ui.select("Select a squad", options);
@@ -1459,9 +1536,8 @@ function activateSquadView(squadId: string, ctx: import("@earendil-works/pi-codi
1459
1536
  // Compact notification — widget already shows full task details.
1460
1537
  // Avoid large multi-line notifications that can break TUI layout.
1461
1538
  const tasks = store.loadAllTasks(squadId);
1462
- const done = tasks.filter((t) => t.status === "done").length;
1463
1539
  const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
1464
- ctx.ui.notify(`Viewing: ${squad.id} [${squad.status}] ${done}/${tasks.length} $${cost.toFixed(2)}`, "info");
1540
+ ctx.ui.notify(`Viewing: ${squad.id} [${squad.status}] ${formatTaskProgress(tasks)} $${cost.toFixed(2)}`, "info");
1465
1541
  }
1466
1542
 
1467
1543
  // ============================================================================
@@ -1500,11 +1576,10 @@ function wireSchedulerEvents(pi: ExtensionAPI, scheduler: Scheduler, squadId: st
1500
1576
  case "squad_failed": {
1501
1577
  const tasks = store.loadAllTasks(squadId);
1502
1578
  const failed = tasks.filter((task) => task.status === "failed");
1503
- const done = tasks.filter((task) => task.status === "done");
1504
1579
  pi.sendMessage({
1505
1580
  customType: "squad-failed",
1506
1581
  content: `[squad] Squad "${squadId}" has stalled. ` +
1507
- `${done.length}/${tasks.length} tasks done, ${failed.length} failed.\n` +
1582
+ `${formatTaskProgress(tasks)}, ${failed.length} failed.\n` +
1508
1583
  `Failed: ${buildFailureSummary(tasks)}\n` +
1509
1584
  "Use squad_status for details or squad_modify to adjust.",
1510
1585
  display: true,
@@ -23,6 +23,7 @@ function statusIcon(status: TaskStatus, th: Theme): string {
23
23
  case "blocked": return th.fg("muted", "◻");
24
24
  case "failed": return th.fg("error", "✗");
25
25
  case "suspended": return th.fg("muted", "⏸");
26
+ case "cancelled": return th.fg("muted", "⊘");
26
27
  default: return th.fg("dim", "·");
27
28
  }
28
29
  }
@@ -75,6 +76,11 @@ export function setupSquadWidget(
75
76
  const lines: string[] = [];
76
77
  const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
77
78
  const doneCount = tasks.filter((t) => t.status === "done").length;
79
+ const cancelledCount = tasks.filter((t) => t.status === "cancelled").length;
80
+ const activeCount = tasks.length - cancelledCount;
81
+ const progressText = cancelledCount > 0
82
+ ? `${doneCount}/${activeCount} active tasks done · ${cancelledCount} cancelled · ${tasks.length} total`
83
+ : `${doneCount}/${tasks.length}`;
78
84
  const elapsed = Date.now() - new Date(squad.created).getTime();
79
85
  const taskMessages = new Map<string, TaskMessage[]>();
80
86
  const recentOrchestratorByTask = new Map<string, TaskMessage>();
@@ -103,7 +109,7 @@ export function setupSquadWidget(
103
109
  : "";
104
110
  lines.push(
105
111
  `${sIcon} ${th.fg("accent", "squad")}${orchestratorSignal} ${th.fg("dim", squad.goal.slice(0, 35))} ` +
106
- `${th.fg("muted", `${doneCount}/${tasks.length}`)} ` +
112
+ `${th.fg("muted", progressText)} ` +
107
113
  `${th.fg("dim", `$${totalCost.toFixed(2)}`)} ` +
108
114
  `${th.fg("dim", formatElapsed(elapsed))} ` +
109
115
  `${th.fg("dim", "^q detail · /squad msg")}`
@@ -144,6 +150,8 @@ export function setupSquadWidget(
144
150
  line += ` ${th.fg("dim", (detail ? `${toolStr} ${detail}` : toolStr).slice(0, 30))}`;
145
151
  }
146
152
  }
153
+ } else if (task.status === "cancelled") {
154
+ line += ` ${th.fg("muted", "cancelled")}`;
147
155
  } else if (task.status === "blocked") {
148
156
  const blockers = task.depends.filter((d) => {
149
157
  const dep = tasks.find((t) => t.id === d);
@@ -164,12 +172,12 @@ export function setupSquadWidget(
164
172
  const cacheKey = `${squad.status}:${tasks.map(t => `${t.id}=${t.status}:${t.usage.turns}`).join(",")}:${recentMessageKeys.join(",")}`;
165
173
 
166
174
  const statusText = squad.status === "done"
167
- ? th.fg("success", `✓ squad ${doneCount}/${tasks.length}`)
175
+ ? th.fg("success", `✓ squad ${progressText}`)
168
176
  : squad.status === "failed"
169
- ? th.fg("error", `✗ squad ${doneCount}/${tasks.length}`)
177
+ ? th.fg("error", `✗ squad ${progressText}`)
170
178
  : squad.status === "review"
171
179
  ? th.fg("warning", `◆ squad review required`)
172
- : th.fg("accent", `⏳ squad ${doneCount}/${tasks.length} $${totalCost.toFixed(2)}`);
180
+ : th.fg("accent", `⏳ squad ${progressText} $${totalCost.toFixed(2)}`);
173
181
 
174
182
  return { lines, cacheKey, statusText };
175
183
  }
@@ -24,6 +24,8 @@ function statusIcon(status: TaskStatus, th: Theme): string {
24
24
  return th.fg("error", "✗");
25
25
  case "suspended":
26
26
  return th.fg("muted", "⏸");
27
+ case "cancelled":
28
+ return th.fg("muted", "⊘");
27
29
  case "pending":
28
30
  default:
29
31
  return th.fg("dim", "·");
@@ -42,6 +44,8 @@ function statusLabel(status: TaskStatus): string {
42
44
  return "FAILED";
43
45
  case "suspended":
44
46
  return "paused";
47
+ case "cancelled":
48
+ return "cancelled";
45
49
  case "pending":
46
50
  return "pending";
47
51
  }
@@ -241,11 +245,15 @@ export class TaskListView {
241
245
  private renderSummary(tasks: Task[], width: number): string {
242
246
  const th = this.theme;
243
247
  const done = tasks.filter((t) => t.status === "done").length;
248
+ const cancelled = tasks.filter((t) => t.status === "cancelled").length;
244
249
  const total = tasks.length;
250
+ const activeTotal = total - cancelled;
245
251
  const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
246
252
 
247
253
  const parts: string[] = [];
248
- parts.push(th.fg("accent", `${done}/${total}`));
254
+ parts.push(th.fg("accent", cancelled > 0
255
+ ? `${done}/${activeTotal} active tasks done · ${cancelled} cancelled · ${total} total`
256
+ : `${done}/${total}`));
249
257
  if (totalCost > 0) parts.push(th.fg("dim", `$${totalCost.toFixed(4)}`));
250
258
 
251
259
  // Find squad creation time for elapsed
package/src/report.ts CHANGED
@@ -5,10 +5,18 @@ import type { Task } from "./types.js";
5
5
  * This is durable report data, not a UI preview: never shorten task output.
6
6
  */
7
7
  export function buildCompletionSummary(tasks: Task[]): string {
8
- return tasks
8
+ const completed = tasks
9
9
  .filter((task) => task.status === "done")
10
10
  .map((task) => `- ${task.id} (${task.agent}): ${task.output || "done"}`)
11
11
  .join("\n");
12
+ const cancelled = tasks
13
+ .filter((task) => task.status === "cancelled")
14
+ .map((task) => `- ${task.id} (${task.agent}): cancelled`)
15
+ .join("\n");
16
+
17
+ return [completed, cancelled ? `CANCELLED TASKS (neutral; not successful output)\n${cancelled}` : ""]
18
+ .filter(Boolean)
19
+ .join("\n\n");
12
20
  }
13
21
 
14
22
  /** Build the full failure handoff without shortening diagnostics. */
package/src/review.ts CHANGED
@@ -11,6 +11,18 @@ export interface OrchestratorReviewInput {
11
11
  issues: string[];
12
12
  }
13
13
 
14
+ /**
15
+ * Start same-squad rework without discarding completed review evidence.
16
+ * The next all-tasks-done transition creates a new active pending review.
17
+ */
18
+ export function beginOrchestratorRework(squad: Squad): void {
19
+ if (squad.review && squad.review.status !== "pending") {
20
+ squad.reviewHistory = [...(squad.reviewHistory ?? []), { ...squad.review }];
21
+ }
22
+ delete squad.review;
23
+ squad.status = "running";
24
+ }
25
+
14
26
  /** Move a squad from agent execution into mandatory independent main-session review. */
15
27
  export function beginOrchestratorReview(squad: Squad): void {
16
28
  squad.status = "review";
@@ -35,6 +47,9 @@ export function recordOrchestratorReview(squad: Squad, input: OrchestratorReview
35
47
  if (squad.status !== "review" || !squad.review) {
36
48
  throw new Error(`Squad '${squad.id}' is not awaiting orchestrator review`);
37
49
  }
50
+ if (squad.review.status !== "pending") {
51
+ throw new Error(`Squad '${squad.id}' review attempt is already ${squad.review.status}; begin same-squad rework before submitting a fresh review`);
52
+ }
38
53
 
39
54
  const contractChecks = cleanList(input.contractChecks);
40
55
  const verificationEvidence = cleanList(input.verificationEvidence);
package/src/scheduler.ts CHANGED
@@ -19,7 +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
+ import { beginOrchestratorReview, beginOrchestratorRework } from "./review.js";
23
23
 
24
24
  // ============================================================================
25
25
  // Types
@@ -170,6 +170,10 @@ export class Scheduler {
170
170
 
171
171
  /** Start the scheduler — begins scheduling ready tasks */
172
172
  async start(): Promise<void> {
173
+ if (this.running) {
174
+ await this.reconcile();
175
+ return;
176
+ }
173
177
  this.running = true;
174
178
  this.monitor.start();
175
179
  await this.reconcile();
@@ -200,13 +204,15 @@ export class Scheduler {
200
204
  await this.pool.killAll();
201
205
  }
202
206
 
203
- /** Resume from suspended OR failed state. Failure is never terminal:
204
- * failed tasks are reset to pending and a failed squad becomes running. */
207
+ /** Resume suspended/failed work. A failed review is archived only when
208
+ * resumable work actually exists; a bare resume cannot bypass the gate. */
205
209
  async resume(): Promise<void> {
206
210
  const tasks = store.loadAllTasks(this.squadId);
211
+ let resumedWork = false;
207
212
  for (const task of tasks) {
208
213
  if (task.status === "suspended") {
209
214
  store.updateTaskStatus(this.squadId, task.id, "pending");
215
+ resumedWork = true;
210
216
  } else if (task.status === "failed") {
211
217
  store.updateTaskStatus(this.squadId, task.id, "pending", { error: null });
212
218
  store.appendMessage(this.squadId, task.id, {
@@ -215,13 +221,19 @@ export class Scheduler {
215
221
  type: "status",
216
222
  text: "Reset failed → pending on squad resume",
217
223
  });
224
+ resumedWork = true;
218
225
  }
219
226
  }
220
227
 
221
228
  const squad = store.loadSquad(this.squadId);
222
- if (squad && (squad.status === "paused" || squad.status === "failed")) {
223
- squad.status = "running";
224
- store.saveSquad(squad);
229
+ if (squad) {
230
+ if (resumedWork && squad.status === "review" && squad.review?.status === "failed") {
231
+ beginOrchestratorRework(squad);
232
+ store.saveSquad(squad);
233
+ } else if (squad.status === "paused" || squad.status === "failed") {
234
+ squad.status = "running";
235
+ store.saveSquad(squad);
236
+ }
225
237
  }
226
238
 
227
239
  await this.start();
@@ -246,6 +258,7 @@ export class Scheduler {
246
258
  // mutation/RPC delivery, reopen exactly that task on reconstruction.
247
259
  let recoveredMailbox = false;
248
260
  for (const task of tasks) {
261
+ if (task.status === "cancelled") continue;
249
262
  if (this.pool.isRunning(task.id)) continue;
250
263
  if (store.loadPendingTaskMessages(this.squadId, task.id).length === 0) continue;
251
264
  if (task.status === "done") await this.invalidateDescendants(task.id);
@@ -262,8 +275,7 @@ export class Scheduler {
262
275
  recoveredMailbox = true;
263
276
  }
264
277
  if (recoveredMailbox && squad.status !== "running") {
265
- squad.status = "running";
266
- delete squad.review;
278
+ beginOrchestratorRework(squad);
267
279
  store.saveSquad(squad);
268
280
  }
269
281
 
@@ -297,6 +309,11 @@ export class Scheduler {
297
309
  }
298
310
 
299
311
  await this.scheduleReadyTasks();
312
+
313
+ const freshSquad = store.loadSquad(this.squadId);
314
+ if (freshSquad && (freshSquad.status === "running" || freshSquad.status === "failed")) {
315
+ this.checkSquadCompletion(store.loadAllTasks(this.squadId), freshSquad);
316
+ }
300
317
  }
301
318
 
302
319
  // =========================================================================
@@ -647,6 +664,7 @@ export class Scheduler {
647
664
  }
648
665
 
649
666
  private handleUnexpectedAgentExit(event: AgentEvent): void {
667
+ if (store.loadTask(this.squadId, event.taskId)?.status === "cancelled") return;
650
668
  const exitCode = event.data?.exitCode ?? 1;
651
669
  const turnCount = event.data?.turnCount ?? 0;
652
670
  const stderr = event.data?.stderr || "";
@@ -776,6 +794,7 @@ export class Scheduler {
776
794
  }
777
795
 
778
796
  case "agent_settled": {
797
+ if (store.loadTask(this.squadId, event.taskId)?.status === "cancelled") return;
779
798
  // A mailbox entry not acknowledged by Pi outranks this run's candidate
780
799
  // completion. Reopen the same session so accepted-at-least-once delivery
781
800
  // occurs before the task can become done.
@@ -827,8 +846,8 @@ export class Scheduler {
827
846
  const task = store.loadTask(this.squadId, taskId);
828
847
  if (!task) return;
829
848
 
830
- // Guard against double-completion
831
- if (task.status === "done") return;
849
+ // Guard against double-completion and late callbacks after cancellation.
850
+ if (task.status === "done" || task.status === "cancelled") return;
832
851
 
833
852
  // Extract output from last messages
834
853
  const messages = store.loadMessages(this.squadId, taskId);
@@ -896,6 +915,7 @@ export class Scheduler {
896
915
  }
897
916
 
898
917
  private handleTaskFailed(taskId: string, error: string): void {
918
+ if (store.loadTask(this.squadId, taskId)?.status === "cancelled") return;
899
919
  store.updateTaskStatus(this.squadId, taskId, "failed", {
900
920
  error,
901
921
  completed: store.now(),
@@ -1137,10 +1157,11 @@ export class Scheduler {
1137
1157
  private checkSquadCompletion(tasks: Task[], squad: Squad): void {
1138
1158
  if (tasks.length === 0) return;
1139
1159
 
1140
- const allDone = tasks.every((t) => t.status === "done");
1141
- const anyFailed = tasks.some((t) => t.status === "failed");
1142
- const anyInProgress = tasks.some(
1143
- (t) => t.status === "in_progress" || t.status === "pending",
1160
+ const relevant = tasks.filter((task) => task.status !== "cancelled");
1161
+ const allDone = relevant.every((task) => task.status === "done");
1162
+ const anyFailed = relevant.some((task) => task.status === "failed");
1163
+ const anyInProgress = relevant.some(
1164
+ (task) => task.status === "in_progress" || task.status === "pending",
1144
1165
  );
1145
1166
 
1146
1167
  if (allDone) {
@@ -1152,9 +1173,9 @@ export class Scheduler {
1152
1173
  this.emit({ type: "squad_review_required", squadId: this.squadId });
1153
1174
  } else if (anyFailed && !anyInProgress) {
1154
1175
  // All remaining tasks are blocked/failed with no way forward
1155
- const blockedCount = tasks.filter((t) => t.status === "blocked").length;
1156
- const failedCount = tasks.filter((t) => t.status === "failed").length;
1157
- if (blockedCount + failedCount === tasks.filter((t) => t.status !== "done").length) {
1176
+ const blockedCount = relevant.filter((task) => task.status === "blocked").length;
1177
+ const failedCount = relevant.filter((task) => task.status === "failed").length;
1178
+ if (blockedCount + failedCount === relevant.filter((task) => task.status !== "done").length) {
1158
1179
  squad.status = "failed";
1159
1180
  store.saveSquad(squad);
1160
1181
  this.emit({ type: "squad_failed", squadId: this.squadId });
@@ -1286,6 +1307,7 @@ export class Scheduler {
1286
1307
  if (seen.has(descendant.id)) continue;
1287
1308
  seen.add(descendant.id);
1288
1309
  queue.push(...(byDependency.get(descendant.id) ?? []));
1310
+ if (descendant.status === "cancelled") continue;
1289
1311
  store.updateTaskStatus(this.squadId, descendant.id, "blocked", {
1290
1312
  completed: null,
1291
1313
  error: null,
@@ -1301,6 +1323,17 @@ export class Scheduler {
1301
1323
  await Promise.all(kills);
1302
1324
  }
1303
1325
 
1326
+ private reopenSquadForWork(): void {
1327
+ const squad = store.loadSquad(this.squadId);
1328
+ if (!squad) throw new Error(`Squad not found: ${this.squadId}`);
1329
+ if (squad.status === "review" || squad.status === "done" || squad.review) {
1330
+ beginOrchestratorRework(squad);
1331
+ } else if (squad.status !== "running") {
1332
+ squad.status = "running";
1333
+ }
1334
+ store.saveSquad(squad);
1335
+ }
1336
+
1304
1337
  private async reopenTaskForMessage(task: Task): Promise<void> {
1305
1338
  if (task.status === "done") await this.invalidateDescendants(task.id);
1306
1339
  const tasks = store.loadAllTasks(this.squadId);
@@ -1313,14 +1346,7 @@ export class Scheduler {
1313
1346
  completed: null,
1314
1347
  });
1315
1348
 
1316
- const squad = store.loadSquad(this.squadId);
1317
- if (squad && squad.status !== "running") {
1318
- squad.status = "running";
1319
- // Any prior acceptance/review applies to the pre-resume result. The
1320
- // normal all-done path will require a fresh independent review.
1321
- delete squad.review;
1322
- store.saveSquad(squad);
1323
- }
1349
+ this.reopenSquadForWork();
1324
1350
  }
1325
1351
 
1326
1352
  /** Send a main-orchestrator request to one exact task and await its next reply. */
@@ -1338,6 +1364,8 @@ export class Scheduler {
1338
1364
  expectsReply,
1339
1365
  });
1340
1366
 
1367
+ if (task.status === "cancelled") return false;
1368
+
1341
1369
  const request = expectsReply
1342
1370
  ? `[squad] Main orchestrator requests a direct response:\n${message}\n\nReply directly in your next assistant message. That complete message will be forwarded automatically to the main session.`
1343
1371
  : `[squad] Main orchestrator message:\n${message}`;
@@ -1372,6 +1400,9 @@ export class Scheduler {
1372
1400
 
1373
1401
  /** Pause a running task */
1374
1402
  async pauseTask(taskId: string): Promise<void> {
1403
+ const task = store.loadTask(this.squadId, taskId);
1404
+ if (!task) throw new Error(`Task not found: ${taskId}`);
1405
+ if (task.status === "cancelled") throw new Error(`Task '${taskId}' is cancelled; use resume_task to revive it.`);
1375
1406
  if (this.pool.isRunning(taskId)) {
1376
1407
  await this.pool.steer(taskId, "[squad] Task paused by user. Summarize your current state.");
1377
1408
  // Give agent a moment to respond, then kill
@@ -1381,10 +1412,112 @@ export class Scheduler {
1381
1412
  this.updateContext();
1382
1413
  }
1383
1414
 
1384
- /** Resume a suspended/failed task. Reconciles so a failed squad heals too. */
1415
+ /** Add work to this exact squad and schedule it, reconstructing safely after restart. */
1416
+ async addTask(task: Task): Promise<void> {
1417
+ if (store.loadTask(this.squadId, task.id)) throw new Error(`Task already exists: ${task.id}`);
1418
+ store.createTask(this.squadId, task);
1419
+ this.reopenSquadForWork();
1420
+ await this.start();
1421
+ this.updateContext();
1422
+ }
1423
+
1424
+ /** Atomically replace one task's dependency list after validating the complete historical DAG. */
1425
+ async setDependencies(taskId: string, depends: string[]): Promise<void> {
1426
+ const tasks = store.loadAllTasks(this.squadId);
1427
+ const target = tasks.find((task) => task.id === taskId);
1428
+ if (!target) throw new Error(`Task not found: ${taskId}`);
1429
+ if (target.status === "in_progress") {
1430
+ throw new Error(`Cannot edit dependencies for in_progress task '${taskId}'; pause or cancel it first.`);
1431
+ }
1432
+ if (target.status === "done") {
1433
+ throw new Error(`Cannot edit dependencies for done task '${taskId}'; resume it first so descendant invalidation remains authoritative.`);
1434
+ }
1435
+
1436
+ const duplicate = depends.find((dependency, index) => depends.indexOf(dependency) !== index);
1437
+ if (duplicate) throw new Error(`Duplicate dependency '${duplicate}' for task '${taskId}'.`);
1438
+ if (depends.includes(taskId)) throw new Error(`Task '${taskId}' cannot depend on itself.`);
1439
+ const known = new Set(tasks.map((task) => task.id));
1440
+ const unknown = depends.filter((dependency) => !known.has(dependency));
1441
+ if (unknown.length > 0) throw new Error(`Unknown dependency task(s): ${unknown.join(", ")}.`);
1442
+
1443
+ const wasRunnable = target.status === "pending" && target.depends.every(
1444
+ (dependency) => tasks.find((candidate) => candidate.id === dependency)?.status === "done",
1445
+ );
1446
+ const graph = new Map(tasks.map((task) => [task.id, [...task.depends]]));
1447
+ graph.set(taskId, [...depends]);
1448
+ for (const [id, dependencies] of graph) {
1449
+ const seen = new Set<string>();
1450
+ for (const dependency of dependencies) {
1451
+ if (!known.has(dependency)) throw new Error(`Task '${id}' depends on unknown task '${dependency}'.`);
1452
+ if (dependency === id) throw new Error(`Task '${id}' cannot depend on itself.`);
1453
+ if (seen.has(dependency)) throw new Error(`Duplicate dependency '${dependency}' for task '${id}'.`);
1454
+ seen.add(dependency);
1455
+ }
1456
+ }
1457
+ const color = new Map<string, 0 | 1 | 2>();
1458
+ const stack: string[] = [];
1459
+ const visit = (id: string): void => {
1460
+ const state = color.get(id) ?? 0;
1461
+ if (state === 2) return;
1462
+ if (state === 1) {
1463
+ const start = stack.indexOf(id);
1464
+ const cycle = [...stack.slice(start), id];
1465
+ throw new Error(`Dependency cycle detected: ${cycle.join(" -> ")}`);
1466
+ }
1467
+ color.set(id, 1);
1468
+ stack.push(id);
1469
+ for (const dependency of graph.get(id) ?? []) visit(dependency);
1470
+ stack.pop();
1471
+ color.set(id, 2);
1472
+ };
1473
+ for (const id of graph.keys()) visit(id);
1474
+
1475
+ target.depends = [...depends];
1476
+ if (target.status === "pending" || target.status === "blocked") {
1477
+ target.status = depends.every(
1478
+ (dependency) => tasks.find((candidate) => candidate.id === dependency)?.status === "done",
1479
+ ) ? "pending" : "blocked";
1480
+ }
1481
+ store.saveTask(this.squadId, target);
1482
+ store.appendMessage(this.squadId, taskId, {
1483
+ ts: store.now(),
1484
+ from: "system",
1485
+ type: "status",
1486
+ text: `Dependencies updated: ${depends.length > 0 ? depends.join(", ") : "none"}`,
1487
+ });
1488
+
1489
+ this.killBlockedAgents();
1490
+ const squad = store.loadSquad(this.squadId);
1491
+ const runnable = target.status === "pending" && target.depends.every(
1492
+ (dependency) => tasks.find((candidate) => candidate.id === dependency)?.status === "done",
1493
+ );
1494
+ const createdRunnableWork = runnable && !wasRunnable;
1495
+ if (createdRunnableWork && squad && squad.status !== "paused" && squad.status !== "running") {
1496
+ this.reopenSquadForWork();
1497
+ await this.start();
1498
+ } else if (squad?.status === "running") {
1499
+ await this.start();
1500
+ }
1501
+ this.updateContext();
1502
+ }
1503
+
1504
+ /** Resume one exact task. Reopening completed work invalidates descendants and
1505
+ * archives a completed active review before fresh scheduling begins. */
1385
1506
  async resumeTask(taskId: string): Promise<void> {
1386
- store.updateTaskStatus(this.squadId, taskId, "pending", { error: null });
1387
- await this.reconcile();
1507
+ const task = store.loadTask(this.squadId, taskId);
1508
+ if (!task) throw new Error(`Task not found: ${taskId}`);
1509
+ if (task.status === "done") await this.invalidateDescendants(taskId);
1510
+ const tasks = store.loadAllTasks(this.squadId);
1511
+ const dependenciesDone = task.depends.every(
1512
+ (depId) => tasks.find((candidate) => candidate.id === depId)?.status === "done",
1513
+ );
1514
+ store.updateTaskStatus(this.squadId, taskId, dependenciesDone ? "pending" : "blocked", {
1515
+ error: null,
1516
+ completed: null,
1517
+ });
1518
+ this.reopenSquadForWork();
1519
+ await this.start();
1520
+ this.updateContext();
1388
1521
  }
1389
1522
 
1390
1523
  /**
@@ -1397,6 +1530,7 @@ export class Scheduler {
1397
1530
  const task = store.loadTask(this.squadId, taskId);
1398
1531
  if (!task) throw new Error(`Task not found: ${taskId}`);
1399
1532
  if (task.status === "done") return;
1533
+ if (task.status === "cancelled") throw new Error(`Task '${taskId}' is cancelled; resume it before marking it done.`);
1400
1534
 
1401
1535
  if (this.pool.isRunning(taskId)) {
1402
1536
  await this.pool.kill(taskId);
@@ -1430,14 +1564,45 @@ export class Scheduler {
1430
1564
  this.updateContext();
1431
1565
  }
1432
1566
 
1433
- /** Cancel a task */
1567
+ /** Cancel one task after ensuring no live historical task still depends on it. */
1434
1568
  async cancelTask(taskId: string): Promise<void> {
1435
- if (this.pool.isRunning(taskId)) {
1436
- await this.pool.kill(taskId);
1569
+ const task = store.loadTask(this.squadId, taskId);
1570
+ if (!task) throw new Error(`Task not found: ${taskId}`);
1571
+ if (task.status === "cancelled") return;
1572
+
1573
+ const dependents = store.loadAllTasks(this.squadId)
1574
+ .filter((candidate) => candidate.status !== "cancelled" && candidate.depends.includes(taskId))
1575
+ .sort((left, right) => left.id.localeCompare(right.id));
1576
+ if (dependents.length > 0) {
1577
+ throw new Error([
1578
+ `Cannot cancel task '${taskId}': it is still required by:`,
1579
+ ...dependents.map((dependent) => `- ${dependent.id} [${dependent.status}]`),
1580
+ "",
1581
+ 'Update each dependent first with squad_modify action "set_dependencies",',
1582
+ "then retry cancel_task. Cancellation does not rewrite or cascade dependencies.",
1583
+ ].join("\n"));
1437
1584
  }
1438
- store.updateTaskStatus(this.squadId, taskId, "failed", {
1439
- error: "Cancelled by user",
1585
+
1586
+ if (this.pool.isRunning(taskId)) await this.pool.kill(taskId);
1587
+ store.updateTaskStatus(this.squadId, taskId, "cancelled", {
1588
+ error: null,
1589
+ completed: store.now(),
1440
1590
  });
1591
+ store.appendMessage(this.squadId, taskId, {
1592
+ ts: store.now(),
1593
+ from: "system",
1594
+ type: "status",
1595
+ text: "Task cancelled by orchestrator",
1596
+ });
1597
+
1598
+ const squad = store.loadSquad(this.squadId);
1599
+ if (squad) {
1600
+ if (squad.status === "done" || (squad.status === "review" && squad.review?.status !== "pending")) {
1601
+ beginOrchestratorRework(squad);
1602
+ store.saveSquad(squad);
1603
+ }
1604
+ this.checkSquadCompletion(store.loadAllTasks(this.squadId), squad);
1605
+ }
1441
1606
  this.updateContext();
1442
1607
  }
1443
1608
 
@@ -142,14 +142,15 @@ Send small, scoped corrections instead of restarting the task:
142
142
  - Name exactly what to change and what to keep: "Change only the header component — keep the layout and routes as they are."
143
143
  - Add missing information the moment you learn it — don't wait for the agent to get stuck
144
144
  - If the user manually edited or reverted files the agent touched, tell the agent immediately so it doesn't overwrite the human's changes
145
- - If an agent starts doing work that's no longer needed, narrow its scope via `squad_message` or stop it with `squad_modify` `cancel_task` don't let it burn tokens on obsolete work
145
+ - If an agent starts doing work that's no longer needed, narrow its scope via `squad_message`. Before using `squad_modify` `cancel_task`, repair every direct dependent explicitly as described below.
146
146
 
147
147
  ## Modifying a Running Squad
148
148
 
149
149
  Use `squad_modify` when:
150
150
  - **`add_task`**: User requests something not in the original plan
151
- - **`cancel_task`**: A task is no longer needed
152
- - **`pause_task`** / **`resume_task`**: Temporarily halt an agent
151
+ - **`set_dependencies`**: Replace a task's dependency list with top-level `taskId` and `depends`, e.g. `{ action: "set_dependencies", taskId: "publish", depends: ["build"] }`. This is allowed only for tasks that are not running or done; the complete replacement is validated atomically for unknown IDs, self-dependencies, duplicates, and cycles.
152
+ - **`cancel_task`**: A task is no longer needed. Cancellation is refused while any non-cancelled task directly depends on it. First call `set_dependencies` for every dependent named in the refusal, then retry `cancel_task`. Cancellation never cascades and never removes or rewrites dependencies automatically.
153
+ - **`pause_task`** / **`resume_task`**: Temporarily halt or explicitly revive an agent; `resume_task` is also the only action that revives a cancelled task.
153
154
  - **`pause`** / **`resume`**: Stop/restart the entire squad
154
155
  - **`cancel`**: Abort everything (user changed their mind)
155
156
 
package/src/store.ts CHANGED
@@ -356,8 +356,15 @@ export function findLatestSquad(projectCwd?: string): Squad | null {
356
356
  // Tasks
357
357
  // ============================================================================
358
358
 
359
+ function normalizeLegacyCancelledTask(task: Task | null): Task | null {
360
+ if (task?.status === "failed" && task.error === "Cancelled by user") {
361
+ return { ...task, status: "cancelled", error: null };
362
+ }
363
+ return task;
364
+ }
365
+
359
366
  export function loadTask(squadId: string, taskId: string, parentPath?: string): Task | null {
360
- return readJson<Task>(getTaskFilePath(squadId, taskId, parentPath));
367
+ return normalizeLegacyCancelledTask(readJson<Task>(getTaskFilePath(squadId, taskId, parentPath)));
361
368
  }
362
369
 
363
370
  export function saveTask(squadId: string, task: Task, parentPath?: string): void {
@@ -377,7 +384,7 @@ export function loadAllTasks(squadId: string): Task[] {
377
384
  if (!entry.isDirectory()) continue;
378
385
  if (entry.name === "knowledge") continue;
379
386
  const taskFile = path.join(squadDir, entry.name, "task.json");
380
- const task = readJson<Task>(taskFile);
387
+ const task = normalizeLegacyCancelledTask(readJson<Task>(taskFile));
381
388
  if (task && !seen.has(task.id)) {
382
389
  seen.add(task.id);
383
390
  tasks.push(task);
@@ -401,7 +408,7 @@ function collectSubtasks(squadDir: string, parentPath: string, tasks: Task[], se
401
408
  for (const entry of entries) {
402
409
  if (!entry.isDirectory()) continue;
403
410
  const taskFile = path.join(parentDir, entry.name, "task.json");
404
- const task = readJson<Task>(taskFile);
411
+ const task = normalizeLegacyCancelledTask(readJson<Task>(taskFile));
405
412
  if (task && !seen.has(task.id)) {
406
413
  seen.add(task.id);
407
414
  tasks.push(task);
package/src/types.ts CHANGED
@@ -112,15 +112,17 @@ export interface Squad {
112
112
  /** Agent name → overrides. Keys must exist in .pi/squad/agents/ */
113
113
  agents: Record<string, SquadAgentEntry>;
114
114
  config: SquadConfig;
115
- /** Mandatory independent main-session review; absent on legacy/running squads. */
115
+ /** Mandatory independent main-session review; absent while rework is running. */
116
116
  review?: SquadReview;
117
+ /** Completed prior review attempts retained as same-squad audit evidence. */
118
+ reviewHistory?: SquadReview[];
117
119
  }
118
120
 
119
121
  // ============================================================================
120
122
  // Tasks
121
123
  // ============================================================================
122
124
 
123
- export type TaskStatus = "pending" | "blocked" | "in_progress" | "done" | "failed" | "suspended";
125
+ export type TaskStatus = "pending" | "blocked" | "in_progress" | "done" | "failed" | "suspended" | "cancelled";
124
126
 
125
127
  export interface TaskUsage {
126
128
  inputTokens: number;