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.
- package/README.md +26 -3
- package/docs/behavioral-contract-exact-targeting-review-suspended-attention.md +204 -0
- package/docs/file-spec-and-full-read-attestation-contract.md +412 -0
- package/docs/fleet-bridge.md +79 -0
- package/package.json +2 -1
- package/src/agent-pool.ts +16 -4
- package/src/file-spec.ts +451 -0
- package/src/index.ts +209 -83
- package/src/panel/squad-widget.ts +16 -7
- package/src/panel/task-list.ts +10 -0
- package/src/presentation.ts +38 -0
- package/src/protocol.ts +21 -0
- package/src/review.ts +19 -6
- package/src/scheduler.ts +211 -18
- package/src/skills/squad-supervisor/SKILL.md +6 -1
- package/src/store.ts +39 -2
- package/src/types.ts +18 -1
package/src/panel/task-list.ts
CHANGED
|
@@ -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
|
|
96
|
-
|
|
97
|
-
|
|
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): ${
|
|
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.
|
|
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}
|
|
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
|
|
718
|
+
const fileSpec = store.loadSquad(this.squadId)?.spec;
|
|
719
|
+
const lines = fileSpec
|
|
629
720
|
? [
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
});
|