pi-squad 0.17.2 → 0.19.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/CHANGELOG.md +89 -0
- package/package.json +3 -1
- package/src/commands.ts +585 -0
- package/src/index.ts +14 -2013
- package/src/inline-input.ts +139 -0
- package/src/lifecycle.ts +164 -0
- package/src/panel-runtime.ts +157 -0
- package/src/planner.ts +2 -3
- package/src/protocol.ts +3 -47
- package/src/report.ts +40 -2
- package/src/runtime.ts +196 -0
- package/src/scheduler-runtime.ts +117 -0
- package/src/scheduler.ts +149 -135
- package/src/skills/squad-plan/SKILL.md +103 -0
- package/src/skills/squad-plan/validate-spec.mjs +65 -0
- package/src/skills/squad-supervisor/SKILL.md +1 -0
- package/src/start-squad.ts +176 -0
- package/src/store.ts +0 -43
- package/src/tools-registration.ts +626 -0
- package/src/types.ts +0 -26
- package/src/supervisor.ts +0 -142
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { prepareSpec, isFileSpecTaskId, type PreparedSpec } from "./file-spec.js";
|
|
5
|
+
import { coerceInlineSquadStart } from "./inline-input.js";
|
|
6
|
+
import { PLAN_STRUCTURE_RULES } from "./plan-rules.js";
|
|
7
|
+
import { formatSuspendedAttention, getReviewPresentation } from "./presentation.js";
|
|
8
|
+
import { buildOrchestratorReviewGate, recordOrchestratorReview } from "./review.js";
|
|
9
|
+
import { formatSuspendedStallAttention } from "./scheduler.js";
|
|
10
|
+
import { cancelExactSquad, ensureScheduler, reviveScheduler } from "./scheduler-runtime.js";
|
|
11
|
+
import { startSquad } from "./start-squad.js";
|
|
12
|
+
import * as store from "./store.js";
|
|
13
|
+
import type { Task } from "./types.js";
|
|
14
|
+
import { activeSuspendedAttentionForProject, disabledToolResult, focusSquad, forceWidgetUpdate, formatTaskProgress, getActiveScheduler, resolveResumeSquad, runtime, type InlineSquadStart } from "./runtime.js";
|
|
15
|
+
|
|
16
|
+
export function registerTools(pi: ExtensionAPI, squadSkillPaths: string[]): void {
|
|
17
|
+
// =========================================================================
|
|
18
|
+
// Context Injection — give main agent awareness of squad state
|
|
19
|
+
// =========================================================================
|
|
20
|
+
|
|
21
|
+
// Inject squad awareness before each LLM call
|
|
22
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
23
|
+
// Review gates are project-wide and survive focus changes, new squads, and
|
|
24
|
+
// disabling normal squad operations. Unaccepted work must stay visible.
|
|
25
|
+
const pendingReviewGates = store.findActiveSquads()
|
|
26
|
+
.filter((s) => s.cwd === ctx.cwd && s.status === "review")
|
|
27
|
+
.map((s) => ({ squad: s, gate: buildOrchestratorReviewGate(s, store.loadAllTasks(s.id)) }));
|
|
28
|
+
const suspendedAttention = activeSuspendedAttentionForProject(ctx.cwd)
|
|
29
|
+
.map(({ squadId, attention }) => formatSuspendedStallAttention(squadId, attention));
|
|
30
|
+
const durablePrompts = [...pendingReviewGates.map(({ gate }) => gate), ...suspendedAttention];
|
|
31
|
+
if (!runtime.squadEnabled) {
|
|
32
|
+
if (durablePrompts.length === 0) return;
|
|
33
|
+
const enableFirst = durablePrompts.map((prompt) =>
|
|
34
|
+
`${prompt}\npi-squad is disabled. Run /squad enable first; then perform only the explicit review or exact-task resume described above.`,
|
|
35
|
+
);
|
|
36
|
+
return { systemPrompt: event.systemPrompt + "\n\n" + enableFirst.join("\n\n") };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// When a squad is active, inject its status
|
|
40
|
+
if (runtime.activeSquadId) {
|
|
41
|
+
const squad = store.loadSquad(runtime.activeSquadId);
|
|
42
|
+
if (!squad) {
|
|
43
|
+
focusSquad(null);
|
|
44
|
+
if (durablePrompts.length > 0) {
|
|
45
|
+
return { systemPrompt: event.systemPrompt + "\n\n" + durablePrompts.join("\n\n") };
|
|
46
|
+
}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const tasks = store.loadAllTasks(runtime.activeSquadId);
|
|
50
|
+
if (tasks.length === 0) {
|
|
51
|
+
if (durablePrompts.length > 0) {
|
|
52
|
+
return { systemPrompt: event.systemPrompt + "\n\n" + durablePrompts.join("\n\n") };
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
58
|
+
|
|
59
|
+
const taskLines = tasks.map((t) => {
|
|
60
|
+
const icon = t.status === "done" ? "✓" : t.status === "in_progress" ? "⏳" : t.status === "failed" ? "✗" : t.status === "blocked" ? "◻" : t.status === "suspended" ? "⏸" : t.status === "cancelled" ? "⊘" : "·";
|
|
61
|
+
let line = ` ${icon} ${t.id} (${t.agent}) [${t.status}]`;
|
|
62
|
+
if (t.output) line += ` — ${t.output}`;
|
|
63
|
+
if (t.error) line += ` ERROR: ${t.error}`;
|
|
64
|
+
return line;
|
|
65
|
+
}).join("\n");
|
|
66
|
+
|
|
67
|
+
const reviewPresentation = getReviewPresentation(squad);
|
|
68
|
+
const squadReference = squad.spec
|
|
69
|
+
? `file spec sha256=${squad.spec.sha256} bytes=${squad.spec.bytes} path=${squad.spec.path}`
|
|
70
|
+
: squad.goal;
|
|
71
|
+
const squadContext = [
|
|
72
|
+
`<squad_status>`,
|
|
73
|
+
`Squad: ${squad.id} — ${squadReference}`,
|
|
74
|
+
`Status: ${squad.status} | ${formatTaskProgress(tasks)} | $${totalCost.toFixed(2)}`,
|
|
75
|
+
...(reviewPresentation ? [`Acceptance: ${reviewPresentation.label}`] : []),
|
|
76
|
+
taskLines,
|
|
77
|
+
`</squad_status>`,
|
|
78
|
+
...(squad.status === "review" ? [buildOrchestratorReviewGate(squad, tasks)] : []),
|
|
79
|
+
...pendingReviewGates.filter(({ squad: pending }) => pending.id !== squad.id).map(({ gate }) => gate),
|
|
80
|
+
...suspendedAttention,
|
|
81
|
+
`You have an active squad. Use squad_message to talk to agents, squad_status for details, squad_modify to change tasks.`,
|
|
82
|
+
`Do NOT poll squad_status in a loop or sleep-wait — the squad wakes you automatically on completion, failure, or escalation. Keep helping the user with other work, or end your turn and stay idle.`,
|
|
83
|
+
].join("\n");
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
systemPrompt: event.systemPrompt + "\n\n" + squadContext,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// When NO squad is active, nudge the agent to consider using squad for complex tasks
|
|
91
|
+
const allAgents = store.loadAllAgentDefs(ctx.cwd).filter((a) => a.name !== "planner" && !a.disabled);
|
|
92
|
+
const agentList = allAgents.map((a) => `${a.name} (${a.role})`).join(", ");
|
|
93
|
+
const squadNudge = [
|
|
94
|
+
`<squad_hint>`,
|
|
95
|
+
`You have the "squad" tool available for multi-agent collaboration.`,
|
|
96
|
+
`Use it when the user's request involves multiple concerns (e.g. backend + frontend + tests + docs),`,
|
|
97
|
+
`would benefit from parallel execution, or is too large for a single agent context.`,
|
|
98
|
+
`The squad tool decomposes work into tasks, assigns specialist agents, and runs them in parallel.`,
|
|
99
|
+
`When in doubt about whether a task is complex enough, prefer using squad — it handles the coordination for you.`,
|
|
100
|
+
allAgents.length > 0 ? `Available agents: ${agentList}. When providing tasks, the "agent" field must be one of these names.` : ``,
|
|
101
|
+
`When you provide tasks yourself, you take the planner's role — follow its rules: contract/design task first for shared interfaces, final QA task for user-facing changes, 3-7 tasks, first task(s) with empty depends.`,
|
|
102
|
+
`Structure descriptions as: Goal (outcome first), Context (files to read), Output (deliverable), Boundaries (what must not change), Verify (proving command).`,
|
|
103
|
+
`</squad_hint>`,
|
|
104
|
+
].filter(Boolean).join("\n");
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
systemPrompt: event.systemPrompt + "\n\n" + squadNudge +
|
|
108
|
+
(durablePrompts.length > 0 ? "\n\n" + durablePrompts.join("\n\n") : ""),
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// =========================================================================
|
|
113
|
+
// Tool: squad
|
|
114
|
+
// =========================================================================
|
|
115
|
+
|
|
116
|
+
pi.registerTool({
|
|
117
|
+
name: "squad",
|
|
118
|
+
label: "Squad",
|
|
119
|
+
description: [
|
|
120
|
+
"Start a multi-agent squad for complex, multi-step tasks.",
|
|
121
|
+
"ALWAYS use squad when a task involves 2+ of: backend, frontend, testing, docs, devops, security.",
|
|
122
|
+
"Use when a task has natural parallelism, touches multiple files/systems, or would overflow a single agent's context.",
|
|
123
|
+
"Examples that NEED squad: 'build a REST API with auth and tests', 'add a feature with frontend + backend + docs',",
|
|
124
|
+
"'refactor the auth system and update tests', 'set up CI/CD with Docker and deployment'.",
|
|
125
|
+
"Do NOT use for simple single-file changes, quick bug fixes, or tasks a single agent can handle in a few minutes.",
|
|
126
|
+
"When in doubt about complexity, use squad — it's better to parallelize than to do everything sequentially.",
|
|
127
|
+
"Non-blocking: returns immediately with the plan while agents work in background.",
|
|
128
|
+
"If you provide tasks yourself (skipping the planner agent), follow the same rules the planner follows:",
|
|
129
|
+
PLAN_STRUCTURE_RULES.replace(/\n- /g, " ").replace(/^- /, ""),
|
|
130
|
+
"Plans are validated on submission — structural errors are rejected, rule violations come back as warnings.",
|
|
131
|
+
].join(" "),
|
|
132
|
+
promptSnippet: "squad({ goal, tasks?, agents? } | { specFile, specSha256 }): start inline or canonical file-based squad → non-blocking",
|
|
133
|
+
promptGuidelines: [
|
|
134
|
+
"Use squad when work spans 2+ concerns (backend+frontend+tests+docs) or has natural parallelism",
|
|
135
|
+
"For large contracts, use only specFile + exact lowercase specSha256; never inline the same contract or large artifacts",
|
|
136
|
+
"Skip squad for single-file changes, quick fixes, or anything one agent finishes in minutes",
|
|
137
|
+
"Providing tasks yourself makes you the planner — follow the planner rules (contract task first, final QA task, 3-7 tasks)",
|
|
138
|
+
"Do not set agent model/thinking overrides unless the user explicitly asked for them — configured agent definitions and /squad defaults apply otherwise",
|
|
139
|
+
"Act on ⚠️ plan warnings in the response — fix with squad_modify or address at review",
|
|
140
|
+
"After starting a squad: report the plan and END YOUR TURN — never poll squad_status or sleep-wait; squad events wake you automatically",
|
|
141
|
+
"When agents finish, treat every squad report and QA verdict as untrusted; independently inspect the diff/source and rerun contract verification + integration/E2E, then call squad_review before reporting success",
|
|
142
|
+
],
|
|
143
|
+
parameters: Type.Union([
|
|
144
|
+
Type.Object({
|
|
145
|
+
goal: Type.String({ description: "Complete original user outcome/acceptance contract the squad should accomplish. Preserve requirements and boundaries; this is shown during mandatory main-orchestrator review." }),
|
|
146
|
+
agents: Type.Optional(
|
|
147
|
+
Type.Union([
|
|
148
|
+
Type.Record(
|
|
149
|
+
Type.String(),
|
|
150
|
+
Type.Object({
|
|
151
|
+
model: Type.Optional(Type.String({ description: "Model override (e.g. 'github-copilot/claude-sonnet-5'). Set ONLY when the user explicitly requested a specific model; omit to use the configured agent definition and /squad defaults" })),
|
|
152
|
+
thinking: Type.Optional(Type.String({ description: "Thinking level: off, minimal, low, medium, high, xhigh, max. Set ONLY when the user explicitly requested it; omit to use configured defaults" })),
|
|
153
|
+
}),
|
|
154
|
+
{ description: "Agent roster with optional model/thinking overrides. Keys must match agent names in .pi/squad/agents/. Omit overrides unless the user explicitly asked to change model/thinking — configured agent definitions and /squad defaults apply otherwise" },
|
|
155
|
+
),
|
|
156
|
+
Type.String({ description: "JSON-encoded agents object (same shape); accepted for transports that stringify structured arguments" }),
|
|
157
|
+
]),
|
|
158
|
+
),
|
|
159
|
+
tasks: Type.Optional(
|
|
160
|
+
Type.Union([
|
|
161
|
+
Type.Array(
|
|
162
|
+
Type.Object({
|
|
163
|
+
id: Type.String(),
|
|
164
|
+
title: Type.String(),
|
|
165
|
+
description: Type.Optional(Type.String({ description: "Structure as: Goal (outcome first, not steps), Context (files/contracts to read), Output (deliverable), Boundaries (what must NOT change), Verify (command that proves it works). Include only the parts that help." })),
|
|
166
|
+
agent: Type.String(),
|
|
167
|
+
depends: Type.Optional(Type.Array(Type.String())),
|
|
168
|
+
inheritContext: Type.Optional(Type.Boolean({ description: "Fork the current pi session so the agent inherits this conversation's full context. Use ONLY when the task depends on decisions/details discussed here that can't be restated briefly. Costly (agent pays the whole history as input each turn) and auto-skipped when the session exceeds 50% of the agent model's context window — prefer restating key context in the description." })),
|
|
169
|
+
}),
|
|
170
|
+
{ description: "Pre-defined task breakdown. If provided, skips the planner agent. Scope tasks to required work only — no optional polish." },
|
|
171
|
+
),
|
|
172
|
+
Type.String({ description: "JSON-encoded tasks array (same item shape); accepted for transports that stringify structured arguments" }),
|
|
173
|
+
]),
|
|
174
|
+
),
|
|
175
|
+
config: Type.Optional(
|
|
176
|
+
Type.Union([
|
|
177
|
+
Type.Object({
|
|
178
|
+
maxConcurrency: Type.Optional(Type.Number({ description: "Max parallel agents (default: 2)" })),
|
|
179
|
+
}),
|
|
180
|
+
Type.String({ description: "JSON-encoded config object (same shape); accepted for transports that stringify structured arguments" }),
|
|
181
|
+
]),
|
|
182
|
+
),
|
|
183
|
+
}, { additionalProperties: false }),
|
|
184
|
+
Type.Object({
|
|
185
|
+
specFile: Type.String({ minLength: 1, description: "Path to a strict v1 squad specification JSON file" }),
|
|
186
|
+
specSha256: Type.String({ pattern: "^[a-f0-9]{64}$", description: "SHA-256 of the exact source bytes" }),
|
|
187
|
+
}, { additionalProperties: false }),
|
|
188
|
+
]),
|
|
189
|
+
|
|
190
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
191
|
+
if (!runtime.squadEnabled) return disabledToolResult();
|
|
192
|
+
if (!runtime.uiCtx) runtime.uiCtx = ctx;
|
|
193
|
+
|
|
194
|
+
// Check if the user cancelled before we start
|
|
195
|
+
if (signal?.aborted) return { content: [{ type: "text" as const, text: "Cancelled." }], details: undefined };
|
|
196
|
+
|
|
197
|
+
// Multiple squads can run concurrently — no guard needed
|
|
198
|
+
|
|
199
|
+
// Resolve to absolute: the fork happens later from a child process whose
|
|
200
|
+
// cwd may differ (e.g. when a relative --session-dir was used).
|
|
201
|
+
const rawSessionFile = ctx.sessionManager.getSessionFile();
|
|
202
|
+
const sessionFile = rawSessionFile ? path.resolve(rawSessionFile) : null;
|
|
203
|
+
let prepared: PreparedSpec | undefined;
|
|
204
|
+
let effective: InlineSquadStart;
|
|
205
|
+
if ("specFile" in params) {
|
|
206
|
+
prepared = prepareSpec(params.specFile, params.specSha256, ctx.cwd);
|
|
207
|
+
effective = { goal: prepared.spec.goal, agents: prepared.spec.agents, tasks: prepared.spec.tasks, config: prepared.spec.config };
|
|
208
|
+
} else {
|
|
209
|
+
// Some transports stringify structured arguments; decode tolerantly with
|
|
210
|
+
// precise errors instead of failing a correct plan on transport shape.
|
|
211
|
+
const coerced = coerceInlineSquadStart(params);
|
|
212
|
+
if (!coerced.ok) {
|
|
213
|
+
return { content: [{ type: "text" as const, text: `Invalid squad input: ${coerced.error} No squad was started.` }], details: undefined };
|
|
214
|
+
}
|
|
215
|
+
effective = coerced.value;
|
|
216
|
+
}
|
|
217
|
+
const baseId = store.makeTaskId(effective.goal) || `squad-${prepared?.sha256.slice(0, 12)}`;
|
|
218
|
+
const squadId = store.squadExists(baseId) ? `${baseId}-${Date.now().toString(36)}` : baseId;
|
|
219
|
+
return await startSquad(squadId, effective, ctx.cwd, squadSkillPaths, pi, sessionFile, prepared);
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// =========================================================================
|
|
224
|
+
// Tool: squad_status
|
|
225
|
+
// =========================================================================
|
|
226
|
+
|
|
227
|
+
pi.registerTool({
|
|
228
|
+
name: "squad_status",
|
|
229
|
+
label: "Squad Status",
|
|
230
|
+
description: "Check current squad status, task progress, and recent activity. Do NOT call this in a loop or after sleep-waits — squad completion/failure/escalations wake you automatically. Use only when the user asks for a status update or after being woken by a squad event.",
|
|
231
|
+
parameters: Type.Object({
|
|
232
|
+
squadId: Type.Optional(Type.String({ description: "Specific squad ID (default: most recent)" })),
|
|
233
|
+
}),
|
|
234
|
+
|
|
235
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
236
|
+
if (!runtime.squadEnabled) return disabledToolResult();
|
|
237
|
+
let id = params.squadId || runtime.activeSquadId;
|
|
238
|
+
|
|
239
|
+
// If no active squad, find the most recent one for this project
|
|
240
|
+
if (!id) {
|
|
241
|
+
const latest = store.findLatestSquad(ctx.cwd);
|
|
242
|
+
if (latest) id = latest.id;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!id) {
|
|
246
|
+
return { content: [{ type: "text" as const, text: "No squads found. Use the squad tool to start one." }], details: undefined };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// If scheduler is running, force a context refresh
|
|
250
|
+
const sched = runtime.schedulers.get(id!);
|
|
251
|
+
if (sched) sched.updateContext();
|
|
252
|
+
|
|
253
|
+
const context = store.loadContext(id);
|
|
254
|
+
if (!context) {
|
|
255
|
+
return { content: [{ type: "text" as const, text: `Squad '${id}' not found or has no context yet.` }], details: undefined };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const taskLines = Object.entries(context.tasks)
|
|
259
|
+
.map(([taskId, task]) => {
|
|
260
|
+
const icon =
|
|
261
|
+
task.status === "done" ? "✓" :
|
|
262
|
+
task.status === "in_progress" ? "⏳" :
|
|
263
|
+
task.status === "blocked" ? "◻" :
|
|
264
|
+
task.status === "failed" ? "✗" :
|
|
265
|
+
task.status === "suspended" ? "⏸" :
|
|
266
|
+
task.status === "cancelled" ? "⊘" :
|
|
267
|
+
"·";
|
|
268
|
+
let line = `${icon} ${taskId} (${task.agent}) — ${task.title} [${task.status}]`;
|
|
269
|
+
if (task.blockedBy?.length) line += ` blocked by: ${task.blockedBy.join(", ")}`;
|
|
270
|
+
return line;
|
|
271
|
+
})
|
|
272
|
+
.join("\n");
|
|
273
|
+
|
|
274
|
+
const durableTasks = store.loadAllTasks(id!);
|
|
275
|
+
const squad = store.loadSquad(id!);
|
|
276
|
+
const review = squad ? getReviewPresentation(squad) : null;
|
|
277
|
+
const summary = [
|
|
278
|
+
`Squad: ${id}`,
|
|
279
|
+
`Status: ${context.status}`,
|
|
280
|
+
`Progress: ${formatTaskProgress(durableTasks)}`,
|
|
281
|
+
`Elapsed: ${context.elapsed}`,
|
|
282
|
+
`Cost: $${context.costs.total.toFixed(4)}`,
|
|
283
|
+
...(review ? [`Acceptance: ${review.label}`] : []),
|
|
284
|
+
...(squad ? formatSuspendedAttention(squad) : []),
|
|
285
|
+
"",
|
|
286
|
+
"Tasks:",
|
|
287
|
+
taskLines,
|
|
288
|
+
].join("\n");
|
|
289
|
+
|
|
290
|
+
return { content: [{ type: "text" as const, text: summary }], details: undefined };
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// =========================================================================
|
|
295
|
+
// Tool: squad_review — mandatory main-orchestrator acceptance gate
|
|
296
|
+
// =========================================================================
|
|
297
|
+
|
|
298
|
+
pi.registerTool({
|
|
299
|
+
name: "squad_review",
|
|
300
|
+
label: "Record Independent Squad Review",
|
|
301
|
+
description: "Record the MAIN Pi/orchestrator's independent review of completed squad work against the original user contract. Call only after inspecting the actual diff/source and independently running verification plus integration/E2E where applicable. Squad reports and squad QA evidence are not sufficient.",
|
|
302
|
+
parameters: Type.Object({
|
|
303
|
+
squadId: Type.Optional(Type.String({ description: "Squad awaiting review (default: active/latest)" })),
|
|
304
|
+
verdict: Type.Union([
|
|
305
|
+
Type.Literal("pass"),
|
|
306
|
+
Type.Literal("pass_with_issues"),
|
|
307
|
+
Type.Literal("fail"),
|
|
308
|
+
]),
|
|
309
|
+
contractChecks: Type.Array(Type.String(), { minItems: 1, description: "Requirement-by-requirement checks against the ORIGINAL user request and later clarifications; include observed result for each" }),
|
|
310
|
+
diffReview: Type.String({ description: "What you independently inspected in the actual diff/source, including scope and integration concerns" }),
|
|
311
|
+
verificationEvidence: Type.Array(Type.String(), { minItems: 1, description: "Commands/checks YOU ran and their actual results; do not copy squad claims" }),
|
|
312
|
+
integrationEvidence: Type.String({ description: "Integration/E2E result from the real target or production-like environment, or a precise reason it is not applicable/impossible and therefore unverified" }),
|
|
313
|
+
issues: Type.Array(Type.String(), { description: "Every discovered or remaining issue; required for fail/pass_with_issues" }),
|
|
314
|
+
}),
|
|
315
|
+
|
|
316
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
317
|
+
if (!runtime.squadEnabled) return disabledToolResult();
|
|
318
|
+
let id = params.squadId;
|
|
319
|
+
if (!id && runtime.activeSquadId && store.loadSquad(runtime.activeSquadId)?.status === "review") {
|
|
320
|
+
id = runtime.activeSquadId;
|
|
321
|
+
}
|
|
322
|
+
if (!id) {
|
|
323
|
+
id = store.listSquadsForProject(ctx.cwd)
|
|
324
|
+
.filter((s) => s.status === "review")
|
|
325
|
+
.sort((a, b) => b.created.localeCompare(a.created))[0]?.id;
|
|
326
|
+
}
|
|
327
|
+
if (!id) {
|
|
328
|
+
return { content: [{ type: "text" as const, text: "No squad is awaiting orchestrator review." }], details: undefined };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const squad = store.loadSquad(id);
|
|
332
|
+
if (!squad) {
|
|
333
|
+
return { content: [{ type: "text" as const, text: `Squad '${id}' not found.` }], details: undefined };
|
|
334
|
+
}
|
|
335
|
+
const attestationScheduler = reviveScheduler(pi, id, squadSkillPaths);
|
|
336
|
+
const invalidAttestations = await attestationScheduler.auditSpecAttestations();
|
|
337
|
+
if (!runtime.squadEnabled) return disabledToolResult();
|
|
338
|
+
if (invalidAttestations.length > 0) {
|
|
339
|
+
void attestationScheduler.start();
|
|
340
|
+
return { content: [{ type: "text" as const, text: `Review rejected: invalid canonical spec attestation for task(s): ${invalidAttestations.join(", ")}. Work was reopened.` }], details: undefined };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
recordOrchestratorReview(squad, {
|
|
345
|
+
verdict: params.verdict,
|
|
346
|
+
contractChecks: params.contractChecks,
|
|
347
|
+
diffReview: params.diffReview,
|
|
348
|
+
verificationEvidence: params.verificationEvidence,
|
|
349
|
+
integrationEvidence: params.integrationEvidence,
|
|
350
|
+
issues: params.issues,
|
|
351
|
+
});
|
|
352
|
+
} catch (error) {
|
|
353
|
+
return { content: [{ type: "text" as const, text: `Review rejected: ${(error as Error).message}` }], details: undefined };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
store.saveSquad(squad);
|
|
357
|
+
forceWidgetUpdate();
|
|
358
|
+
const accepted = squad.status === "done";
|
|
359
|
+
const text = accepted
|
|
360
|
+
? `Independent orchestrator review recorded for '${id}' (${params.verdict}). The squad is now accepted as done.`
|
|
361
|
+
: `Independent review FAILED for '${id}'. The squad remains review-required. Fix every issue, rerun verification/E2E, then submit a fresh squad_review.`;
|
|
362
|
+
return { content: [{ type: "text" as const, text }], details: undefined };
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// =========================================================================
|
|
367
|
+
// Tool: squad_message
|
|
368
|
+
// =========================================================================
|
|
369
|
+
|
|
370
|
+
pi.registerTool({
|
|
371
|
+
name: "squad_message",
|
|
372
|
+
label: "Squad Message",
|
|
373
|
+
description: "Send a durable request to a specific task. Existing tasks reopen their original Pi session; only a currently running task may be selected by agent name.",
|
|
374
|
+
parameters: Type.Object({
|
|
375
|
+
message: Type.String({ description: "Message to send" }),
|
|
376
|
+
taskId: Type.Optional(Type.String({ description: "Target task ID" })),
|
|
377
|
+
agent: Type.Optional(Type.String({ description: "Target agent name" })),
|
|
378
|
+
expectReply: Type.Optional(Type.Boolean({ description: "Forward the agent's next substantive response back to main Pi and wake it (default true)" })),
|
|
379
|
+
}),
|
|
380
|
+
|
|
381
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
382
|
+
if (!runtime.squadEnabled) return disabledToolResult();
|
|
383
|
+
if (!runtime.activeSquadId) {
|
|
384
|
+
return { content: [{ type: "text" as const, text: "No active squad." }], details: undefined };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
let activeScheduler = getActiveScheduler();
|
|
388
|
+
let taskId = params.taskId;
|
|
389
|
+
|
|
390
|
+
// Agent name is safe shorthand only when it identifies one live task.
|
|
391
|
+
// Multiple concurrent tasks can share a role, so never guess between them.
|
|
392
|
+
if (!taskId && params.agent && activeScheduler) {
|
|
393
|
+
const liveMatches = store.loadAllTasks(runtime.activeSquadId).filter(
|
|
394
|
+
(task) => task.agent === params.agent && activeScheduler!.getPool().isRunning(task.id),
|
|
395
|
+
);
|
|
396
|
+
if (liveMatches.length === 1) taskId = liveMatches[0].id;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (!taskId) {
|
|
400
|
+
return { content: [{ type: "text" as const, text: "Could not determine target task. Provide taskId or an agent name that is currently running." }], details: undefined };
|
|
401
|
+
}
|
|
402
|
+
if (!store.loadTask(runtime.activeSquadId, taskId)) {
|
|
403
|
+
return { content: [{ type: "text" as const, text: `Task not found: ${taskId}` }], details: undefined };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Review/done squads have no live scheduler after a process restart. An
|
|
407
|
+
// exact task ID is enough to reconstruct it from disk and reopen only that
|
|
408
|
+
// task; the task's immutable session binding supplies --session.
|
|
409
|
+
if (!activeScheduler) {
|
|
410
|
+
activeScheduler = reviveScheduler(pi, runtime.activeSquadId, squadSkillPaths);
|
|
411
|
+
await activeScheduler.start();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const sent = await activeScheduler.sendHumanMessage(taskId, params.message, params.expectReply ?? true);
|
|
415
|
+
const status = sent ? "delivered" : "queued for when the agent starts";
|
|
416
|
+
|
|
417
|
+
return { content: [{ type: "text" as const, text: `Message ${status}: "${params.message}"` }], details: undefined };
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// =========================================================================
|
|
422
|
+
// Tool: squad_modify
|
|
423
|
+
// =========================================================================
|
|
424
|
+
|
|
425
|
+
pi.registerTool({
|
|
426
|
+
name: "squad_modify",
|
|
427
|
+
label: "Squad Modify",
|
|
428
|
+
description: "Modify a squad. The destructive cancel action requires an exact squadId and never infers focus; task actions reconstruct the persisted scheduler after restart when needed.",
|
|
429
|
+
parameters: Type.Object({
|
|
430
|
+
squadId: Type.Optional(Type.String({ description: "Exact squad to modify; required for cancel (other actions may use the focused/recoverable project squad)" })),
|
|
431
|
+
action: Type.Union(
|
|
432
|
+
[
|
|
433
|
+
Type.Literal("add_task"),
|
|
434
|
+
Type.Literal("set_dependencies"),
|
|
435
|
+
Type.Literal("cancel_task"),
|
|
436
|
+
Type.Literal("pause_task"),
|
|
437
|
+
Type.Literal("resume_task"),
|
|
438
|
+
Type.Literal("complete_task"),
|
|
439
|
+
Type.Literal("pause"),
|
|
440
|
+
Type.Literal("resume"),
|
|
441
|
+
Type.Literal("cancel"),
|
|
442
|
+
],
|
|
443
|
+
{ description: "Action to perform" },
|
|
444
|
+
),
|
|
445
|
+
taskId: Type.Optional(Type.String({ description: "Task ID for task-specific actions" })),
|
|
446
|
+
depends: Type.Optional(Type.Array(Type.String(), { description: "Complete replacement dependency list; required at top level for set_dependencies" })),
|
|
447
|
+
output: Type.Optional(Type.String({ description: "Result summary for complete_task (what was accomplished)" })),
|
|
448
|
+
task: Type.Optional(
|
|
449
|
+
Type.Object({
|
|
450
|
+
id: Type.String(),
|
|
451
|
+
title: Type.String(),
|
|
452
|
+
description: Type.Optional(Type.String()),
|
|
453
|
+
agent: Type.String(),
|
|
454
|
+
depends: Type.Optional(Type.Array(Type.String())),
|
|
455
|
+
inheritContext: Type.Optional(Type.Boolean({ description: "Fork the current pi session so the agent inherits this conversation's context (see squad tool docs for caveats)" })),
|
|
456
|
+
}, { description: "Task definition for add_task" }),
|
|
457
|
+
),
|
|
458
|
+
}),
|
|
459
|
+
|
|
460
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
461
|
+
if (!runtime.squadEnabled) return disabledToolResult();
|
|
462
|
+
if (params.action === "cancel") {
|
|
463
|
+
if (!params.squadId?.trim()) {
|
|
464
|
+
return { content: [{ type: "text" as const, text: "cancel requires exact squadId; no squad was changed." }], details: undefined };
|
|
465
|
+
}
|
|
466
|
+
const squadId = params.squadId.trim();
|
|
467
|
+
if (!store.loadSquad(squadId)) {
|
|
468
|
+
return { content: [{ type: "text" as const, text: `Squad '${squadId}' not found; no squad was changed.` }], details: undefined };
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
await cancelExactSquad(squadId, squadSkillPaths);
|
|
472
|
+
} catch (error) {
|
|
473
|
+
return { content: [{ type: "text" as const, text: `Cancel failed for squad '${squadId}': ${(error as Error).message}` }], details: undefined };
|
|
474
|
+
}
|
|
475
|
+
return { content: [{ type: "text" as const, text: `Squad '${squadId}' cancelled.` }], details: undefined };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (params.action === "resume") {
|
|
479
|
+
const squad = resolveResumeSquad(ctx.cwd, params.squadId);
|
|
480
|
+
if (!squad) {
|
|
481
|
+
const text = params.squadId
|
|
482
|
+
? `Squad '${params.squadId}' not found.`
|
|
483
|
+
: "No paused, failed, or failed-review squad found to resume.";
|
|
484
|
+
return { content: [{ type: "text" as const, text }], details: undefined };
|
|
485
|
+
}
|
|
486
|
+
const resumeSched = ensureScheduler(pi, squad.id, squadSkillPaths);
|
|
487
|
+
try {
|
|
488
|
+
await resumeSched.resume();
|
|
489
|
+
} catch (err) {
|
|
490
|
+
return { content: [{ type: "text" as const, text: `Resume failed: ${(err as Error).message}` }], details: undefined };
|
|
491
|
+
}
|
|
492
|
+
const tasks = store.loadAllTasks(squad.id);
|
|
493
|
+
return { content: [{ type: "text" as const, text: `Squad "${squad.id}" resumed (${formatTaskProgress(tasks)}). Agents restarting in background.` }], details: undefined };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const squadId = params.squadId || runtime.activeSquadId;
|
|
497
|
+
if (!squadId || !store.loadSquad(squadId)) {
|
|
498
|
+
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 };
|
|
499
|
+
}
|
|
500
|
+
let activeScheduler = runtime.schedulers.get(squadId) || null;
|
|
501
|
+
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")) {
|
|
502
|
+
activeScheduler = ensureScheduler(pi, squadId, squadSkillPaths);
|
|
503
|
+
}
|
|
504
|
+
if (!activeScheduler) {
|
|
505
|
+
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 };
|
|
506
|
+
}
|
|
507
|
+
focusSquad(squadId);
|
|
508
|
+
|
|
509
|
+
switch (params.action) {
|
|
510
|
+
case "add_task": {
|
|
511
|
+
if (!params.task) {
|
|
512
|
+
return { content: [{ type: "text" as const, text: "Provide a task definition for add_task." }], details: undefined };
|
|
513
|
+
}
|
|
514
|
+
// Validate against the live squad: deps must exist, agent must exist
|
|
515
|
+
const targetSquad = store.loadSquad(squadId)!;
|
|
516
|
+
if (targetSquad.spec && !isFileSpecTaskId(params.task.id)) {
|
|
517
|
+
return { content: [{ type: "text" as const, text: `Invalid file-spec task id '${params.task.id}'. Use 1..64 lowercase letters/digits with internal hyphens.` }], details: undefined };
|
|
518
|
+
}
|
|
519
|
+
const existing = store.loadAllTasks(squadId);
|
|
520
|
+
const existingIds = new Set(existing.map((t) => t.id));
|
|
521
|
+
if (existingIds.has(params.task.id)) {
|
|
522
|
+
return { content: [{ type: "text" as const, text: `Task id '${params.task.id}' already exists in this squad.` }], details: undefined };
|
|
523
|
+
}
|
|
524
|
+
const badDeps = (params.task.depends || []).filter((d) => !existingIds.has(d));
|
|
525
|
+
if (badDeps.length > 0) {
|
|
526
|
+
return { content: [{ type: "text" as const, text: `Unknown dependency task(s): ${badDeps.join(", ")}. Existing tasks: ${[...existingIds].join(", ")}` }], details: undefined };
|
|
527
|
+
}
|
|
528
|
+
const targetCwd = store.loadSquad(squadId)!.cwd;
|
|
529
|
+
if (!store.loadAgentDef(params.task.agent, targetCwd)) {
|
|
530
|
+
const available = store.loadAllAgentDefs(targetCwd).filter((a) => !a.disabled).map((a) => a.name).join(", ");
|
|
531
|
+
return { content: [{ type: "text" as const, text: `Unknown agent '${params.task.agent}'. Available: ${available}` }], details: undefined };
|
|
532
|
+
}
|
|
533
|
+
const dependencies = params.task.depends || [];
|
|
534
|
+
const task: Task = {
|
|
535
|
+
id: params.task.id,
|
|
536
|
+
title: params.task.title,
|
|
537
|
+
description: params.task.description || "",
|
|
538
|
+
agent: params.task.agent,
|
|
539
|
+
status: dependencies.every((dependency) => existing.find((candidate) => candidate.id === dependency)?.status === "done") ? "pending" : "blocked",
|
|
540
|
+
depends: dependencies,
|
|
541
|
+
...(params.task.inheritContext ? { inheritContext: true } : {}),
|
|
542
|
+
...(targetSquad.spec ? { fileSpecDelta: true } : {}),
|
|
543
|
+
created: store.now(),
|
|
544
|
+
started: null,
|
|
545
|
+
completed: null,
|
|
546
|
+
output: null,
|
|
547
|
+
error: null,
|
|
548
|
+
usage: { inputTokens: 0, outputTokens: 0, cost: 0, turns: 0 },
|
|
549
|
+
};
|
|
550
|
+
try {
|
|
551
|
+
await activeScheduler.addTask(task);
|
|
552
|
+
} catch (err) {
|
|
553
|
+
return { content: [{ type: "text" as const, text: `add_task failed: ${(err as Error).message}` }], details: undefined };
|
|
554
|
+
}
|
|
555
|
+
return { content: [{ type: "text" as const, text: `Task '${task.id}' added to squad '${squadId}'.` }], details: undefined };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
case "set_dependencies": {
|
|
559
|
+
if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
|
|
560
|
+
if (!params.depends) return { content: [{ type: "text" as const, text: "Provide top-level depends for set_dependencies (use [] to remove all dependencies)." }], details: undefined };
|
|
561
|
+
try {
|
|
562
|
+
await activeScheduler.setDependencies(params.taskId, params.depends);
|
|
563
|
+
} catch (err) {
|
|
564
|
+
return { content: [{ type: "text" as const, text: `set_dependencies failed: ${(err as Error).message}` }], details: undefined };
|
|
565
|
+
}
|
|
566
|
+
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' dependencies updated in squad '${squadId}'.` }], details: undefined };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
case "cancel_task": {
|
|
570
|
+
if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
|
|
571
|
+
try {
|
|
572
|
+
await activeScheduler.cancelTask(params.taskId);
|
|
573
|
+
} catch (err) {
|
|
574
|
+
return { content: [{ type: "text" as const, text: `cancel_task failed: ${(err as Error).message}` }], details: undefined };
|
|
575
|
+
}
|
|
576
|
+
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' cancelled.` }], details: undefined };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
case "pause_task": {
|
|
580
|
+
if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
|
|
581
|
+
await activeScheduler.pauseTask(params.taskId);
|
|
582
|
+
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' paused.` }], details: undefined };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
case "resume_task": {
|
|
586
|
+
if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
|
|
587
|
+
try {
|
|
588
|
+
const result = await activeScheduler.resumeTask(params.taskId);
|
|
589
|
+
if (result === "already_running") {
|
|
590
|
+
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' is already running in squad '${squadId}'; no duplicate resume was started.` }], details: undefined };
|
|
591
|
+
}
|
|
592
|
+
} catch (err) {
|
|
593
|
+
return { content: [{ type: "text" as const, text: `resume_task failed for task '${params.taskId}' in squad '${squadId}': ${(err as Error).message}` }], details: undefined };
|
|
594
|
+
}
|
|
595
|
+
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' resumed in squad '${squadId}'.` }], details: undefined };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
case "complete_task": {
|
|
599
|
+
if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
|
|
600
|
+
try {
|
|
601
|
+
await activeScheduler.completeTask(params.taskId, params.output);
|
|
602
|
+
} catch (err) {
|
|
603
|
+
return { content: [{ type: "text" as const, text: `complete_task failed: ${(err as Error).message}` }], details: undefined };
|
|
604
|
+
}
|
|
605
|
+
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' marked done — dependents unblocked and scheduled.` }], details: undefined };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
case "pause": {
|
|
609
|
+
const squad = store.loadSquad(squadId);
|
|
610
|
+
if (squad) {
|
|
611
|
+
squad.status = "paused";
|
|
612
|
+
store.saveSquad(squad);
|
|
613
|
+
}
|
|
614
|
+
await activeScheduler.stop();
|
|
615
|
+
return { content: [{ type: "text" as const, text: "Squad paused. Use squad_modify with action 'resume' to continue." }], details: undefined };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Note: "resume" and exact-ID "cancel" are handled above.
|
|
619
|
+
|
|
620
|
+
default:
|
|
621
|
+
return { content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }], details: undefined };
|
|
622
|
+
}
|
|
623
|
+
},
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -255,20 +255,6 @@ export interface SquadContext {
|
|
|
255
255
|
modifiedFiles: Record<string, string[]>;
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
-
// ============================================================================
|
|
259
|
-
// Knowledge (JSONL entries)
|
|
260
|
-
// ============================================================================
|
|
261
|
-
|
|
262
|
-
export type KnowledgeType = "decision" | "convention" | "finding";
|
|
263
|
-
|
|
264
|
-
export interface KnowledgeEntry {
|
|
265
|
-
ts: string;
|
|
266
|
-
from: string;
|
|
267
|
-
squad?: string;
|
|
268
|
-
type: KnowledgeType;
|
|
269
|
-
text: string;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
258
|
// ============================================================================
|
|
273
259
|
// Scheduler
|
|
274
260
|
// ============================================================================
|
|
@@ -287,18 +273,6 @@ export interface AgentActivity {
|
|
|
287
273
|
|
|
288
274
|
export type HealthStatus = "healthy" | "idle_warning" | "stuck" | "looping" | "long_running";
|
|
289
275
|
|
|
290
|
-
// ============================================================================
|
|
291
|
-
// Supervisor
|
|
292
|
-
// ============================================================================
|
|
293
|
-
|
|
294
|
-
export type SupervisorVerdict = "approve" | "revise" | "escalate";
|
|
295
|
-
|
|
296
|
-
export interface SupervisorResult {
|
|
297
|
-
verdict: SupervisorVerdict;
|
|
298
|
-
reason: string;
|
|
299
|
-
feedback?: string;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
276
|
// ============================================================================
|
|
303
277
|
// Planner
|
|
304
278
|
// ============================================================================
|