pi-squad 0.7.2 → 0.14.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 +200 -318
- package/package.json +15 -2
- package/src/advisor.ts +145 -0
- package/src/agent-pool.ts +39 -15
- package/src/agents/_defaults/architect.json +8 -1
- package/src/agents/_defaults/backend.json +9 -1
- package/src/agents/_defaults/debugger.json +8 -1
- package/src/agents/_defaults/devops.json +9 -1
- package/src/agents/_defaults/docs.json +8 -1
- package/src/agents/_defaults/frontend.json +10 -1
- package/src/agents/_defaults/fullstack.json +8 -1
- package/src/agents/_defaults/planner.json +7 -1
- package/src/agents/_defaults/qa.json +8 -1
- package/src/agents/_defaults/researcher.json +8 -1
- package/src/agents/_defaults/reviewer.json +10 -0
- package/src/agents/_defaults/security.json +8 -1
- package/src/index.ts +365 -76
- package/src/monitor.ts +20 -7
- package/src/panel/message-view.ts +2 -2
- package/src/panel/squad-panel.ts +3 -3
- package/src/panel/squad-widget.ts +3 -3
- package/src/panel/task-list.ts +2 -2
- package/src/plan-rules.ts +134 -0
- package/src/planner.ts +18 -14
- package/src/protocol.ts +10 -2
- package/src/scheduler.ts +172 -9
- package/src/skills/squad-code-review/SKILL.md +89 -0
- package/src/skills/squad-collaboration/SKILL.md +5 -1
- package/src/skills/squad-debugging/SKILL.md +61 -0
- package/src/skills/squad-protocol/SKILL.md +19 -0
- package/src/skills/squad-qa-testing/SKILL.md +19 -8
- package/src/skills/squad-supervisor/SKILL.md +85 -11
- package/src/store.ts +24 -0
- package/src/types.ts +48 -1
package/src/monitor.ts
CHANGED
|
@@ -11,16 +11,23 @@
|
|
|
11
11
|
|
|
12
12
|
import type { AgentPool } from "./agent-pool.js";
|
|
13
13
|
import type { HealthStatus } from "./types.js";
|
|
14
|
+
import { debug } from "./logger.js";
|
|
14
15
|
|
|
15
16
|
// ============================================================================
|
|
16
17
|
// Config
|
|
17
18
|
// ============================================================================
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
20
|
+
/** Env-overridable for testing (PI_SQUAD_IDLE_MS etc.) */
|
|
21
|
+
function envMs(name: string, fallback: number): number {
|
|
22
|
+
const v = Number(process.env[name]);
|
|
23
|
+
return Number.isFinite(v) && v > 0 ? v : fallback;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const IDLE_WARNING_MS = envMs("PI_SQUAD_IDLE_MS", 3 * 60 * 1000); // no output → warn
|
|
27
|
+
const STUCK_TIMEOUT_MS = envMs("PI_SQUAD_STUCK_MS", 5 * 60 * 1000); // no output → intervene
|
|
28
|
+
const HARD_CEILING_MS = envMs("PI_SQUAD_CEILING_MS", 30 * 60 * 1000); // total → force stop
|
|
22
29
|
const LOOP_THRESHOLD = 5; // same tool call 5x → looping
|
|
23
|
-
const POLL_INTERVAL_MS = 30 * 1000; // check
|
|
30
|
+
const POLL_INTERVAL_MS = envMs("PI_SQUAD_POLL_MS", 30 * 1000); // check interval
|
|
24
31
|
|
|
25
32
|
// ============================================================================
|
|
26
33
|
// Types
|
|
@@ -51,6 +58,8 @@ export class Monitor {
|
|
|
51
58
|
private warned = new Set<string>();
|
|
52
59
|
/** Track which agents have been steered for stuck (to avoid repeated steers) */
|
|
53
60
|
private stuckSteered = new Set<string>();
|
|
61
|
+
/** Track which tasks have been escalated (one escalation per stuck episode) */
|
|
62
|
+
private escalated = new Set<string>();
|
|
54
63
|
|
|
55
64
|
constructor(pool: AgentPool, squadId: string) {
|
|
56
65
|
this.pool = pool;
|
|
@@ -90,6 +99,7 @@ export class Monitor {
|
|
|
90
99
|
}
|
|
91
100
|
this.warned.clear();
|
|
92
101
|
this.stuckSteered.clear();
|
|
102
|
+
this.escalated.clear();
|
|
93
103
|
}
|
|
94
104
|
|
|
95
105
|
/** Check all running agents */
|
|
@@ -132,11 +142,13 @@ export class Monitor {
|
|
|
132
142
|
|
|
133
143
|
/** Take action based on health status */
|
|
134
144
|
private handleHealth(taskId: string, agentName: string, status: HealthStatus): void {
|
|
145
|
+
if (status !== "healthy") debug("squad-monitor", `${taskId} (${agentName}): ${status}`);
|
|
135
146
|
switch (status) {
|
|
136
147
|
case "healthy":
|
|
137
|
-
// Clear warning flags
|
|
148
|
+
// Clear warning flags — a new stuck episode escalates again
|
|
138
149
|
this.warned.delete(taskId);
|
|
139
150
|
this.stuckSteered.delete(taskId);
|
|
151
|
+
this.escalated.delete(taskId);
|
|
140
152
|
break;
|
|
141
153
|
|
|
142
154
|
case "idle_warning":
|
|
@@ -166,8 +178,9 @@ export class Monitor {
|
|
|
166
178
|
"Summarize what you've done and what's blocking you. " +
|
|
167
179
|
"If you can't proceed, state what you need.",
|
|
168
180
|
});
|
|
169
|
-
} else {
|
|
170
|
-
// Already steered once,
|
|
181
|
+
} else if (!this.escalated.has(taskId)) {
|
|
182
|
+
// Already steered once — escalate, but only once per stuck episode
|
|
183
|
+
this.escalated.add(taskId);
|
|
171
184
|
this.emit({
|
|
172
185
|
type: "escalate",
|
|
173
186
|
taskId,
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* TUI corruption from large message histories.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { Theme } from "@
|
|
8
|
-
import { truncateToWidth, visibleWidth } from "@
|
|
7
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
9
|
import type { TaskMessage } from "../types.js";
|
|
10
10
|
import * as store from "../store.js";
|
|
11
11
|
|
package/src/panel/squad-panel.ts
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
* - done() closes the overlay cleanly
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import type { Component, Focusable, TUI } from "@
|
|
13
|
-
import { matchesKey, visibleWidth, truncateToWidth } from "@
|
|
14
|
-
import type { Theme } from "@
|
|
12
|
+
import type { Component, Focusable, TUI } from "@earendil-works/pi-tui";
|
|
13
|
+
import { matchesKey, visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
|
|
14
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import type { PanelState, PanelView } from "../types.js";
|
|
16
16
|
import type { Scheduler } from "../scheduler.js";
|
|
17
17
|
import { TaskListView } from "./task-list.js";
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
* lines and calls setWidget() again.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { truncateToWidth, visibleWidth } from "@
|
|
14
|
-
import type { Component, TUI } from "@
|
|
15
|
-
import type { Theme } from "@
|
|
13
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
14
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
15
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import type { TaskStatus } from "../types.js";
|
|
17
17
|
import * as store from "../store.js";
|
|
18
18
|
|
package/src/panel/task-list.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* task-list.ts — Task tree view with status icons, live activity preview.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { Theme } from "@
|
|
6
|
-
import { truncateToWidth } from "@
|
|
5
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
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
9
|
import * as store from "../store.js";
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plan-rules.ts — Single source of truth for squad planning behavior.
|
|
3
|
+
*
|
|
4
|
+
* Used by BOTH planning paths so they behave identically:
|
|
5
|
+
* - The planner agent (planner.ts injects these into its prompt)
|
|
6
|
+
* - The main pi session (index.ts injects these into the squad tool
|
|
7
|
+
* description / hint, and validatePlan() enforces them at submission)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Shared prompt fragments
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
/** How to structure task descriptions (Goal/Context/Output/Boundaries/Verify) */
|
|
15
|
+
export const TASK_DESCRIPTION_GUIDE = `Structure each task description around these parts (include only the parts that help):
|
|
16
|
+
- **Goal**: the outcome, stated first. Describe the result, not step-by-step process — prescribe steps only when the process itself matters
|
|
17
|
+
- **Context**: the specific files, contracts, or dependency outputs the agent should read — name where to look, don't dump everything
|
|
18
|
+
- **Output**: the expected deliverable — files, format, level of detail
|
|
19
|
+
- **Boundaries**: what must stay unchanged (public APIs, schemas, config), what the agent must not do, and anything requiring escalation instead of guessing
|
|
20
|
+
- **Verify**: the exact command or check that proves the task is done (e.g. "npm test -- auth", "curl /api/health returns 200")`;
|
|
21
|
+
|
|
22
|
+
/** Structural rules for a good plan */
|
|
23
|
+
export const PLAN_STRUCTURE_RULES = `- Task IDs must be short kebab-case (e.g., "setup-db", "auth-middleware")
|
|
24
|
+
- Dependencies must reference task IDs from the same plan
|
|
25
|
+
- First task(s) should have empty depends: []
|
|
26
|
+
- When tasks share an interface (API endpoints, database schema, data formats), create a design/contract task first that defines the contract, and make consuming tasks depend on it
|
|
27
|
+
- Include a final QA/verification task if there are user-facing changes
|
|
28
|
+
- Don't over-decompose — 3-7 tasks is usually right for most goals
|
|
29
|
+
- Scope tasks to required work only — no optional polish or nice-to-haves unless the goal asks for them`;
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// Plan validation (enforcement — applies to planner AND main-session plans)
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
export interface PlanTaskInput {
|
|
36
|
+
id: string;
|
|
37
|
+
title: string;
|
|
38
|
+
description: string;
|
|
39
|
+
agent: string;
|
|
40
|
+
depends: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PlanValidation {
|
|
44
|
+
/** Hard failures — squad must not start */
|
|
45
|
+
errors: string[];
|
|
46
|
+
/** Rule violations worth reporting back to the plan author */
|
|
47
|
+
warnings: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Does this task look like a QA/verification/review task? */
|
|
51
|
+
function isQaLikeTask(task: PlanTaskInput): boolean {
|
|
52
|
+
const hay = `${task.id} ${task.title} ${task.agent}`.toLowerCase();
|
|
53
|
+
return /\b(qa|test|tests|testing|verif\w*|review|audit)\b/.test(hay);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validate a plan's structure. Errors block squad creation;
|
|
58
|
+
* warnings are returned to the plan author for correction.
|
|
59
|
+
*/
|
|
60
|
+
export function validatePlan(tasks: PlanTaskInput[]): PlanValidation {
|
|
61
|
+
const errors: string[] = [];
|
|
62
|
+
const warnings: string[] = [];
|
|
63
|
+
|
|
64
|
+
// Duplicate IDs
|
|
65
|
+
const ids = new Set<string>();
|
|
66
|
+
for (const t of tasks) {
|
|
67
|
+
if (ids.has(t.id)) errors.push(`Duplicate task id: "${t.id}"`);
|
|
68
|
+
ids.add(t.id);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Unknown dependency references
|
|
72
|
+
for (const t of tasks) {
|
|
73
|
+
for (const dep of t.depends) {
|
|
74
|
+
if (!ids.has(dep)) {
|
|
75
|
+
errors.push(`Task "${t.id}" depends on unknown task "${dep}"`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Cycle detection (DFS, only over known deps)
|
|
81
|
+
const visiting = new Set<string>();
|
|
82
|
+
const visited = new Set<string>();
|
|
83
|
+
const byId = new Map(tasks.map((t) => [t.id, t]));
|
|
84
|
+
const inCycle: string[] = [];
|
|
85
|
+
const visit = (id: string): boolean => {
|
|
86
|
+
if (visited.has(id)) return false;
|
|
87
|
+
if (visiting.has(id)) return true;
|
|
88
|
+
visiting.add(id);
|
|
89
|
+
const task = byId.get(id);
|
|
90
|
+
if (task) {
|
|
91
|
+
for (const dep of task.depends) {
|
|
92
|
+
if (byId.has(dep) && visit(dep)) {
|
|
93
|
+
inCycle.push(id);
|
|
94
|
+
visiting.delete(id);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
visiting.delete(id);
|
|
100
|
+
visited.add(id);
|
|
101
|
+
return false;
|
|
102
|
+
};
|
|
103
|
+
for (const t of tasks) {
|
|
104
|
+
if (visit(t.id) && inCycle.length > 0) {
|
|
105
|
+
errors.push(`Dependency cycle involving: ${[...new Set(inCycle)].join(", ")}`);
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// No entry point (every task has deps) — only meaningful without errors
|
|
111
|
+
if (tasks.length > 0 && errors.length === 0 && !tasks.some((t) => t.depends.length === 0)) {
|
|
112
|
+
errors.push("No task has empty depends — nothing can start");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --- Warnings (planner rules) ---
|
|
116
|
+
|
|
117
|
+
if (tasks.length > 9) {
|
|
118
|
+
warnings.push(`${tasks.length} tasks — over-decomposed (3-7 is usually right). Consider merging related tasks.`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (tasks.length >= 3 && !tasks.some(isQaLikeTask)) {
|
|
122
|
+
warnings.push("No QA/verification task in the plan. Add a final task that tests the integrated result, or verify it yourself when the squad completes.");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const t of tasks) {
|
|
126
|
+
if (!t.description || t.description.trim().length === 0) {
|
|
127
|
+
warnings.push(`Task "${t.id}" has no description — the agent only gets the title.`);
|
|
128
|
+
} else if (!/verif|test|check|curl|run\b|npm |pnpm |cargo |pytest|tsc\b/i.test(t.description)) {
|
|
129
|
+
warnings.push(`Task "${t.id}" description has no Verify criterion — the agent won't know how to prove it's done.`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { errors, warnings };
|
|
134
|
+
}
|
package/src/planner.ts
CHANGED
|
@@ -11,6 +11,7 @@ import * as os from "node:os";
|
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import type { AgentDef, PlannerOutput } from "./types.js";
|
|
13
13
|
import { loadAllAgentDefs, loadAgentDef } from "./store.js";
|
|
14
|
+
import { PLAN_STRUCTURE_RULES, TASK_DESCRIPTION_GUIDE } from "./plan-rules.js";
|
|
14
15
|
|
|
15
16
|
// ============================================================================
|
|
16
17
|
// Planner
|
|
@@ -21,6 +22,10 @@ export interface PlannerOptions {
|
|
|
21
22
|
cwd: string;
|
|
22
23
|
/** If provided, use this model for planning instead of the planner agent's default */
|
|
23
24
|
model?: string;
|
|
25
|
+
/** Fallback model when neither `model` nor the planner agent def specifies one (squad default policy) */
|
|
26
|
+
fallbackModel?: string;
|
|
27
|
+
/** Fallback thinking level when the planner agent def doesn't specify one (squad default policy) */
|
|
28
|
+
fallbackThinking?: string;
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
/**
|
|
@@ -28,7 +33,7 @@ export interface PlannerOptions {
|
|
|
28
33
|
* Returns the parsed plan or throws on failure.
|
|
29
34
|
*/
|
|
30
35
|
export async function runPlanner(options: PlannerOptions): Promise<PlannerOutput> {
|
|
31
|
-
const { goal, cwd, model } = options;
|
|
36
|
+
const { goal, cwd, model, fallbackModel, fallbackThinking } = options;
|
|
32
37
|
|
|
33
38
|
const plannerDef = loadAgentDef("planner", cwd);
|
|
34
39
|
const allAgents = loadAllAgentDefs(cwd).filter((a) => a.name !== "planner" && !a.disabled);
|
|
@@ -62,7 +67,8 @@ export async function runPlanner(options: PlannerOptions): Promise<PlannerOutput
|
|
|
62
67
|
cwd,
|
|
63
68
|
prompt: `Read the prompt file at ${promptFile} and follow the instructions.`,
|
|
64
69
|
systemPromptFile: systemFile,
|
|
65
|
-
model: model || plannerDef?.model || undefined,
|
|
70
|
+
model: model || plannerDef?.model || fallbackModel || undefined,
|
|
71
|
+
thinking: plannerDef?.thinking || fallbackThinking || undefined,
|
|
66
72
|
});
|
|
67
73
|
|
|
68
74
|
const agentNames = new Set(allAgents.map((a) => a.name));
|
|
@@ -87,13 +93,15 @@ interface PiJsonOptions {
|
|
|
87
93
|
prompt: string;
|
|
88
94
|
systemPromptFile?: string;
|
|
89
95
|
model?: string;
|
|
96
|
+
thinking?: string;
|
|
90
97
|
}
|
|
91
98
|
|
|
92
99
|
async function runPiJson(options: PiJsonOptions): Promise<string> {
|
|
93
|
-
const { cwd, prompt, systemPromptFile, model } = options;
|
|
100
|
+
const { cwd, prompt, systemPromptFile, model, thinking } = options;
|
|
94
101
|
|
|
95
102
|
const args: string[] = ["--mode", "json", "-p", "--no-session"];
|
|
96
103
|
if (model) args.push("--model", model);
|
|
104
|
+
if (thinking) args.push("--thinking", thinking);
|
|
97
105
|
if (systemPromptFile) args.push("--append-system-prompt", systemPromptFile);
|
|
98
106
|
args.push(prompt);
|
|
99
107
|
|
|
@@ -244,13 +252,13 @@ Respond with a JSON object (and nothing else outside the JSON):
|
|
|
244
252
|
{
|
|
245
253
|
"agents": {
|
|
246
254
|
"agent-name": {},
|
|
247
|
-
"agent-name": { "model": "override-model-if-needed" }
|
|
255
|
+
"agent-name": { "model": "override-model-if-needed", "thinking": "high" }
|
|
248
256
|
},
|
|
249
257
|
"tasks": [
|
|
250
258
|
{
|
|
251
259
|
"id": "short-kebab-id",
|
|
252
260
|
"title": "Human-readable task title",
|
|
253
|
-
"description": "
|
|
261
|
+
"description": "Goal: the outcome to achieve. Context: relevant files/contracts to read. Output: expected deliverable and format. Boundaries: what must NOT change. Verify: command or check that proves it works.",
|
|
254
262
|
"agent": "agent-name",
|
|
255
263
|
"depends": ["id-of-dependency", "another-dependency"]
|
|
256
264
|
}
|
|
@@ -259,16 +267,12 @@ Respond with a JSON object (and nothing else outside the JSON):
|
|
|
259
267
|
\`\`\`
|
|
260
268
|
|
|
261
269
|
## Rules
|
|
262
|
-
|
|
270
|
+
${PLAN_STRUCTURE_RULES}
|
|
263
271
|
- Only reference agents that exist in the Available Agents list
|
|
264
|
-
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
- Don't over-decompose — 3-7 tasks is usually right for most goals
|
|
269
|
-
|
|
270
|
-
## Shared Contracts
|
|
271
|
-
- When tasks share an interface (e.g., API endpoints, database schema, data formats), create a design/architecture task first that defines the contract, and make consuming tasks depend on it
|
|
272
|
+
- Agent overrides are optional: "model" (a pi model id) and "thinking" (one of: off, minimal, low, medium, high, xhigh, max). Omit both to use defaults. Raise "thinking" only for tasks needing deep reasoning (architecture, security)
|
|
273
|
+
|
|
274
|
+
## Task Descriptions
|
|
275
|
+
${TASK_DESCRIPTION_GUIDE}
|
|
272
276
|
- If tasks run in parallel, they MUST depend on a shared spec task that defines the interface between them
|
|
273
277
|
- Frontend tasks should depend on backend API tasks — the frontend agent needs a running API to test against
|
|
274
278
|
- In each task description, specify the exact API paths, data schemas, and conventions that the agent should follow or create
|
package/src/protocol.ts
CHANGED
|
@@ -56,7 +56,13 @@ The squad system will detect this and route help.
|
|
|
56
56
|
- Read the dependency outputs below — don't redo completed work
|
|
57
57
|
- Check the modified files list — coordinate before editing shared files
|
|
58
58
|
- Ask for help if stuck — don't spin for more than a few minutes
|
|
59
|
-
- Verify your work before claiming done
|
|
59
|
+
- Verify your work before claiming done — report the commands you ran and their results
|
|
60
|
+
|
|
61
|
+
## Boundaries
|
|
62
|
+
- If required information is missing or ambiguous, ask (@mention or escalate) — flag gaps instead of guessing or inventing
|
|
63
|
+
- Keep changes minimal and within your task's scope — don't refactor unrelated code or add unrequested polish
|
|
64
|
+
- Respect your task's stated boundaries: keep public APIs, schemas, and configs unchanged unless the task says otherwise
|
|
65
|
+
- Never take externally visible actions (git push, deploy, publish, send messages/emails) unless your task explicitly instructs it — prepare, don't ship
|
|
60
66
|
`;
|
|
61
67
|
}
|
|
62
68
|
|
|
@@ -262,10 +268,12 @@ function buildReworkContext(task: Task, squadId: string): string {
|
|
|
262
268
|
|
|
263
269
|
lines.push("## Instructions");
|
|
264
270
|
lines.push("- Read the QA feedback carefully — fix ONLY the reported issues");
|
|
271
|
+
lines.push("- FIRST reproduce each reported issue (run the failing test or repro steps) — confirm you see the failure before changing code");
|
|
265
272
|
lines.push("- Do NOT rewrite everything from scratch");
|
|
266
273
|
lines.push("- Make targeted, minimal fixes");
|
|
267
274
|
lines.push("- Re-run the failing tests to verify your fixes");
|
|
268
|
-
lines.push("- Include test output as evidence in your completion message
|
|
275
|
+
lines.push("- Include test output as evidence in your completion message");
|
|
276
|
+
lines.push("- Follow the squad-debugging skill for the full repro-first rework discipline\n");
|
|
269
277
|
|
|
270
278
|
return lines.join("\n");
|
|
271
279
|
}
|
package/src/scheduler.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* - Detects squad completion
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
12
14
|
import type { AgentDef, Squad, SquadConfig, Task, TaskMessage, TaskStatus } from "./types.js";
|
|
13
15
|
import { AgentPool, type AgentEvent } from "./agent-pool.js";
|
|
14
16
|
import { Monitor } from "./monitor.js";
|
|
@@ -16,6 +18,7 @@ import { Router } from "./router.js";
|
|
|
16
18
|
import * as store from "./store.js";
|
|
17
19
|
import { debug, logError } from "./logger.js";
|
|
18
20
|
import { buildAgentSystemPrompt } from "./protocol.js";
|
|
21
|
+
import { buildAdvisorConsultText, formatAdvisorSteerMessage, adviceNeedsHuman, type AdvisorConsultInput } from "./advisor.js";
|
|
19
22
|
|
|
20
23
|
// ============================================================================
|
|
21
24
|
// Types
|
|
@@ -44,6 +47,16 @@ export interface SchedulerEvent {
|
|
|
44
47
|
|
|
45
48
|
export type SchedulerEventListener = (event: SchedulerEvent) => void;
|
|
46
49
|
|
|
50
|
+
/** Host-session capabilities passed in by the extension (index.ts) */
|
|
51
|
+
export interface SchedulerSpawnContext {
|
|
52
|
+
/** Resolve a model string (or null = default model) to its context window in tokens */
|
|
53
|
+
resolveContextWindow?: (model: string | null) => number | undefined;
|
|
54
|
+
/** Resolve the squad default model/thinking policy (settings.json + main session state) */
|
|
55
|
+
getDefaultModelThinking?: () => { model?: string; thinking?: string };
|
|
56
|
+
/** Consult the advisor model with a curated digest. Returns advice text, or null when disabled/unavailable. */
|
|
57
|
+
consultAdvisor?: (input: AdvisorConsultInput) => Promise<string | null>;
|
|
58
|
+
}
|
|
59
|
+
|
|
47
60
|
// ============================================================================
|
|
48
61
|
// Scheduler
|
|
49
62
|
// ============================================================================
|
|
@@ -55,6 +68,7 @@ export class Scheduler {
|
|
|
55
68
|
private router: Router;
|
|
56
69
|
private listeners: SchedulerEventListener[] = [];
|
|
57
70
|
private skillPaths: string[] = [];
|
|
71
|
+
private spawnContext?: SchedulerSpawnContext;
|
|
58
72
|
private running = false;
|
|
59
73
|
/** Track spawn retries to allow one retry per task */
|
|
60
74
|
private spawnRetries = new Set<string>();
|
|
@@ -64,9 +78,10 @@ export class Scheduler {
|
|
|
64
78
|
return store.loadSquad(this.squadId)?.cwd;
|
|
65
79
|
}
|
|
66
80
|
|
|
67
|
-
constructor(squadId: string, skillPaths: string[]) {
|
|
81
|
+
constructor(squadId: string, skillPaths: string[], spawnContext?: SchedulerSpawnContext) {
|
|
68
82
|
this.squadId = squadId;
|
|
69
83
|
this.skillPaths = skillPaths;
|
|
84
|
+
this.spawnContext = spawnContext;
|
|
70
85
|
this.pool = new AgentPool();
|
|
71
86
|
this.monitor = new Monitor(this.pool, squadId);
|
|
72
87
|
this.router = new Router(this.pool, squadId);
|
|
@@ -81,11 +96,16 @@ export class Scheduler {
|
|
|
81
96
|
} else if (action.type === "abort") {
|
|
82
97
|
this.handleTaskFailed(action.taskId, action.reason);
|
|
83
98
|
} else if (action.type === "escalate") {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
99
|
+
// Advisor-first: try a strong-model rescue before interrupting the human
|
|
100
|
+
void this.tryAdvisorRescue(action.taskId, action.agentName, action.reason).then((rescued) => {
|
|
101
|
+
if (rescued) return;
|
|
102
|
+
this.emit({
|
|
103
|
+
type: "escalation",
|
|
104
|
+
squadId: this.squadId,
|
|
105
|
+
taskId: action.taskId,
|
|
106
|
+
agentName: action.agentName,
|
|
107
|
+
message: action.reason,
|
|
108
|
+
});
|
|
89
109
|
});
|
|
90
110
|
}
|
|
91
111
|
});
|
|
@@ -248,11 +268,25 @@ export class Scheduler {
|
|
|
248
268
|
return;
|
|
249
269
|
}
|
|
250
270
|
|
|
251
|
-
// Apply squad-level model
|
|
271
|
+
// Apply squad-level model/thinking overrides
|
|
252
272
|
const squadAgentEntry = squad.agents[task.agent];
|
|
253
273
|
if (squadAgentEntry?.model) {
|
|
254
274
|
agentDef.model = squadAgentEntry.model;
|
|
255
275
|
}
|
|
276
|
+
if (squadAgentEntry?.thinking) {
|
|
277
|
+
agentDef.thinking = squadAgentEntry.thinking;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Apply squad defaults for anything still unset (policy resolved by the
|
|
281
|
+
// extension host: "main" = main session's model/thinking, "pi-default" =
|
|
282
|
+
// leave unset, or an explicit value from ~/.pi/squad/settings.json)
|
|
283
|
+
const defaults = this.spawnContext?.getDefaultModelThinking?.();
|
|
284
|
+
if (!agentDef.model && defaults?.model) {
|
|
285
|
+
agentDef.model = defaults.model;
|
|
286
|
+
}
|
|
287
|
+
if (!agentDef.thinking && defaults?.thinking) {
|
|
288
|
+
agentDef.thinking = defaults.thinking;
|
|
289
|
+
}
|
|
256
290
|
|
|
257
291
|
// Build modified files map from all running agents
|
|
258
292
|
const modifiedFiles: Record<string, string[]> = {};
|
|
@@ -285,6 +319,9 @@ export class Scheduler {
|
|
|
285
319
|
agentName: task.agent,
|
|
286
320
|
});
|
|
287
321
|
|
|
322
|
+
// Decide whether to fork the main session for context inheritance
|
|
323
|
+
const forkSessionFile = this.resolveForkSession(task, squad, agentDef);
|
|
324
|
+
|
|
288
325
|
try {
|
|
289
326
|
await this.pool.spawn({
|
|
290
327
|
taskId: task.id,
|
|
@@ -299,6 +336,14 @@ export class Scheduler {
|
|
|
299
336
|
},
|
|
300
337
|
cwd: squad.cwd,
|
|
301
338
|
skillPaths: this.skillPaths,
|
|
339
|
+
...(forkSessionFile
|
|
340
|
+
? {
|
|
341
|
+
forkSession: {
|
|
342
|
+
file: forkSessionFile,
|
|
343
|
+
sessionDir: path.join(store.getSquadDir(this.squadId), "sessions"),
|
|
344
|
+
},
|
|
345
|
+
}
|
|
346
|
+
: {}),
|
|
302
347
|
});
|
|
303
348
|
} catch (error) {
|
|
304
349
|
this.handleTaskFailed(task.id, (error as Error).message);
|
|
@@ -307,6 +352,123 @@ export class Scheduler {
|
|
|
307
352
|
this.updateContext();
|
|
308
353
|
}
|
|
309
354
|
|
|
355
|
+
/** Advisor consultations per task (advisor-first escalation) */
|
|
356
|
+
private advisorAttempts = new Map<string, number>();
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Consult the advisor for a stuck agent and steer it with the advice.
|
|
360
|
+
* Returns true when the agent was steered (escalation suppressed).
|
|
361
|
+
* Returns false when the advisor is disabled, exhausted, unavailable,
|
|
362
|
+
* failed, or explicitly said the problem needs human input.
|
|
363
|
+
*/
|
|
364
|
+
private async tryAdvisorRescue(taskId: string, agentName: string | undefined, reason: string): Promise<boolean> {
|
|
365
|
+
try {
|
|
366
|
+
const consult = this.spawnContext?.consultAdvisor;
|
|
367
|
+
if (!consult) return false;
|
|
368
|
+
|
|
369
|
+
const settings = store.loadSquadSettings();
|
|
370
|
+
if (!settings.advisor.enabled) return false;
|
|
371
|
+
|
|
372
|
+
const attempts = this.advisorAttempts.get(taskId) || 0;
|
|
373
|
+
if (attempts >= settings.advisor.maxCallsPerTask) {
|
|
374
|
+
debug("squad-advisor", `${taskId}: advisor exhausted (${attempts}/${settings.advisor.maxCallsPerTask}), escalating`);
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
if (!this.pool.isRunning(taskId)) return false;
|
|
378
|
+
|
|
379
|
+
const task = store.loadTask(this.squadId, taskId);
|
|
380
|
+
const squad = store.loadSquad(this.squadId);
|
|
381
|
+
if (!task || !squad) return false;
|
|
382
|
+
const agentDef = store.loadAgentDef(task.agent, squad.cwd);
|
|
383
|
+
const activity = this.pool.getActivity(taskId);
|
|
384
|
+
|
|
385
|
+
this.advisorAttempts.set(taskId, attempts + 1);
|
|
386
|
+
|
|
387
|
+
const input: AdvisorConsultInput = {
|
|
388
|
+
taskId,
|
|
389
|
+
taskTitle: task.title,
|
|
390
|
+
taskDescription: task.description,
|
|
391
|
+
agentName: task.agent,
|
|
392
|
+
agentRole: agentDef?.role || task.agent,
|
|
393
|
+
reason,
|
|
394
|
+
recentMessages: store.loadMessages(this.squadId, taskId).slice(-12).map((m) => ({ from: m.from, type: m.type, text: m.text })),
|
|
395
|
+
recentToolCalls: activity ? [...activity.recentToolCalls] : [],
|
|
396
|
+
turnCount: activity?.turnCount || 0,
|
|
397
|
+
elapsedMinutes: activity ? (Date.now() - activity.startedAt) / 60000 : 0,
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const advice = await consult(input);
|
|
401
|
+
if (!advice) return false;
|
|
402
|
+
|
|
403
|
+
store.appendMessage(this.squadId, taskId, {
|
|
404
|
+
ts: store.now(),
|
|
405
|
+
from: "advisor",
|
|
406
|
+
type: "message",
|
|
407
|
+
text: advice,
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// Advisor says a human decision is required — escalate with the advice attached
|
|
411
|
+
if (adviceNeedsHuman(advice)) {
|
|
412
|
+
this.emit({
|
|
413
|
+
type: "escalation",
|
|
414
|
+
squadId: this.squadId,
|
|
415
|
+
taskId,
|
|
416
|
+
agentName,
|
|
417
|
+
message: `${reason}\n\nAdvisor assessment:\n${advice.slice(0, 800)}`,
|
|
418
|
+
});
|
|
419
|
+
return true; // escalation already emitted with richer context
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const delivered = await this.pool.steer(taskId, formatAdvisorSteerMessage(advice, reason));
|
|
423
|
+
debug("squad-advisor", `${taskId}: advisor steered agent (attempt ${attempts + 1}, delivered=${delivered})`);
|
|
424
|
+
return delivered;
|
|
425
|
+
} catch (error) {
|
|
426
|
+
logError("squad-advisor", `rescue failed for ${taskId}: ${(error as Error).message}`);
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Decide whether this task's agent should be spawned as a fork of the main
|
|
433
|
+
* pi session (context inheritance). Guards against blowing the child model's
|
|
434
|
+
* context window: forks only when the estimated session tokens fit within
|
|
435
|
+
* 50% of the agent model's context window.
|
|
436
|
+
*/
|
|
437
|
+
private resolveForkSession(task: Task, squad: Squad, agentDef: AgentDef): string | undefined {
|
|
438
|
+
if (!task.inheritContext) return undefined;
|
|
439
|
+
|
|
440
|
+
const skip = (reason: string): undefined => {
|
|
441
|
+
logError("squad-scheduler", `inheritContext skipped for ${task.id}: ${reason}`);
|
|
442
|
+
store.appendMessage(this.squadId, task.id, {
|
|
443
|
+
ts: store.now(),
|
|
444
|
+
from: "system",
|
|
445
|
+
type: "status",
|
|
446
|
+
text: `Context inheritance skipped: ${reason}. Agent starts with standard squad context only.`,
|
|
447
|
+
});
|
|
448
|
+
return undefined;
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const sessionFile = squad.sessionFile;
|
|
452
|
+
if (!sessionFile) return skip("main session has no session file (ephemeral --no-session run)");
|
|
453
|
+
if (!fs.existsSync(sessionFile)) return skip(`session file not found: ${sessionFile}`);
|
|
454
|
+
|
|
455
|
+
// Rough token estimate: JSONL bytes / 4. Overestimates (JSON overhead), which is safe.
|
|
456
|
+
const estTokens = Math.ceil(fs.statSync(sessionFile).size / 4);
|
|
457
|
+
|
|
458
|
+
const window = this.spawnContext?.resolveContextWindow?.(agentDef.model ?? null);
|
|
459
|
+
if (!window) {
|
|
460
|
+
return skip(`cannot determine context window for model "${agentDef.model || "(default)"}"`);
|
|
461
|
+
}
|
|
462
|
+
if (estTokens > window * 0.5) {
|
|
463
|
+
return skip(
|
|
464
|
+
`estimated session context (~${Math.round(estTokens / 1000)}k tokens) exceeds 50% of ${agentDef.model || "default model"}'s ${Math.round(window / 1000)}k window — restate key context in the task description instead`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
debug("squad-scheduler", `inheritContext: forking ${sessionFile} for ${task.id} (~${Math.round(estTokens / 1000)}k tokens, window ${Math.round(window / 1000)}k)`);
|
|
469
|
+
return sessionFile;
|
|
470
|
+
}
|
|
471
|
+
|
|
310
472
|
// =========================================================================
|
|
311
473
|
// Event Handlers
|
|
312
474
|
// =========================================================================
|
|
@@ -599,7 +761,7 @@ export class Scheduler {
|
|
|
599
761
|
*/
|
|
600
762
|
private checkForRework(task: Task, output: string): boolean {
|
|
601
763
|
// Only trigger rework for QA/test agent tasks
|
|
602
|
-
const qaAgents = ["qa", "tester", "security"];
|
|
764
|
+
const qaAgents = ["qa", "tester", "security", "reviewer"];
|
|
603
765
|
if (!qaAgents.includes(task.agent)) return false;
|
|
604
766
|
|
|
605
767
|
// Parse verdict from output
|
|
@@ -610,7 +772,7 @@ export class Scheduler {
|
|
|
610
772
|
const allTasks = store.loadAllTasks(this.squadId);
|
|
611
773
|
const implDeps = task.depends
|
|
612
774
|
.map((depId) => allTasks.find((t) => t.id === depId))
|
|
613
|
-
.filter((t): t is Task => t !==
|
|
775
|
+
.filter((t): t is Task => t !== undefined && !qaAgents.includes(t.agent));
|
|
614
776
|
|
|
615
777
|
if (implDeps.length === 0) return false;
|
|
616
778
|
|
|
@@ -649,6 +811,7 @@ export class Scheduler {
|
|
|
649
811
|
agent: implTask.agent,
|
|
650
812
|
status: "pending",
|
|
651
813
|
depends: [],
|
|
814
|
+
...(implTask.inheritContext ? { inheritContext: true } : {}),
|
|
652
815
|
created: store.now(),
|
|
653
816
|
started: null,
|
|
654
817
|
completed: null,
|