pi-squad 0.16.6 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
6
6
  import { truncateToWidth } from "@earendil-works/pi-tui";
7
7
  import type { Task, TaskStatus } from "../types.js";
8
8
  import type { Scheduler } from "../scheduler.js";
9
+ import { formatSuspendedAttention, getReviewPresentation } from "../presentation.js";
9
10
  import * as store from "../store.js";
10
11
 
11
12
  // ============================================================================
@@ -90,6 +91,15 @@ export class TaskListView {
90
91
  bottomLines.push("");
91
92
  bottomLines.push(truncateToWidth(th.fg("border", " " + "─".repeat(width - 2)), width, ""));
92
93
 
94
+ const squad = store.loadSquad(this.squadId);
95
+ const review = squad ? getReviewPresentation(squad) : null;
96
+ if (review) bottomLines.push(truncateToWidth(` ${th.fg(review.tone, review.label)}`, width, ""));
97
+ if (squad) {
98
+ for (const line of formatSuspendedAttention(squad)) {
99
+ bottomLines.push(truncateToWidth(` ${th.fg("warning", line)}`, width, ""));
100
+ }
101
+ }
102
+
93
103
  const runningTask = tasks.find((t) => t.status === "in_progress");
94
104
  if (runningTask) {
95
105
  bottomLines.push(...this.renderLiveActivity(runningTask, width, scheduler));
@@ -0,0 +1,38 @@
1
+ import type { Squad, SuspendedStallAttention } from "./types.js";
2
+
3
+ export const REVIEW_PENDING_LABEL = "◆ REVIEW PENDING · independent review required";
4
+ export const REVIEW_FAILED_LABEL = "✗ REVIEW FAILED · awaiting same-squad rework";
5
+ export const SUSPENDED_ATTENTION_LABEL = "⚠ SUSPENDED — explicit resume required";
6
+
7
+ export interface ReviewPresentation {
8
+ kind: "pending" | "failed";
9
+ icon: "◆" | "✗";
10
+ label: string;
11
+ tone: "warning" | "error";
12
+ }
13
+
14
+ /** Keep execution progress separate from the independent acceptance gate. */
15
+ export function getReviewPresentation(squad: Squad): ReviewPresentation | null {
16
+ if (squad.status !== "review") return null;
17
+ if (squad.review?.status === "failed") {
18
+ return { kind: "failed", icon: "✗", label: REVIEW_FAILED_LABEL, tone: "error" };
19
+ }
20
+ return { kind: "pending", icon: "◆", label: REVIEW_PENDING_LABEL, tone: "warning" };
21
+ }
22
+
23
+ export function getSuspendedAttention(squad: Squad): SuspendedStallAttention | null {
24
+ return squad.suspendedStallAttention?.kind === "suspended_stall" ? squad.suspendedStallAttention : null;
25
+ }
26
+
27
+ /** Full-fidelity operator output. Terminal components may clip only while rendering. */
28
+ export function formatSuspendedAttention(squad: Squad): string[] {
29
+ const attention = getSuspendedAttention(squad);
30
+ if (!attention) return [];
31
+ return [
32
+ `Attention: ${SUSPENDED_ATTENTION_LABEL}`,
33
+ `Suspended task IDs: ${attention.suspendedTaskIds.join(", ")}`,
34
+ `Blocked by suspended work: ${attention.blockedTaskIds.length > 0 ? attention.blockedTaskIds.join(", ") : "none"}`,
35
+ `No task was resumed automatically.`,
36
+ `Resume intentionally with squad_modify { action: "resume_task", squadId: "${squad.id}", taskId: "<exact-task-id>" } for each task you choose.`,
37
+ ];
38
+ }
package/src/protocol.ts CHANGED
@@ -311,6 +311,27 @@ export function buildAgentSystemPrompt(options: ProtocolBuildOptions): string {
311
311
  const { squadId, squad, task, agentDef, modifiedFiles, queuedMessages } = options;
312
312
  const allTasks = loadAllTasks(squadId);
313
313
 
314
+ if (squad.spec) {
315
+ const manifest = [
316
+ "# File-spec squad bootstrap",
317
+ `Squad ID: ${squad.id}`,
318
+ `Task ID: ${task.id}`,
319
+ `Spec SHA-256: ${squad.spec.sha256}`,
320
+ `Spec bytes: ${squad.spec.bytes}`,
321
+ `Chunk count: ${squad.spec.chunkCount}`,
322
+ "Read every canonical chunk with squad_spec_read before using normal tools or completing the task.",
323
+ ].join("\n");
324
+ const fileSections = [
325
+ manifest,
326
+ buildAgentIdentity(agentDef),
327
+ ...(task.fileSpecDelta ? [buildTaskSection(task), buildReworkContext(task, squadId)] : []),
328
+ buildChainContext(task, allTasks, squadId),
329
+ buildKnowledgeSection(squadId),
330
+ buildQueuedMessages(queuedMessages),
331
+ ].filter((section) => section.length > 0);
332
+ return fileSections.join("\n---\n\n");
333
+ }
334
+
314
335
  const sections = [
315
336
  buildSquadProtocol(task.agent, agentDef, squad),
316
337
  buildAgentIdentity(agentDef),
package/src/review.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import * as path from "node:path";
1
2
  import type { Squad, Task } from "./types.js";
2
3
 
3
4
  export type OrchestratorReviewVerdict = "pass" | "pass_with_issues" | "fail";
@@ -92,15 +93,27 @@ export function recordOrchestratorReview(squad: Squad, input: OrchestratorReview
92
93
 
93
94
  /** Persistent system-prompt contract shown until squad_review accepts the work. */
94
95
  export function buildOrchestratorReviewGate(squad: Squad, tasks: Task[]): string {
95
- const delegatedPlan = tasks
96
- .map((task) => `- ${task.id} (${task.agent}): ${task.title}\n ${task.description || "(no description)"}`)
97
- .join("\n");
96
+ const failed = squad.review?.status === "failed";
97
+ const reviewLabel = failed
98
+ ? "✗ REVIEW FAILED · awaiting same-squad rework"
99
+ : "◆ REVIEW PENDING · independent review required";
100
+ const reviewAction = failed
101
+ ? `This candidate was rejected. Do not submit another verdict yet. Start concrete rework in this same exact squad with squad_modify and squadId: "${squad.id}"; the failed evidence remains immutable history. After rework settles, a fresh REVIEW PENDING gate will require a new independent review.`
102
+ : `This candidate is awaiting its first verdict. Complete the checks below, then call squad_review for squadId: "${squad.id}".`;
103
+ const goalReference = squad.spec
104
+ ? `Canonical file spec at ${squad.spec.path} (sha256=${squad.spec.sha256}, bytes=${squad.spec.bytes}). Read and hash the exact file during review; its contract is intentionally not duplicated into this prompt.`
105
+ : squad.goal;
106
+ const delegatedPlan = squad.spec
107
+ ? tasks.map((task) => `- ${task.id} (${task.agent}) [${task.status}] — task state: ${path.join(path.dirname(path.dirname(squad.spec!.path)), task.id, "task.json")}`).join("\n")
108
+ : tasks.map((task) => `- ${task.id} (${task.agent}): ${task.title}\n ${task.description || "(no description)"}`).join("\n");
98
109
 
99
110
  return `<squad_review_required>
100
111
  UNTRUSTED CANDIDATE WORK — INDEPENDENT ORCHESTRATOR REVIEW IS MANDATORY.
101
112
 
102
113
  Authoritative contract: re-read the user's ORIGINAL request and all later clarifications in this main-session conversation. The squad report and delegated task descriptions are claims, not proof and not substitutes for that contract.
103
- Recorded squad goal (secondary reference): ${squad.goal}
114
+ Recorded squad goal (secondary reference): ${goalReference}
115
+ Acceptance: ${reviewLabel}
116
+ ${reviewAction}
104
117
 
105
118
  Delegated plan (non-authoritative):
106
119
  ${delegatedPlan}
@@ -111,9 +124,9 @@ Before reporting success, completion, or acceptance to the user, YOU (the main P
111
124
  3. Independently run the original Verify commands and appropriate build/tests. Do not rely on pasted squad output.
112
125
  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.
113
126
  5. Fix discovered defects and repeat checks. Squad QA PASS does not override your findings.
114
- 6. Call squad_review with contract checks, diff review, actual command/result evidence, integration/E2E evidence, and remaining issues.
127
+ 6. When the active gate is REVIEW PENDING, call squad_review with contract checks, diff review, actual command/result evidence, integration/E2E evidence, and remaining issues. When it is REVIEW FAILED, route same-squad rework first; another verdict cannot overwrite the failed evidence.
115
128
 
116
- 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.
129
+ Do not ask the user whether you should verify. Do not merely summarize the squad report. Until fresh rework is independently reviewed and squad_review records PASS/PASS_WITH_ISSUES, the work is not accepted.
117
130
  </squad_review_required>`;
118
131
  }
119
132
 
package/src/scheduler.ts CHANGED
@@ -11,11 +11,12 @@
11
11
 
12
12
  import * as fs from "node:fs";
13
13
  import * as path from "node:path";
14
- import type { AgentDef, Squad, SquadConfig, Task, TaskMailboxEntry, TaskMessage, TaskStatus } from "./types.js";
14
+ import type { AgentDef, Squad, SquadConfig, SuspendedStallAttention, Task, TaskMailboxEntry, TaskMessage, TaskStatus } from "./types.js";
15
15
  import { AgentPool, type AgentEvent } from "./agent-pool.js";
16
16
  import { Monitor } from "./monitor.js";
17
17
  import { Router } from "./router.js";
18
18
  import * as store from "./store.js";
19
+ import { isFileSpecTaskId, validateCanonicalSpec, validateTaskSpecAttestation } from "./file-spec.js";
19
20
  import { debug, logError } from "./logger.js";
20
21
  import { buildAgentSystemPrompt } from "./protocol.js";
21
22
  import { buildAdvisorConsultText, formatAdvisorSteerMessage, adviceNeedsHuman, type AdvisorConsultInput } from "./advisor.js";
@@ -34,6 +35,7 @@ export type SchedulerEventType =
34
35
  | "task_rework"
35
36
  | "squad_review_required"
36
37
  | "squad_failed"
38
+ | "suspended_stall"
37
39
  | "orchestrator_reply"
38
40
  | "escalation"
39
41
  | "activity";
@@ -49,6 +51,61 @@ export interface SchedulerEvent {
49
51
 
50
52
  export type SchedulerEventListener = (event: SchedulerEvent) => void;
51
53
 
54
+ export interface SuspendedStallState {
55
+ fingerprint: string;
56
+ suspendedTaskIds: string[];
57
+ blockedTaskIds: string[];
58
+ }
59
+
60
+ /** Pure derivation of an explicit-suspension stall from one persisted DAG. */
61
+ export function deriveSuspendedStall(tasks: Task[]): SuspendedStallState | null {
62
+ const relevant = tasks.filter((task) => task.status !== "cancelled");
63
+ const byId = new Map(relevant.map((task) => [task.id, task]));
64
+ const suspendedTaskIds = relevant
65
+ .filter((task) => task.status === "suspended")
66
+ .map((task) => task.id)
67
+ .sort((left, right) => left.localeCompare(right));
68
+ if (suspendedTaskIds.length === 0) return null;
69
+ const suspended = new Set(suspendedTaskIds);
70
+
71
+ const reachesSuspended = (taskId: string, seen = new Set<string>()): boolean => {
72
+ if (suspended.has(taskId)) return true;
73
+ if (seen.has(taskId)) return false;
74
+ seen.add(taskId);
75
+ const task = byId.get(taskId);
76
+ if (!task) return false;
77
+ return task.depends.some((dependencyId) => reachesSuspended(dependencyId, seen));
78
+ };
79
+ const suspensionBlocked = (task: Task): boolean => task.status === "blocked" && task.depends.some((dependencyId) => {
80
+ const dependency = byId.get(dependencyId);
81
+ return dependency?.status !== "done" && reachesSuspended(dependencyId);
82
+ });
83
+ const runnableOrLive = relevant.some((task) => task.status === "in_progress" || (
84
+ task.status === "pending" && task.depends.every((dependencyId) => byId.get(dependencyId)?.status === "done")
85
+ ));
86
+ if (runnableOrLive) return null;
87
+
88
+ const blockedTaskIds = relevant.filter(suspensionBlocked).map((task) => task.id).sort((left, right) => left.localeCompare(right));
89
+ const blocked = new Set(blockedTaskIds);
90
+ const terminal = new Set<TaskStatus>(["done", "failed", "cancelled"]);
91
+ if (!relevant.every((task) => suspended.has(task.id) || terminal.has(task.status) || blocked.has(task.id))) return null;
92
+
93
+ return {
94
+ fingerprint: JSON.stringify([suspendedTaskIds, blockedTaskIds]),
95
+ suspendedTaskIds,
96
+ blockedTaskIds,
97
+ };
98
+ }
99
+
100
+ /** Complete, actionable wake text; semantic task IDs are never abbreviated. */
101
+ export function formatSuspendedStallAttention(squadId: string, attention: Pick<SuspendedStallAttention, "suspendedTaskIds" | "blockedTaskIds">): string {
102
+ return `[squad] SUSPENDED WORK NEEDS ACTION in '${squadId}'.\n` +
103
+ `Suspended task IDs: ${attention.suspendedTaskIds.join(", ")}\n` +
104
+ `Blocked by suspended work: ${attention.blockedTaskIds.length > 0 ? attention.blockedTaskIds.join(", ") : "none"}\n` +
105
+ "No task was resumed automatically.\n" +
106
+ `Resume intentionally with squad_modify { action: "resume_task", squadId: "${squadId}", taskId: "<exact-task-id>" } for each task you choose.`;
107
+ }
108
+
52
109
  /** Host-session capabilities passed in by the extension (index.ts) */
53
110
  export interface SchedulerSpawnContext {
54
111
  /** Resolve a model string (or null = default model) to its context window in tokens */
@@ -76,6 +133,8 @@ export class Scheduler {
76
133
  private spawnRetries = new Set<string>();
77
134
  /** Periodic level-triggered reconcile (heals missed events / out-of-band store edits) */
78
135
  private reconcileTimer: ReturnType<typeof setInterval> | null = null;
136
+ /** Suppress duplicate edge emission within one scheduler; disk remains the outbox. */
137
+ private attentionEmittedFingerprint: string | null = null;
79
138
 
80
139
  /** Get the project cwd for this squad (from squad.json) */
81
140
  getProjectCwd(): string | undefined {
@@ -153,6 +212,29 @@ export class Scheduler {
153
212
  }
154
213
  }
155
214
 
215
+ /** Revalidate durable file-spec completion evidence after parent restart/review recovery. */
216
+ async auditSpecAttestations(): Promise<string[]> {
217
+ const squad = store.loadSquad(this.squadId); if (!squad?.spec) return [];
218
+ const tasks = store.loadAllTasks(this.squadId);
219
+ if (!validateCanonicalSpec(squad)) {
220
+ const quarantined = tasks.filter((task) => task.status !== "cancelled");
221
+ for (const task of quarantined) {
222
+ if (task.status === "done") await this.invalidateDescendants(task.id);
223
+ if (task.status === "in_progress" || task.status === "done") store.updateTaskStatus(this.squadId, task.id, "pending", { completed: null, output: null, error: "Canonical spec integrity failure" });
224
+ else store.updateTaskStatus(this.squadId, task.id, task.status, { error: "Canonical spec integrity failure" });
225
+ }
226
+ return quarantined.map((task) => task.id);
227
+ }
228
+ const invalid = tasks.filter((task) => task.status === "done" && !validateTaskSpecAttestation(squad, task));
229
+ for (const task of invalid) {
230
+ store.updateTaskStatus(this.squadId, task.id, "pending", { completed: null, output: null, error: "Missing or invalid canonical spec read attestation" });
231
+ store.appendMessage(this.squadId, task.id, { ts: store.now(), from: "system", type: "status", text: "Completion invalidated after attestation audit" });
232
+ await this.invalidateDescendants(task.id);
233
+ }
234
+ if (invalid.length > 0 && squad.status === "review") { squad.status = "running"; delete squad.review; store.saveSquad(squad); }
235
+ return invalid.map((task) => task.id);
236
+ }
237
+
156
238
  /** Get references for external use */
157
239
  getPool(): AgentPool {
158
240
  return this.pool;
@@ -250,6 +332,12 @@ export class Scheduler {
250
332
  if (!this.running) return;
251
333
  const squad = store.loadSquad(this.squadId);
252
334
  if (!squad) return;
335
+ // Canonical integrity is a scheduling precondition, not merely a completion check.
336
+ if (squad.spec && !validateCanonicalSpec(squad)) {
337
+ await this.auditSpecAttestations();
338
+ debug("squad-scheduler", "reconcile: canonical spec integrity failure — scheduling quarantined");
339
+ return;
340
+ }
253
341
 
254
342
  const tasks = store.loadAllTasks(this.squadId);
255
343
 
@@ -258,7 +346,7 @@ export class Scheduler {
258
346
  // mutation/RPC delivery, reopen exactly that task on reconstruction.
259
347
  let recoveredMailbox = false;
260
348
  for (const task of tasks) {
261
- if (task.status === "cancelled") continue;
349
+ if (task.status === "cancelled" || task.status === "suspended") continue;
262
350
  if (this.pool.isRunning(task.id)) continue;
263
351
  if (store.loadPendingTaskMessages(this.squadId, task.id).length === 0) continue;
264
352
  if (task.status === "done") await this.invalidateDescendants(task.id);
@@ -309,6 +397,7 @@ export class Scheduler {
309
397
  }
310
398
 
311
399
  await this.scheduleReadyTasks();
400
+ this.reconcileSuspendedStallAttention();
312
401
 
313
402
  const freshSquad = store.loadSquad(this.squadId);
314
403
  if (freshSquad && (freshSquad.status === "running" || freshSquad.status === "failed")) {
@@ -328,8 +417,8 @@ export class Scheduler {
328
417
  }
329
418
 
330
419
  const squad = store.loadSquad(this.squadId);
331
- if (!squad || squad.status !== "running") {
332
- debug("squad-scheduler", `scheduleReadyTasks: squad status=${squad?.status}, skipping`);
420
+ if (!squad || squad.status !== "running" || (squad.spec && !validateCanonicalSpec(squad))) {
421
+ debug("squad-scheduler", `scheduleReadyTasks: squad unavailable, inactive, or canonical integrity invalid; status=${squad?.status}`);
333
422
  return;
334
423
  }
335
424
 
@@ -477,6 +566,7 @@ export class Scheduler {
477
566
  },
478
567
  cwd: squad.cwd,
479
568
  skillPaths: this.skillPaths,
569
+ ...(squad.spec ? { spec: { squadId: squad.id, path: squad.spec.path, sha256: squad.spec.sha256, bytes: squad.spec.bytes, chunkBytes: squad.spec.chunkBytes } } : {}),
480
570
  ...(resumeSession
481
571
  ? { resumeSession }
482
572
  : forkSessionFile
@@ -625,16 +715,25 @@ export class Scheduler {
625
715
  entries: TaskMailboxEntry[],
626
716
  legacySeed?: { messages: TaskMessage[]; output: string | null },
627
717
  ): string {
628
- const lines = resumed
718
+ const fileSpec = store.loadSquad(this.squadId)?.spec;
719
+ const lines = fileSpec
629
720
  ? [
630
- `Resume your existing task: ${task.title}`,
631
- "Continue from the durable Pi session context. Do not restart the task from scratch.",
721
+ `${resumed ? "Resume" : "Start"} file-spec squad task ${task.id}.`,
722
+ `Canonical spec: sha256=${fileSpec.sha256} bytes=${fileSpec.bytes} chunks=${fileSpec.chunkCount}.`,
723
+ "Use squad_spec_read to receive every canonical chunk before normal tools or completion.",
724
+ ...(task.fileSpecDelta ? ["", `Dynamic task delta: ${task.title}`, task.description] : []),
725
+ ...(resumed ? ["Continue from this task's durable Pi session and existing read coverage."] : []),
632
726
  ]
633
- : [
634
- `Your task: ${task.title}`,
635
- "",
636
- task.description || "",
637
- ];
727
+ : resumed
728
+ ? [
729
+ `Resume your existing task: ${task.title}`,
730
+ "Continue from the durable Pi session context. Do not restart the task from scratch.",
731
+ ]
732
+ : [
733
+ `Your task: ${task.title}`,
734
+ "",
735
+ task.description || "",
736
+ ];
638
737
 
639
738
  if (legacySeed) {
640
739
  const pendingIds = new Set(entries.map((entry) => entry.id));
@@ -664,7 +763,8 @@ export class Scheduler {
664
763
  }
665
764
 
666
765
  private handleUnexpectedAgentExit(event: AgentEvent): void {
667
- if (store.loadTask(this.squadId, event.taskId)?.status === "cancelled") return;
766
+ const status = store.loadTask(this.squadId, event.taskId)?.status;
767
+ if (status === "cancelled" || status === "suspended") return;
668
768
  const exitCode = event.data?.exitCode ?? 1;
669
769
  const turnCount = event.data?.turnCount ?? 0;
670
770
  const stderr = event.data?.stderr || "";
@@ -794,7 +894,17 @@ export class Scheduler {
794
894
  }
795
895
 
796
896
  case "agent_settled": {
797
- if (store.loadTask(this.squadId, event.taskId)?.status === "cancelled") return;
897
+ const settledTask = store.loadTask(this.squadId, event.taskId);
898
+ const status = settledTask?.status;
899
+ if (status === "cancelled" || status === "suspended") return;
900
+ const settledSquad = store.loadSquad(this.squadId);
901
+ if (settledTask && settledSquad && !validateTaskSpecAttestation(settledSquad, settledTask)) {
902
+ store.updateTaskStatus(this.squadId, event.taskId, "pending", { completed: null, output: null, error: "Canonical squad spec was not fully delivered; read all chunks with squad_spec_read" });
903
+ store.appendMessage(this.squadId, event.taskId, { ts: store.now(), from: "system", type: "status", text: "Completion rejected: missing or invalid spec-read-attestation; reopening same task session" });
904
+ if (this.running) void this.reconcile();
905
+ this.updateContext();
906
+ return;
907
+ }
798
908
  // A mailbox entry not acknowledged by Pi outranks this run's candidate
799
909
  // completion. Reopen the same session so accepted-at-least-once delivery
800
910
  // occurs before the task can become done.
@@ -846,12 +956,18 @@ export class Scheduler {
846
956
  const task = store.loadTask(this.squadId, taskId);
847
957
  if (!task) return;
848
958
 
849
- // Guard against double-completion and late callbacks after cancellation.
850
- if (task.status === "done" || task.status === "cancelled") return;
959
+ // Guard against double-completion and late callbacks after cancellation or
960
+ // an explicit pause. Only exact resume_task may revive suspended work.
961
+ if (task.status === "done" || task.status === "cancelled" || task.status === "suspended") return;
851
962
 
852
963
  // Extract output from last messages
853
964
  const messages = store.loadMessages(this.squadId, taskId);
965
+ const squad = store.loadSquad(this.squadId);
966
+ const rejectedAt = squad?.spec
967
+ ? messages.reduce((last, message, index) => message.from === "system" && message.type === "status" && message.text.startsWith("Completion rejected: missing or invalid spec-read-attestation") ? index : last, -1)
968
+ : -1;
854
969
  const agentMessages = messages
970
+ .slice(rejectedAt + 1)
855
971
  .filter((m) => m.from === task.agent && (m.type === "text" || m.type === "done"));
856
972
  const output = agentMessages.map((m) => m.text).join("\n");
857
973
 
@@ -905,6 +1021,11 @@ export class Scheduler {
905
1021
  debug("squad-scheduler", `handleTaskCompleted: scheduling next ready tasks`);
906
1022
  await this.scheduleReadyTasks();
907
1023
 
1024
+ // A completion can remove the last independent runnable task and expose a
1025
+ // stall behind an explicitly suspended task. Derive/wake immediately rather
1026
+ // than waiting for the periodic reconciliation timer.
1027
+ this.reconcileSuspendedStallAttention();
1028
+
908
1029
  // Re-check squad completion with fresh data AFTER scheduling
909
1030
  const freshTasks = store.loadAllTasks(this.squadId);
910
1031
  const freshSquad = store.loadSquad(this.squadId);
@@ -938,6 +1059,9 @@ export class Scheduler {
938
1059
  this.pool.kill(taskId);
939
1060
  this.updateContext();
940
1061
 
1062
+ // Failure can likewise expose a suspended-only cut in the remaining DAG.
1063
+ this.reconcileSuspendedStallAttention();
1064
+
941
1065
  // Check if squad should be marked failed
942
1066
  const tasks = store.loadAllTasks(this.squadId);
943
1067
  const squad = store.loadSquad(this.squadId);
@@ -1047,6 +1171,11 @@ export class Scheduler {
1047
1171
 
1048
1172
  // Create rework task for the original agent
1049
1173
  const reworkId = `${originalId}-fix-${fixN}`;
1174
+ const retestId = `${task.id}-retest-${fixN}`;
1175
+ if (squad.spec && (!isFileSpecTaskId(reworkId) || !isFileSpecTaskId(retestId))) {
1176
+ this.handleTaskFailed(task.id, `Generated file-spec rework IDs exceed the safe task-ID contract: ${reworkId}, ${retestId}`);
1177
+ return true;
1178
+ }
1050
1179
  const reworkTask: Task = {
1051
1180
  id: reworkId,
1052
1181
  title: `Fix: ${implTask.title} (attempt ${fixN})`,
@@ -1055,6 +1184,7 @@ export class Scheduler {
1055
1184
  status: "pending",
1056
1185
  depends: [],
1057
1186
  ...(implTask.inheritContext ? { inheritContext: true } : {}),
1187
+ ...(squad.spec ? { fileSpecDelta: true } : {}),
1058
1188
  created: store.now(),
1059
1189
  started: null,
1060
1190
  completed: null,
@@ -1068,7 +1198,6 @@ export class Scheduler {
1068
1198
  store.createTask(this.squadId, reworkTask);
1069
1199
 
1070
1200
  // Create retest task for QA
1071
- const retestId = `${task.id}-retest-${fixN}`;
1072
1201
  const retestTask: Task = {
1073
1202
  id: retestId,
1074
1203
  title: `Re-test: ${implTask.title} (after fix ${fixN})`,
@@ -1076,6 +1205,7 @@ export class Scheduler {
1076
1205
  agent: task.agent,
1077
1206
  status: "blocked",
1078
1207
  depends: [reworkId],
1208
+ ...(squad.spec ? { fileSpecDelta: true } : {}),
1079
1209
  created: store.now(),
1080
1210
  started: null,
1081
1211
  completed: null,
@@ -1157,6 +1287,18 @@ export class Scheduler {
1157
1287
  private checkSquadCompletion(tasks: Task[], squad: Squad): void {
1158
1288
  if (tasks.length === 0) return;
1159
1289
 
1290
+ const invalidDone = tasks.filter((task) => task.status === "done" && !validateTaskSpecAttestation(squad, task));
1291
+ if (invalidDone.length > 0) {
1292
+ for (const task of invalidDone) {
1293
+ store.updateTaskStatus(this.squadId, task.id, "pending", { completed: null, output: null, error: "Missing or invalid canonical spec read attestation" });
1294
+ store.appendMessage(this.squadId, task.id, { ts: store.now(), from: "system", type: "status", text: "Completion invalidated: canonical spec attestation is missing or invalid" });
1295
+ void this.invalidateDescendants(task.id);
1296
+ }
1297
+ if (squad.status === "review") { squad.status = "running"; delete squad.review; store.saveSquad(squad); }
1298
+ if (this.running) void this.reconcile();
1299
+ return;
1300
+ }
1301
+
1160
1302
  const relevant = tasks.filter((task) => task.status !== "cancelled");
1161
1303
  const allDone = relevant.every((task) => task.status === "done");
1162
1304
  const anyFailed = relevant.some((task) => task.status === "failed");
@@ -1409,6 +1551,7 @@ export class Scheduler {
1409
1551
  setTimeout(() => this.pool.kill(taskId), 3000);
1410
1552
  }
1411
1553
  store.updateTaskStatus(this.squadId, taskId, "suspended");
1554
+ this.reconcileSuspendedStallAttention();
1412
1555
  this.updateContext();
1413
1556
  }
1414
1557
 
@@ -1503,9 +1646,12 @@ export class Scheduler {
1503
1646
 
1504
1647
  /** Resume one exact task. Reopening completed work invalidates descendants and
1505
1648
  * archives a completed active review before fresh scheduling begins. */
1506
- async resumeTask(taskId: string): Promise<void> {
1649
+ async resumeTask(taskId: string): Promise<"resumed" | "already_running"> {
1507
1650
  const task = store.loadTask(this.squadId, taskId);
1508
1651
  if (!task) throw new Error(`Task not found: ${taskId}`);
1652
+ const live = this.pool.isRunning(taskId);
1653
+ if (task.status === "in_progress" && live) return "already_running";
1654
+ if (live) throw new Error(`Task '${taskId}' has a live child but durable status '${task.status}'; no duplicate resume was started.`);
1509
1655
  if (task.status === "done") await this.invalidateDescendants(taskId);
1510
1656
  const tasks = store.loadAllTasks(this.squadId);
1511
1657
  const dependenciesDone = task.depends.every(
@@ -1518,6 +1664,18 @@ export class Scheduler {
1518
1664
  this.reopenSquadForWork();
1519
1665
  await this.start();
1520
1666
  this.updateContext();
1667
+ return "resumed";
1668
+ }
1669
+
1670
+ /** Mark an exact pending outbox fingerprint delivered after host acceptance. */
1671
+ acknowledgeSuspendedStall(fingerprint: string): boolean {
1672
+ const squad = store.loadSquad(this.squadId);
1673
+ const attention = squad?.suspendedStallAttention;
1674
+ if (!squad || !attention || attention.fingerprint !== fingerprint || attention.delivery !== "pending") return false;
1675
+ attention.delivery = "delivered";
1676
+ attention.deliveredAt = store.now();
1677
+ store.saveSquad(squad);
1678
+ return true;
1521
1679
  }
1522
1680
 
1523
1681
  /**
@@ -1531,6 +1689,8 @@ export class Scheduler {
1531
1689
  if (!task) throw new Error(`Task not found: ${taskId}`);
1532
1690
  if (task.status === "done") return;
1533
1691
  if (task.status === "cancelled") throw new Error(`Task '${taskId}' is cancelled; resume it before marking it done.`);
1692
+ const squad = store.loadSquad(this.squadId);
1693
+ if (squad && !validateTaskSpecAttestation(squad, task)) throw new Error(`Task '${taskId}' cannot complete: missing or invalid canonical spec read attestation.`);
1534
1694
 
1535
1695
  if (this.pool.isRunning(taskId)) {
1536
1696
  await this.pool.kill(taskId);
@@ -1601,6 +1761,7 @@ export class Scheduler {
1601
1761
  beginOrchestratorRework(squad);
1602
1762
  store.saveSquad(squad);
1603
1763
  }
1764
+ this.reconcileSuspendedStallAttention();
1604
1765
  this.checkSquadCompletion(store.loadAllTasks(this.squadId), squad);
1605
1766
  }
1606
1767
  this.updateContext();
@@ -1610,6 +1771,38 @@ export class Scheduler {
1610
1771
  // Helpers
1611
1772
  // =========================================================================
1612
1773
 
1774
+ private reconcileSuspendedStallAttention(): void {
1775
+ const squad = store.loadSquad(this.squadId);
1776
+ if (!squad) return;
1777
+ const derived = deriveSuspendedStall(store.loadAllTasks(this.squadId));
1778
+ if (!derived) {
1779
+ if (squad.suspendedStallAttention) {
1780
+ delete squad.suspendedStallAttention;
1781
+ store.saveSquad(squad);
1782
+ }
1783
+ this.attentionEmittedFingerprint = null;
1784
+ return;
1785
+ }
1786
+
1787
+ let attention = squad.suspendedStallAttention;
1788
+ if (!attention || attention.fingerprint !== derived.fingerprint) {
1789
+ attention = {
1790
+ kind: "suspended_stall",
1791
+ ...derived,
1792
+ detectedAt: store.now(),
1793
+ delivery: "pending",
1794
+ deliveredAt: null,
1795
+ };
1796
+ squad.suspendedStallAttention = attention;
1797
+ store.saveSquad(squad);
1798
+ this.attentionEmittedFingerprint = null;
1799
+ }
1800
+ if (attention.delivery === "pending" && this.attentionEmittedFingerprint !== attention.fingerprint) {
1801
+ this.attentionEmittedFingerprint = attention.fingerprint;
1802
+ this.emit({ type: "suspended_stall", squadId: this.squadId, data: attention });
1803
+ }
1804
+ }
1805
+
1613
1806
  private extractAssistantText(msg: any): string | null {
1614
1807
  if (!msg.content) return null;
1615
1808
  const textParts = msg.content
@@ -120,6 +120,7 @@ When you receive `[squad] Agent needs attention`:
120
120
  4. **If you can't**: ask the user, then relay their answer via `squad_message`
121
121
 
122
122
  Common escalation patterns:
123
+ - **`SUSPENDED WORK NEEDS ACTION`** → Read every exact suspended task ID and blocked descendant. Do not assume consent and do not use whole-squad `resume`; resume only each intended task with `squad_modify { action: "resume_task", squadId: "<exact squad>", taskId: "<exact task>" }`. Nothing resumes automatically.
123
124
  - **"Which approach should I use?"** → Ask the user for preference, relay via `squad_message`
124
125
  - **"I need info from another agent"** → Check if that agent is done, relay their output
125
126
  - **"I'm blocked by a failing test"** → Check the error, suggest a fix via `squad_message`
@@ -152,12 +153,16 @@ Use `squad_modify` when:
152
153
  - **`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
154
  - **`pause_task`** / **`resume_task`**: Temporarily halt or explicitly revive an agent; `resume_task` is also the only action that revives a cancelled task.
154
155
  - **`pause`** / **`resume`**: Stop/restart the entire squad
155
- - **`cancel`**: Abort everything (user changed their mind)
156
+ - **`cancel`**: Abort everything (user changed their mind). This destructive tool action requires the exact `squadId`; never infer it from focus or recency. The result must name the affected squad. Interactive `/squad cancel` is the only cancellation shorthand that may use the visibly focused squad.
157
+
158
+ A suspended task is an explicit pause. Scheduler restart, reconciliation, dependency edits, and attention delivery must not be treated as permission to resume it. When durable suspended-stall attention is active, use `squad_status` once to see the complete IDs and exact-squad guidance, then resume only the tasks deliberately chosen.
156
159
 
157
160
  ## After Agents Finish — Mandatory Independent Orchestrator Review
158
161
 
159
162
  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.
160
163
 
164
+ Read acceptance labels literally: `◆ REVIEW PENDING · independent review required` means review has not happened; `✗ REVIEW FAILED · awaiting same-squad rework` means review happened and rejected the candidate. Task progress such as `3/3` is execution progress only. A failed gate requires concrete rework in that same exact squad before a fresh review can exist; never overwrite the failed verdict or create a separate squad to evade it.
165
+
161
166
  You MUST do all of this before telling the user the work succeeded:
162
167
  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.
163
168
  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.
package/src/store.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  import * as fs from "node:fs";
10
10
  import * as path from "node:path";
11
11
  import * as os from "node:os";
12
- import { randomUUID } from "node:crypto";
12
+ import { createHash, randomUUID } from "node:crypto";
13
13
  import type {
14
14
  AgentDef,
15
15
  KnowledgeEntry,
@@ -314,13 +314,50 @@ export function saveSquad(squad: Squad): void {
314
314
  writeJsonAtomic(getSquadFilePath(squad.id), squad);
315
315
  }
316
316
 
317
+ /** Atomically publish a new file-spec squad, canonical bytes and initial tasks as one directory rename. */
318
+ export function publishFileSquad(squad: Squad, tasks: Task[], canonicalBytes: Buffer): void {
319
+ if (!squad.spec) throw new Error("PUBLISH_FAILED: file squad is missing spec metadata");
320
+ const root = path.resolve(getSquadRoot()); ensureDir(root);
321
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/.test(squad.id)) throw new Error(`PUBLISH_FAILED: unsafe squad id ${squad.id}`);
322
+ const finalDir = path.resolve(getSquadDir(squad.id));
323
+ if (path.dirname(finalDir) !== root) throw new Error(`PUBLISH_FAILED: squad destination escapes root`);
324
+ const expectedSpecPath = path.join(finalDir, "spec", "spec.v1.json");
325
+ if (path.resolve(squad.spec.path) !== expectedSpecPath || canonicalBytes.length !== squad.spec.bytes || createHash("sha256").update(canonicalBytes).digest("hex") !== squad.spec.sha256) throw new Error("PUBLISH_FAILED: canonical bytes or destination do not match spec metadata");
326
+ const taskIds = new Set<string>();
327
+ for (const task of tasks) {
328
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(task.id) || taskIds.has(task.id)) throw new Error(`PUBLISH_FAILED: unsafe or duplicate task id ${task.id}`);
329
+ taskIds.add(task.id);
330
+ }
331
+ if (fs.existsSync(finalDir)) throw new Error(`PUBLISH_FAILED: squad ${squad.id} already exists`);
332
+ const stagingDir = path.join(root, `${squad.id}.creating.${randomUUID()}`);
333
+ fs.mkdirSync(stagingDir, { mode: 0o700 });
334
+ try {
335
+ const writeDurable = (filePath: string, content: string): void => { const fd = fs.openSync(filePath, "wx", 0o600); try { fs.writeFileSync(fd, content); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } };
336
+ const specDir = path.join(stagingDir, "spec"); fs.mkdirSync(specDir, { mode: 0o700 });
337
+ const specPath = path.join(specDir, "spec.v1.json"); const specFd = fs.openSync(specPath, "wx", 0o600);
338
+ try { fs.writeFileSync(specFd, canonicalBytes); fs.fsyncSync(specFd); } finally { fs.closeSync(specFd); }
339
+ try { fs.chmodSync(specPath, 0o400); } catch { /* Windows/best effort */ }
340
+ for (const task of tasks) {
341
+ const taskDir = path.join(stagingDir, task.id); fs.mkdirSync(taskDir, { mode: 0o700 });
342
+ writeDurable(path.join(taskDir, "task.json"), JSON.stringify(task, null, 2) + "\n");
343
+ }
344
+ writeDurable(path.join(stagingDir, "squad.json"), JSON.stringify(squad, null, 2) + "\n");
345
+ for (const dir of [specDir, stagingDir]) { try { const fd = fs.openSync(dir, fs.constants.O_RDONLY); try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } } catch { /* directory fsync unavailable */ } }
346
+ fs.renameSync(stagingDir, finalDir);
347
+ try { const fd = fs.openSync(root, fs.constants.O_RDONLY); try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } } catch { /* directory fsync unavailable */ }
348
+ } catch (error) {
349
+ try { fs.rmSync(stagingDir, { recursive: true, force: true }); } catch { /* preserve original failure */ }
350
+ throw new Error(`PUBLISH_FAILED: ${(error as Error).message}`);
351
+ }
352
+ }
353
+
317
354
  export function listSquads(): string[] {
318
355
  const root = getSquadRoot();
319
356
  if (!fs.existsSync(root)) return [];
320
357
  return fs
321
358
  .readdirSync(root)
322
359
  .filter((entry) => {
323
- if (entry === "agents" || entry === "memory.jsonl") return false;
360
+ if (entry === "agents" || entry === "memory.jsonl" || entry.includes(".creating.")) return false;
324
361
  const squadFile = path.join(root, entry, "squad.json");
325
362
  return fs.existsSync(squadFile);
326
363
  });