pi-squad 0.1.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/src/planner.ts ADDED
@@ -0,0 +1,275 @@
1
+ /**
2
+ * planner.ts — One-shot planner agent for goal decomposition.
3
+ *
4
+ * Spawns a pi process in json mode to analyze the codebase
5
+ * and produce a task breakdown with dependencies.
6
+ */
7
+
8
+ import { spawn } from "node:child_process";
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+ import type { AgentDef, PlannerOutput } from "./types.js";
13
+ import { loadAllAgentDefs, loadAgentDef } from "./store.js";
14
+
15
+ // ============================================================================
16
+ // Planner
17
+ // ============================================================================
18
+
19
+ export interface PlannerOptions {
20
+ goal: string;
21
+ cwd: string;
22
+ /** If provided, use this model for planning instead of the planner agent's default */
23
+ model?: string;
24
+ }
25
+
26
+ /**
27
+ * Run the planner agent to produce a task breakdown.
28
+ * Returns the parsed plan or throws on failure.
29
+ */
30
+ export async function runPlanner(options: PlannerOptions): Promise<PlannerOutput> {
31
+ const { goal, cwd, model } = options;
32
+
33
+ const plannerDef = loadAgentDef("planner", cwd);
34
+ const allAgents = loadAllAgentDefs(cwd).filter((a) => a.name !== "planner");
35
+
36
+ const agentList = allAgents
37
+ .map((a) => `- **${a.name}** (${a.role}): ${a.description} [tags: ${a.tags.join(", ")}]`)
38
+ .join("\n");
39
+
40
+ const prompt = buildPlannerPrompt(goal, agentList);
41
+
42
+ // Write prompt to temp file
43
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-squad-planner-"));
44
+ const promptFile = path.join(tmpDir, "planner-prompt.md");
45
+ const systemFile = path.join(tmpDir, "planner-system.md");
46
+
47
+ fs.writeFileSync(promptFile, prompt, "utf-8");
48
+
49
+ const systemPrompt = plannerDef?.prompt || DEFAULT_PLANNER_SYSTEM;
50
+ fs.writeFileSync(systemFile, systemPrompt, "utf-8");
51
+
52
+ try {
53
+ const output = await runPiJson({
54
+ cwd,
55
+ prompt: `Read the prompt file at ${promptFile} and follow the instructions.`,
56
+ systemPromptFile: systemFile,
57
+ model: model || plannerDef?.model || undefined,
58
+ });
59
+
60
+ return parsePlannerOutput(output);
61
+ } finally {
62
+ try {
63
+ fs.unlinkSync(promptFile);
64
+ fs.unlinkSync(systemFile);
65
+ fs.rmdirSync(tmpDir);
66
+ } catch {
67
+ /* ignore */
68
+ }
69
+ }
70
+ }
71
+
72
+ // ============================================================================
73
+ // Pi JSON Mode Execution
74
+ // ============================================================================
75
+
76
+ interface PiJsonOptions {
77
+ cwd: string;
78
+ prompt: string;
79
+ systemPromptFile?: string;
80
+ model?: string;
81
+ }
82
+
83
+ async function runPiJson(options: PiJsonOptions): Promise<string> {
84
+ const { cwd, prompt, systemPromptFile, model } = options;
85
+
86
+ const args: string[] = ["--mode", "json", "-p", "--no-session"];
87
+ if (model) args.push("--model", model);
88
+ if (systemPromptFile) args.push("--append-system-prompt", systemPromptFile);
89
+ args.push(prompt);
90
+
91
+ const invocation = getPiInvocation(args);
92
+
93
+ return new Promise<string>((resolve, reject) => {
94
+ const proc = spawn(invocation.command, invocation.args, {
95
+ cwd,
96
+ stdio: ["ignore", "pipe", "pipe"],
97
+ });
98
+
99
+ let stdout = "";
100
+ let stderr = "";
101
+ const messages: any[] = [];
102
+
103
+ let buffer = "";
104
+ proc.stdout.on("data", (data) => {
105
+ buffer += data.toString();
106
+ const lines = buffer.split("\n");
107
+ buffer = lines.pop() || "";
108
+ for (const line of lines) {
109
+ if (!line.trim()) continue;
110
+ try {
111
+ const event = JSON.parse(line);
112
+ if (event.type === "message_end" && event.message?.role === "assistant") {
113
+ messages.push(event.message);
114
+ }
115
+ } catch {
116
+ /* skip */
117
+ }
118
+ }
119
+ });
120
+
121
+ proc.stderr.on("data", (data) => {
122
+ stderr += data.toString();
123
+ });
124
+
125
+ proc.on("close", (code) => {
126
+ if (buffer.trim()) {
127
+ try {
128
+ const event = JSON.parse(buffer);
129
+ if (event.type === "message_end" && event.message?.role === "assistant") {
130
+ messages.push(event.message);
131
+ }
132
+ } catch {
133
+ /* skip */
134
+ }
135
+ }
136
+
137
+ if (code !== 0 && messages.length === 0) {
138
+ reject(new Error(`Planner failed (code ${code}): ${stderr.slice(0, 500)}`));
139
+ return;
140
+ }
141
+
142
+ // Extract text from last assistant message
143
+ const lastMsg = messages[messages.length - 1];
144
+ if (!lastMsg) {
145
+ reject(new Error("Planner produced no output"));
146
+ return;
147
+ }
148
+
149
+ const text = lastMsg.content
150
+ ?.filter((p: any) => p.type === "text")
151
+ .map((p: any) => p.text)
152
+ .join("\n");
153
+
154
+ resolve(text || "");
155
+ });
156
+
157
+ proc.on("error", (err) => {
158
+ reject(new Error(`Failed to spawn planner: ${err.message}`));
159
+ });
160
+ });
161
+ }
162
+
163
+ // ============================================================================
164
+ // Output Parsing
165
+ // ============================================================================
166
+
167
+ function parsePlannerOutput(text: string): PlannerOutput {
168
+ // Try to extract JSON from the text (might be wrapped in markdown code blocks)
169
+ const jsonMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/) || text.match(/(\{[\s\S]*\})/);
170
+
171
+ if (!jsonMatch) {
172
+ throw new Error("Planner output does not contain valid JSON");
173
+ }
174
+
175
+ try {
176
+ const parsed = JSON.parse(jsonMatch[1]);
177
+
178
+ // Validate structure
179
+ if (!parsed.tasks || !Array.isArray(parsed.tasks)) {
180
+ throw new Error("Planner output missing 'tasks' array");
181
+ }
182
+
183
+ for (const task of parsed.tasks) {
184
+ if (!task.id || !task.title || !task.agent) {
185
+ throw new Error(`Invalid task in planner output: ${JSON.stringify(task)}`);
186
+ }
187
+ if (!task.depends) task.depends = [];
188
+ if (!task.description) task.description = "";
189
+ }
190
+
191
+ if (!parsed.agents) parsed.agents = {};
192
+
193
+ return parsed as PlannerOutput;
194
+ } catch (error) {
195
+ if (error instanceof SyntaxError) {
196
+ throw new Error(`Planner output is not valid JSON: ${text.slice(0, 200)}`);
197
+ }
198
+ throw error;
199
+ }
200
+ }
201
+
202
+ // ============================================================================
203
+ // Prompt
204
+ // ============================================================================
205
+
206
+ function buildPlannerPrompt(goal: string, agentList: string): string {
207
+ return `# Task: Create a Squad Plan
208
+
209
+ ## Goal
210
+ ${goal}
211
+
212
+ ## Available Agents
213
+ ${agentList}
214
+
215
+ ## Instructions
216
+
217
+ 1. Read the codebase to understand the project structure, tech stack, and existing code
218
+ 2. Break the goal into concrete, implementable tasks
219
+ 3. Assign each task to the most appropriate agent based on their specialty
220
+ 4. Define dependencies between tasks (which tasks must complete before others can start)
221
+ 5. Keep the plan minimal — don't create tasks for things that aren't needed
222
+
223
+ ## Output Format
224
+
225
+ Respond with a JSON object (and nothing else outside the JSON):
226
+
227
+ \`\`\`json
228
+ {
229
+ "agents": {
230
+ "agent-name": {},
231
+ "agent-name": { "model": "override-model-if-needed" }
232
+ },
233
+ "tasks": [
234
+ {
235
+ "id": "short-kebab-id",
236
+ "title": "Human-readable task title",
237
+ "description": "Detailed description of what to implement",
238
+ "agent": "agent-name",
239
+ "depends": ["id-of-dependency", "another-dependency"]
240
+ }
241
+ ]
242
+ }
243
+ \`\`\`
244
+
245
+ ## Rules
246
+ - Task IDs must be short kebab-case (e.g., "setup-db", "auth-middleware")
247
+ - Only reference agents that exist in the Available Agents list
248
+ - Dependencies must reference task IDs from your own plan
249
+ - First task(s) should have empty depends: []
250
+ - Include a final QA/verification task if there are user-facing changes
251
+ - Keep descriptions actionable — agent should know exactly what to build
252
+ - Don't over-decompose — 3-7 tasks is usually right for most goals
253
+ `;
254
+ }
255
+
256
+ const DEFAULT_PLANNER_SYSTEM = `You are a project planner. You analyze codebases and break goals into concrete tasks for a team of specialized agents. You read the project structure, understand the tech stack, and create minimal but complete task breakdowns. Always output valid JSON.`;
257
+
258
+ // ============================================================================
259
+ // Helpers
260
+ // ============================================================================
261
+
262
+ function getPiInvocation(args: string[]): { command: string; args: string[] } {
263
+ const currentScript = process.argv[1];
264
+ if (currentScript && fs.existsSync(currentScript)) {
265
+ return { command: process.execPath, args: [currentScript, ...args] };
266
+ }
267
+
268
+ const execName = path.basename(process.execPath).toLowerCase();
269
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
270
+ if (!isGenericRuntime) {
271
+ return { command: process.execPath, args };
272
+ }
273
+
274
+ return { command: "pi", args };
275
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * protocol.ts — System prompt builder for squad agents.
3
+ *
4
+ * Assembles the full context that gets injected into each agent via --append-system-prompt:
5
+ * 1. Squad protocol (communication rules)
6
+ * 2. Agent identity (role, custom prompt)
7
+ * 3. Chain context (completed dependency outputs)
8
+ * 4. Sibling awareness (parallel tasks, file map)
9
+ * 5. Knowledge entries (decisions, conventions, findings)
10
+ * 6. Queued messages (received while agent wasn't running)
11
+ */
12
+
13
+ import type { AgentDef, KnowledgeEntry, Squad, Task, TaskMessage } from "./types.js";
14
+ import { loadAllKnowledge, loadAllTasks, loadMessages } from "./store.js";
15
+
16
+ // ============================================================================
17
+ // Squad Protocol (injected into every agent)
18
+ // ============================================================================
19
+
20
+ function buildSquadProtocol(agentName: string, agentDef: AgentDef, squad: Squad): string {
21
+ const agentList = Object.entries(squad.agents)
22
+ .map(([name]) => `- ${name}`)
23
+ .join("\n");
24
+
25
+ return `# Squad Protocol
26
+
27
+ You are agent "${agentName}" (${agentDef.role}) in a multi-agent squad.
28
+
29
+ **Goal:** ${squad.goal}
30
+
31
+ ## Team
32
+ ${agentList}
33
+
34
+ ## Communication
35
+
36
+ ### Talking to other agents
37
+ Write @agentname followed by your message in your regular output.
38
+ The squad system parses @mentions and routes them to the target agent.
39
+ - "@frontend what token format do you need?"
40
+ - "@backend the schema needs a role column"
41
+
42
+ ### Receiving messages
43
+ Messages from other agents and the human will arrive as interruptions
44
+ injected into your conversation. Read them, incorporate the info, and continue.
45
+
46
+ ### Completion
47
+ When you finish your task, clearly state your final output in your last message.
48
+ This output gets passed to dependent tasks as context.
49
+
50
+ ### Blocking
51
+ If you cannot proceed, clearly explain what you need and from whom.
52
+ The squad system will detect this and route help.
53
+
54
+ ## Rules
55
+ - Stay focused on YOUR task — don't do work assigned to other agents
56
+ - Read the dependency outputs below — don't redo completed work
57
+ - Check the modified files list — coordinate before editing shared files
58
+ - Ask for help if stuck — don't spin for more than a few minutes
59
+ - Verify your work before claiming done
60
+ `;
61
+ }
62
+
63
+ // ============================================================================
64
+ // Agent Identity
65
+ // ============================================================================
66
+
67
+ function buildAgentIdentity(agentDef: AgentDef): string {
68
+ if (!agentDef.prompt) return "";
69
+ return `# Agent Identity: ${agentDef.name}
70
+
71
+ Role: ${agentDef.role}
72
+ ${agentDef.description}
73
+
74
+ ${agentDef.prompt}
75
+ `;
76
+ }
77
+
78
+ // ============================================================================
79
+ // Chain Context (completed dependency outputs)
80
+ // ============================================================================
81
+
82
+ function buildChainContext(task: Task, allTasks: Task[], squadId: string): string {
83
+ if (task.depends.length === 0) return "";
84
+
85
+ const sections: string[] = [];
86
+
87
+ for (const depId of task.depends) {
88
+ const dep = allTasks.find((t) => t.id === depId);
89
+ if (!dep || dep.status !== "done") continue;
90
+
91
+ let section = `## ${dep.id} (done by ${dep.agent})\n**${dep.title}**\n`;
92
+ if (dep.output) {
93
+ section += `\nOutput:\n${dep.output}\n`;
94
+ } else {
95
+ // Fall back to last messages if no explicit output
96
+ const messages = loadMessages(squadId, dep.id);
97
+ const lastText = messages
98
+ .filter((m) => m.from === dep.agent && (m.type === "text" || m.type === "done"))
99
+ .slice(-3)
100
+ .map((m) => m.text)
101
+ .join("\n");
102
+ if (lastText) {
103
+ section += `\nLast messages:\n${lastText}\n`;
104
+ }
105
+ }
106
+ sections.push(section);
107
+ }
108
+
109
+ if (sections.length === 0) return "";
110
+
111
+ return `# Completed Dependencies
112
+
113
+ ${sections.join("\n---\n\n")}
114
+ `;
115
+ }
116
+
117
+ // ============================================================================
118
+ // Sibling Awareness
119
+ // ============================================================================
120
+
121
+ function buildSiblingAwareness(
122
+ task: Task,
123
+ allTasks: Task[],
124
+ modifiedFiles: Record<string, string[]>,
125
+ ): string {
126
+ const siblings = allTasks.filter(
127
+ (t) => t.id !== task.id && (t.status === "in_progress" || t.status === "blocked" || t.status === "pending"),
128
+ );
129
+
130
+ if (siblings.length === 0 && Object.keys(modifiedFiles).length === 0) return "";
131
+
132
+ const lines: string[] = ["# Sibling Tasks\n"];
133
+
134
+ if (siblings.length > 0) {
135
+ lines.push("Other tasks in this squad:\n");
136
+ for (const sib of siblings) {
137
+ let line = `- **${sib.id}** [${sib.status}] — ${sib.agent} — ${sib.title}`;
138
+ if (sib.status === "blocked" && sib.depends.some((d) => d === task.id)) {
139
+ line += " ⚠️ WAITING ON YOUR TASK";
140
+ }
141
+ lines.push(line);
142
+ }
143
+ }
144
+
145
+ // File ownership map
146
+ const fileEntries = Object.entries(modifiedFiles).filter(([agent]) => agent !== task.agent);
147
+ if (fileEntries.length > 0) {
148
+ lines.push("\n## Files Modified by Other Agents\n");
149
+ for (const [agent, files] of fileEntries) {
150
+ if (files.length > 0) {
151
+ lines.push(`**${agent}:**`);
152
+ for (const f of files.slice(0, 10)) {
153
+ lines.push(` - ${f}`);
154
+ }
155
+ if (files.length > 10) {
156
+ lines.push(` - ...and ${files.length - 10} more`);
157
+ }
158
+ }
159
+ }
160
+ lines.push(
161
+ "\n⚠️ Coordinate with the owning agent before editing files listed above.",
162
+ );
163
+ }
164
+
165
+ return lines.join("\n") + "\n";
166
+ }
167
+
168
+ // ============================================================================
169
+ // Knowledge
170
+ // ============================================================================
171
+
172
+ function buildKnowledgeSection(squadId: string): string {
173
+ const entries = loadAllKnowledge(squadId);
174
+ if (entries.length === 0) return "";
175
+
176
+ const decisions = entries.filter((e) => e.type === "decision");
177
+ const conventions = entries.filter((e) => e.type === "convention");
178
+ const findings = entries.filter((e) => e.type === "finding");
179
+
180
+ const lines: string[] = ["# Squad Knowledge\n"];
181
+
182
+ if (decisions.length > 0) {
183
+ lines.push("## Decisions");
184
+ for (const d of decisions.slice(-10)) {
185
+ lines.push(`- ${d.text} (${d.from})`);
186
+ }
187
+ lines.push("");
188
+ }
189
+
190
+ if (conventions.length > 0) {
191
+ lines.push("## Project Conventions");
192
+ for (const c of conventions.slice(-10)) {
193
+ lines.push(`- ${c.text} (${c.from})`);
194
+ }
195
+ lines.push("");
196
+ }
197
+
198
+ if (findings.length > 0) {
199
+ lines.push("## Findings");
200
+ for (const f of findings.slice(-10)) {
201
+ lines.push(`- ${f.text} (${f.from})`);
202
+ }
203
+ lines.push("");
204
+ }
205
+
206
+ return lines.join("\n");
207
+ }
208
+
209
+ // ============================================================================
210
+ // Queued Messages
211
+ // ============================================================================
212
+
213
+ function buildQueuedMessages(messages: TaskMessage[]): string {
214
+ if (messages.length === 0) return "";
215
+
216
+ const lines = ["# Messages Received While You Were Offline\n"];
217
+ for (const msg of messages) {
218
+ lines.push(`[${msg.ts}] ${msg.from}: ${msg.text}`);
219
+ }
220
+ lines.push("\nPlease read and incorporate these before starting your work.\n");
221
+ return lines.join("\n");
222
+ }
223
+
224
+ // ============================================================================
225
+ // Task Description
226
+ // ============================================================================
227
+
228
+ function buildTaskSection(task: Task): string {
229
+ return `# Your Task
230
+
231
+ **${task.title}**
232
+
233
+ ${task.description || "(no additional description)"}
234
+ `;
235
+ }
236
+
237
+ // ============================================================================
238
+ // Full Prompt Assembly
239
+ // ============================================================================
240
+
241
+ export interface ProtocolBuildOptions {
242
+ squadId: string;
243
+ squad: Squad;
244
+ task: Task;
245
+ agentDef: AgentDef;
246
+ modifiedFiles: Record<string, string[]>;
247
+ queuedMessages: TaskMessage[];
248
+ }
249
+
250
+ export function buildAgentSystemPrompt(options: ProtocolBuildOptions): string {
251
+ const { squadId, squad, task, agentDef, modifiedFiles, queuedMessages } = options;
252
+ const allTasks = loadAllTasks(squadId);
253
+
254
+ const sections = [
255
+ buildSquadProtocol(task.agent, agentDef, squad),
256
+ buildAgentIdentity(agentDef),
257
+ buildTaskSection(task),
258
+ buildChainContext(task, allTasks, squadId),
259
+ buildSiblingAwareness(task, allTasks, modifiedFiles),
260
+ buildKnowledgeSection(squadId),
261
+ buildQueuedMessages(queuedMessages),
262
+ ].filter((s) => s.length > 0);
263
+
264
+ return sections.join("\n---\n\n");
265
+ }