pi-plans 0.2.0 → 0.3.1
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 +90 -26
- package/agents/ref-analyst.md +18 -0
- package/index.ts +121 -9
- package/package.json +16 -1
- package/references/pi-planning-workflow.md +21 -6
- package/references/state-and-config.md +52 -5
- package/scripts/validate.ts +5 -0
- package/skills/plan-with-refs/SKILL.md +3 -3
- package/src/code-graph/commands.ts +483 -0
- package/src/code-graph/discovery.ts +118 -0
- package/src/code-graph/git.ts +108 -0
- package/src/code-graph/identity.ts +59 -0
- package/src/code-graph/indexer.ts +281 -0
- package/src/code-graph/materialize.ts +166 -0
- package/src/code-graph/mode.ts +28 -0
- package/src/code-graph/mutations.ts +160 -0
- package/src/code-graph/parser.ts +51 -0
- package/src/code-graph/parsers/javascript.ts +35 -0
- package/src/code-graph/parsers/python.ts +160 -0
- package/src/code-graph/parsers/tree-sitter.ts +316 -0
- package/src/code-graph/paths.ts +85 -0
- package/src/code-graph/prompts.ts +18 -0
- package/src/code-graph/resolver.ts +69 -0
- package/src/code-graph/runtime.ts +158 -0
- package/src/code-graph/schema.ts +135 -0
- package/src/code-graph/screening.ts +82 -0
- package/src/code-graph/store.ts +278 -0
- package/src/code-graph/summary.ts +435 -0
- package/src/code-graph/types.ts +163 -0
- package/src/compaction.ts +1125 -371
- package/src/config-command.ts +361 -0
- package/src/exec.ts +508 -693
- package/src/guard.ts +14 -1
- package/src/refine-prompts.ts +109 -0
- package/src/refine-ui-helpers.ts +71 -18
- package/src/refine-ui-state.ts +88 -22
- package/src/refine-ui.ts +210 -102
- package/src/state.ts +36 -7
- package/src/subagent.ts +164 -61
- package/src/termination-prompt.ts +22 -0
- package/tests/analyze-refs.test.ts +265 -0
- package/tests/ask-choice.test.ts +264 -0
- package/tests/autocomplete.test.ts +6 -1
- package/tests/code-graph-apply-action.test.ts +173 -0
- package/tests/code-graph-apply.test.ts +185 -0
- package/tests/code-graph-commands.test.ts +211 -0
- package/tests/code-graph-db.test.ts +166 -0
- package/tests/code-graph-discovery.test.ts +38 -0
- package/tests/code-graph-git.test.ts +94 -0
- package/tests/code-graph-index.test.ts +175 -0
- package/tests/code-graph-loop.e2e.test.ts +159 -0
- package/tests/code-graph-mutations.test.ts +117 -0
- package/tests/code-graph-parser.test.ts +85 -0
- package/tests/code-graph-rollback.test.ts +100 -0
- package/tests/code-graph-summary-batching.test.ts +518 -0
- package/tests/code-graph-summary.test.ts +148 -0
- package/tests/compaction.test.ts +371 -57
- package/tests/config-command.test.ts +263 -0
- package/tests/exec.test.ts +808 -241
- package/tests/fixtures/code-graph/sample.js +36 -0
- package/tests/fixtures/code-graph/sample.py +20 -0
- package/tests/fixtures/code-graph/sample.ts +15 -0
- package/tests/graph-aware-file-tools.test.ts +411 -0
- package/tests/guard.test.ts +27 -1
- package/tests/plans.test.ts +10 -0
- package/tests/refine-prompts.test.ts +101 -2
- package/tests/refine-ui.test.ts +371 -72
- package/tests/state.test.ts +32 -0
- package/tests/subagent.test.ts +48 -20
- package/tools/analyze-refs.ts +263 -0
- package/tools/ask-choice.ts +159 -11
- package/tools/code-graph.ts +277 -0
- package/tools/graph-aware-file-tools.ts +392 -0
- package/tools/plans.ts +97 -2
- package/tools/refine.ts +61 -15
package/src/subagent.ts
CHANGED
|
@@ -9,16 +9,21 @@ import * as fs from "node:fs";
|
|
|
9
9
|
import * as os from "node:os";
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
|
|
12
|
+
export type SubagentTranscriptEntryType = "assistant-text" | "thinking" | "tool-call" | "tool-result";
|
|
13
|
+
|
|
12
14
|
export type SubagentProgressEvent =
|
|
13
15
|
| { type: "process"; phase: "started" | "exited"; code?: number }
|
|
14
|
-
| { type: "turn"; phase: "start" | "end" }
|
|
15
|
-
| { type: "message"; phase: "start" | "update" | "end"; role: string; text: string }
|
|
16
|
+
| { type: "turn"; phase: "start" | "end"; turnIndex?: number }
|
|
16
17
|
| {
|
|
17
|
-
type: "
|
|
18
|
+
type: "transcript";
|
|
18
19
|
phase: "start" | "update" | "end";
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
entryType: SubagentTranscriptEntryType;
|
|
21
|
+
key: string;
|
|
22
|
+
text: string;
|
|
23
|
+
update: "append" | "replace";
|
|
24
|
+
streaming: boolean;
|
|
25
|
+
toolCallId?: string;
|
|
26
|
+
toolName?: string;
|
|
22
27
|
isError?: boolean;
|
|
23
28
|
}
|
|
24
29
|
| { type: "stderr"; text: string };
|
|
@@ -72,12 +77,13 @@ export function getPiInvocation(args: string[]): { command: string; args: string
|
|
|
72
77
|
|
|
73
78
|
interface MessageLike {
|
|
74
79
|
role: string;
|
|
75
|
-
content?:
|
|
80
|
+
content?: unknown;
|
|
76
81
|
model?: string;
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
interface RawSubagentEvent {
|
|
80
85
|
type?: unknown;
|
|
86
|
+
turnIndex?: unknown;
|
|
81
87
|
message?: MessageLike;
|
|
82
88
|
toolCallId?: unknown;
|
|
83
89
|
toolName?: unknown;
|
|
@@ -88,6 +94,42 @@ interface RawSubagentEvent {
|
|
|
88
94
|
assistantMessageEvent?: unknown;
|
|
89
95
|
}
|
|
90
96
|
|
|
97
|
+
function formatValue(value: unknown): string {
|
|
98
|
+
if (typeof value === "string") return value;
|
|
99
|
+
if (value === undefined) return "";
|
|
100
|
+
if (value && typeof value === "object") {
|
|
101
|
+
const content = (value as { content?: unknown }).content;
|
|
102
|
+
if (Array.isArray(content)) {
|
|
103
|
+
const text = content
|
|
104
|
+
.map((part) => {
|
|
105
|
+
if (part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string") {
|
|
106
|
+
return (part as { text: string }).text;
|
|
107
|
+
}
|
|
108
|
+
return formatValue(part);
|
|
109
|
+
})
|
|
110
|
+
.filter(Boolean)
|
|
111
|
+
.join("\n");
|
|
112
|
+
if (text) return text;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
return JSON.stringify(value, null, 2) ?? String(value);
|
|
117
|
+
} catch {
|
|
118
|
+
return String(value);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function messageText(message: MessageLike | undefined, kind: "text" | "thinking" = "text"): string {
|
|
123
|
+
if (!Array.isArray(message?.content)) return typeof message?.content === "string" && kind === "text" ? message.content : "";
|
|
124
|
+
return message.content
|
|
125
|
+
.filter((part) => part && typeof part === "object" && (part as { type?: unknown }).type === kind)
|
|
126
|
+
.map((part) => {
|
|
127
|
+
const value = part as { text?: unknown; thinking?: unknown };
|
|
128
|
+
return typeof value.text === "string" ? value.text : typeof value.thinking === "string" ? value.thinking : "";
|
|
129
|
+
})
|
|
130
|
+
.join("\n");
|
|
131
|
+
}
|
|
132
|
+
|
|
91
133
|
function finalOutput(messages: MessageLike[]): string {
|
|
92
134
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
93
135
|
const message = messages[i];
|
|
@@ -99,29 +141,91 @@ function finalOutput(messages: MessageLike[]): string {
|
|
|
99
141
|
return "";
|
|
100
142
|
}
|
|
101
143
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
.filter((part) => part.type === kind)
|
|
107
|
-
.map((part) => (kind === "thinking" ? part.thinking ?? part.text ?? "" : part.text ?? ""))
|
|
108
|
-
.join("\n");
|
|
144
|
+
interface TranscriptEventOptions {
|
|
145
|
+
toolCallId?: string;
|
|
146
|
+
toolName?: string;
|
|
147
|
+
isError?: boolean;
|
|
109
148
|
}
|
|
110
149
|
|
|
111
|
-
function
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
150
|
+
function transcriptEvent(
|
|
151
|
+
phase: "start" | "update" | "end",
|
|
152
|
+
entryType: SubagentTranscriptEntryType,
|
|
153
|
+
key: string,
|
|
154
|
+
text: string,
|
|
155
|
+
update: "append" | "replace",
|
|
156
|
+
streaming: boolean,
|
|
157
|
+
options: TranscriptEventOptions = {},
|
|
158
|
+
): SubagentProgressEvent {
|
|
159
|
+
return { type: "transcript", phase, entryType, key, text, update, streaming, ...options };
|
|
115
160
|
}
|
|
116
161
|
|
|
117
|
-
function
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
162
|
+
function messageContentEvents(message: MessageLike | undefined, phase: "start" | "end"): SubagentProgressEvent[] {
|
|
163
|
+
if (message?.role !== "assistant" || !Array.isArray(message.content)) return [];
|
|
164
|
+
return message.content.flatMap((part, index) => {
|
|
165
|
+
if (!part || typeof part !== "object") return [];
|
|
166
|
+
const value = part as { type?: unknown; text?: unknown; thinking?: unknown; id?: unknown; name?: unknown; arguments?: unknown };
|
|
167
|
+
if (value.type === "text" && typeof value.text === "string") {
|
|
168
|
+
return [transcriptEvent(phase, "assistant-text", `content:${index}`, value.text, "replace", phase !== "end")];
|
|
169
|
+
}
|
|
170
|
+
if (value.type === "thinking") {
|
|
171
|
+
const text = typeof value.thinking === "string" ? value.thinking : typeof value.text === "string" ? value.text : "";
|
|
172
|
+
return [transcriptEvent(phase, "thinking", `content:${index}`, text, "replace", phase !== "end")];
|
|
173
|
+
}
|
|
174
|
+
if (value.type === "toolCall") {
|
|
175
|
+
return [transcriptEvent(phase, "tool-call", `content:${index}`, formatValue(value.arguments), "replace", phase !== "end", {
|
|
176
|
+
toolCallId: typeof value.id === "string" ? value.id : undefined,
|
|
177
|
+
toolName: typeof value.name === "string" ? value.name : undefined,
|
|
178
|
+
})];
|
|
179
|
+
}
|
|
180
|
+
return [];
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function assistantUpdateEvents(event: RawSubagentEvent): SubagentProgressEvent[] {
|
|
185
|
+
const update = event.assistantMessageEvent;
|
|
186
|
+
if (!update || typeof update !== "object") return messageContentEvents(event.message, "end");
|
|
187
|
+
const value = update as {
|
|
188
|
+
type?: unknown;
|
|
189
|
+
contentIndex?: unknown;
|
|
190
|
+
delta?: unknown;
|
|
191
|
+
content?: unknown;
|
|
192
|
+
id?: unknown;
|
|
193
|
+
toolName?: unknown;
|
|
194
|
+
toolCall?: { id?: unknown; name?: unknown; arguments?: unknown };
|
|
195
|
+
};
|
|
196
|
+
const type = typeof value.type === "string" ? value.type : "";
|
|
197
|
+
const index = typeof value.contentIndex === "number" ? value.contentIndex : 0;
|
|
198
|
+
const key = `content:${index}`;
|
|
199
|
+
switch (type) {
|
|
200
|
+
case "text_start":
|
|
201
|
+
return [transcriptEvent("start", "assistant-text", key, "", "replace", true)];
|
|
202
|
+
case "text_delta":
|
|
203
|
+
return [transcriptEvent("update", "assistant-text", key, typeof value.delta === "string" ? value.delta : "", "append", true)];
|
|
204
|
+
case "text_end":
|
|
205
|
+
return [transcriptEvent("end", "assistant-text", key, typeof value.content === "string" ? value.content : "", "replace", false)];
|
|
206
|
+
case "thinking_start":
|
|
207
|
+
return [transcriptEvent("start", "thinking", key, "", "replace", true)];
|
|
208
|
+
case "thinking_delta":
|
|
209
|
+
return [transcriptEvent("update", "thinking", key, typeof value.delta === "string" ? value.delta : "", "append", true)];
|
|
210
|
+
case "thinking_end":
|
|
211
|
+
return [transcriptEvent("end", "thinking", key, typeof value.content === "string" ? value.content : "", "replace", false)];
|
|
212
|
+
case "toolcall_start":
|
|
213
|
+
return [transcriptEvent("start", "tool-call", key, "", "replace", true, {
|
|
214
|
+
toolCallId: typeof value.id === "string" ? value.id : undefined,
|
|
215
|
+
toolName: typeof value.toolName === "string" ? value.toolName : undefined,
|
|
216
|
+
})];
|
|
217
|
+
case "toolcall_delta":
|
|
218
|
+
return [transcriptEvent("update", "tool-call", key, typeof value.delta === "string" ? value.delta : "", "append", true, {
|
|
219
|
+
toolCallId: typeof value.id === "string" ? value.id : undefined,
|
|
220
|
+
toolName: typeof value.toolName === "string" ? value.toolName : undefined,
|
|
221
|
+
})];
|
|
222
|
+
case "toolcall_end":
|
|
223
|
+
return [transcriptEvent("end", "tool-call", key, formatValue(value.toolCall?.arguments), "replace", false, {
|
|
224
|
+
toolCallId: typeof value.toolCall?.id === "string" ? value.toolCall.id : typeof value.id === "string" ? value.id : undefined,
|
|
225
|
+
toolName: typeof value.toolCall?.name === "string" ? value.toolCall.name : typeof value.toolName === "string" ? value.toolName : undefined,
|
|
226
|
+
})];
|
|
227
|
+
default:
|
|
228
|
+
return [];
|
|
125
229
|
}
|
|
126
230
|
}
|
|
127
231
|
|
|
@@ -130,45 +234,43 @@ function preview(value: unknown, maxLength = 240): string {
|
|
|
130
234
|
* protocol consumed by the refinement overlay. Unknown events are ignored so
|
|
131
235
|
* adding progress support cannot make result parsing version-fragile.
|
|
132
236
|
*/
|
|
133
|
-
export function normalizeSubagentEvent(value: unknown): SubagentProgressEvent
|
|
134
|
-
if (!value || typeof value !== "object") return
|
|
237
|
+
export function normalizeSubagentEvent(value: unknown): SubagentProgressEvent[] {
|
|
238
|
+
if (!value || typeof value !== "object") return [];
|
|
135
239
|
const event = value as RawSubagentEvent;
|
|
136
|
-
if (typeof event.type !== "string") return
|
|
137
|
-
|
|
138
|
-
if (event.type === "turn_start") return { type: "turn", phase: "start" };
|
|
139
|
-
if (event.type === "turn_end") return { type: "turn", phase: "end" };
|
|
240
|
+
if (typeof event.type !== "string") return [];
|
|
140
241
|
|
|
141
|
-
if (event.type === "
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
phase: event.type.slice("message_".length) as "start" | "update" | "end",
|
|
147
|
-
role,
|
|
148
|
-
text: messageText(event.message) || messageEventText(event.assistantMessageEvent),
|
|
149
|
-
};
|
|
242
|
+
if (event.type === "turn_start") {
|
|
243
|
+
return [{ type: "turn", phase: "start", ...(typeof event.turnIndex === "number" ? { turnIndex: event.turnIndex } : {}) }];
|
|
244
|
+
}
|
|
245
|
+
if (event.type === "turn_end") {
|
|
246
|
+
return [{ type: "turn", phase: "end", ...(typeof event.turnIndex === "number" ? { turnIndex: event.turnIndex } : {}) }];
|
|
150
247
|
}
|
|
151
248
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
toolCallId: typeof event.toolCallId === "string" ? event.toolCallId :
|
|
165
|
-
toolName: typeof event.toolName === "string" ? event.toolName :
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
249
|
+
if (event.type === "message_start") return messageContentEvents(event.message, "start");
|
|
250
|
+
if (event.type === "message_update") return assistantUpdateEvents(event);
|
|
251
|
+
if (event.type === "message_end") return messageContentEvents(event.message, "end");
|
|
252
|
+
|
|
253
|
+
if (event.type === "tool_execution_start") {
|
|
254
|
+
return [transcriptEvent("start", "tool-call", `tool:${String(event.toolCallId ?? "unknown")}`, formatValue(event.args), "replace", true, {
|
|
255
|
+
toolCallId: typeof event.toolCallId === "string" ? event.toolCallId : undefined,
|
|
256
|
+
toolName: typeof event.toolName === "string" ? event.toolName : undefined,
|
|
257
|
+
})];
|
|
258
|
+
}
|
|
259
|
+
if (event.type === "tool_execution_update") {
|
|
260
|
+
return [transcriptEvent("update", "tool-result", `tool:${String(event.toolCallId ?? "unknown")}`, formatValue(event.partialResult), "replace", true, {
|
|
261
|
+
toolCallId: typeof event.toolCallId === "string" ? event.toolCallId : undefined,
|
|
262
|
+
toolName: typeof event.toolName === "string" ? event.toolName : undefined,
|
|
263
|
+
})];
|
|
264
|
+
}
|
|
265
|
+
if (event.type === "tool_execution_end") {
|
|
266
|
+
return [transcriptEvent("end", "tool-result", `tool:${String(event.toolCallId ?? "unknown")}`, formatValue(event.result), "replace", false, {
|
|
267
|
+
toolCallId: typeof event.toolCallId === "string" ? event.toolCallId : undefined,
|
|
268
|
+
toolName: typeof event.toolName === "string" ? event.toolName : undefined,
|
|
269
|
+
isError: typeof event.isError === "boolean" ? event.isError : undefined,
|
|
270
|
+
})];
|
|
169
271
|
}
|
|
170
272
|
|
|
171
|
-
return
|
|
273
|
+
return [];
|
|
172
274
|
}
|
|
173
275
|
|
|
174
276
|
function emitProgress(options: SubagentOptions, event: SubagentProgressEvent): void {
|
|
@@ -179,7 +281,7 @@ function emitProgress(options: SubagentOptions, event: SubagentProgressEvent): v
|
|
|
179
281
|
}
|
|
180
282
|
}
|
|
181
283
|
|
|
182
|
-
const DEFAULT_TIMEOUT_MS =
|
|
284
|
+
const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000;
|
|
183
285
|
|
|
184
286
|
export async function runPiSubagent(options: SubagentOptions): Promise<SubagentResult> {
|
|
185
287
|
const tools = options.tools ?? ["read", "grep", "find", "ls"];
|
|
@@ -204,6 +306,7 @@ export async function runPiSubagent(options: SubagentOptions): Promise<SubagentR
|
|
|
204
306
|
cwd: options.cwd,
|
|
205
307
|
shell: false,
|
|
206
308
|
stdio: ["ignore", "pipe", "pipe"],
|
|
309
|
+
env: { ...process.env, PI_PLANS_REFINER: "1" },
|
|
207
310
|
});
|
|
208
311
|
let buffer = "";
|
|
209
312
|
let closed = false;
|
|
@@ -240,7 +343,7 @@ export async function runPiSubagent(options: SubagentOptions): Promise<SubagentR
|
|
|
240
343
|
return;
|
|
241
344
|
}
|
|
242
345
|
const progress = normalizeSubagentEvent(event);
|
|
243
|
-
|
|
346
|
+
for (const progressEvent of progress) emitProgress(options, progressEvent);
|
|
244
347
|
if ((event.type === "message_end" || event.type === "tool_result_end") && event.message) {
|
|
245
348
|
messages.push(event.message);
|
|
246
349
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the post-execution implementation-review
|
|
3
|
+
* termination question. Consumed by BOTH the goal-running continuation prompt
|
|
4
|
+
* (src/exec.ts AMELIORATION_PROMPT_TEXT) and the ask_choice trailing branch
|
|
5
|
+
* (tools/ask-choice.ts). Pure constants only — no runtime imports, no
|
|
6
|
+
* execution-loop coupling.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const TERMINATION_QUESTION = "How should the implementation-review loop terminate?";
|
|
10
|
+
|
|
11
|
+
export const TERMINATION_OPTIONS = [
|
|
12
|
+
"goal wait: continue until no unpassed VCs remain (auto-continue each round)",
|
|
13
|
+
"until no high-severity finding (hard cap 5 rounds)",
|
|
14
|
+
"1 round",
|
|
15
|
+
"2 rounds",
|
|
16
|
+
"3 rounds",
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
/** "1. <option> 2. <option> …" — recommended (goal wait) first. */
|
|
20
|
+
export function renderTerminationOptions(): string {
|
|
21
|
+
return TERMINATION_OPTIONS.map((option, index) => `${index + 1}. ${option}`).join(" ");
|
|
22
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/** Tests for the analyze_refs tool: gates, batching, records, failure contract. */
|
|
2
|
+
|
|
3
|
+
import * as assert from "node:assert/strict";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as os from "node:os";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import * as url from "node:url";
|
|
8
|
+
import { after, before, describe, it } from "node:test";
|
|
9
|
+
import { registerAnalyzeRefsTool } from "../tools/analyze-refs.ts";
|
|
10
|
+
import { initState, setRole, startRun, readActive } from "../src/state.ts";
|
|
11
|
+
|
|
12
|
+
const ROOT = path.dirname(path.dirname(url.fileURLToPath(import.meta.url)));
|
|
13
|
+
|
|
14
|
+
let tmpRoot: string;
|
|
15
|
+
|
|
16
|
+
before(() => {
|
|
17
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-analyze-refs-"));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
after(() => {
|
|
21
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function mkWorkdir(name: string): string {
|
|
25
|
+
const dir = path.join(tmpRoot, name);
|
|
26
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
27
|
+
return dir;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function fakePiScript(body: string): string {
|
|
31
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-fake-pi-"));
|
|
32
|
+
const script = path.join(dir, "fake-pi.mjs");
|
|
33
|
+
fs.writeFileSync(
|
|
34
|
+
script,
|
|
35
|
+
[
|
|
36
|
+
'const emit = (event) => process.stdout.write(JSON.stringify(event) + "\\n");',
|
|
37
|
+
'emit({ type: "turn_start" });',
|
|
38
|
+
`async function main() { ${body} }`,
|
|
39
|
+
"await main();",
|
|
40
|
+
].join("\n"),
|
|
41
|
+
);
|
|
42
|
+
return script;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function withFakePi(scriptPath: string): () => void {
|
|
46
|
+
const previousScript = process.argv[1];
|
|
47
|
+
process.argv[1] = scriptPath;
|
|
48
|
+
return () => {
|
|
49
|
+
process.argv[1] = previousScript;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface CapturedTool {
|
|
54
|
+
execute: (toolCallId: string, params: unknown, signal: AbortSignal | undefined, onUpdate: unknown, ctx: unknown) => Promise<{ content: Array<{ type: string; text: string }>; details: any }>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loadTool(): CapturedTool {
|
|
58
|
+
let captured: CapturedTool | undefined;
|
|
59
|
+
const pi = {
|
|
60
|
+
registerTool: (definition: unknown) => {
|
|
61
|
+
captured = definition as CapturedTool;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
registerAnalyzeRefsTool(pi as any, ROOT);
|
|
65
|
+
assert.ok(captured, "registerTool was not called");
|
|
66
|
+
return captured!;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function headlessCtx(workdir: string): unknown {
|
|
70
|
+
return { cwd: workdir };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function subagentLines(workdir: string): Array<any> {
|
|
74
|
+
const active = readActive(workdir);
|
|
75
|
+
assert.ok(active, "expected an active run");
|
|
76
|
+
const file = path.join(active.run_dir, "subagents.jsonl");
|
|
77
|
+
if (!fs.existsSync(file)) return [];
|
|
78
|
+
return fs
|
|
79
|
+
.readFileSync(file, "utf8")
|
|
80
|
+
.trim()
|
|
81
|
+
.split("\n")
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.map((line) => JSON.parse(line));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("analyze_refs gates", () => {
|
|
87
|
+
it("refuses when no pi-plans state exists", async () => {
|
|
88
|
+
const workdir = mkWorkdir("gates-no-state");
|
|
89
|
+
const tool = loadTool();
|
|
90
|
+
await assert.rejects(
|
|
91
|
+
tool.execute("c1", { refs: [{ id: "ref-1", localPath: "." }] }, undefined, undefined, headlessCtx(workdir)),
|
|
92
|
+
/no pi-plans state found/,
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("refuses with role-setting guidance when reviewer mode is invalid", async () => {
|
|
97
|
+
const workdir = mkWorkdir("gates-bad-mode");
|
|
98
|
+
initState(workdir);
|
|
99
|
+
const configPath = path.join(workdir, ".git", "pi_plans", "config.json");
|
|
100
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
101
|
+
config.reviewer.mode = "bogus";
|
|
102
|
+
fs.writeFileSync(configPath, `${JSON.stringify(config, null, "\t")}\n`, "utf8");
|
|
103
|
+
const tool = loadTool();
|
|
104
|
+
await assert.rejects(
|
|
105
|
+
tool.execute("c1", { refs: [{ id: "ref-1", localPath: "." }] }, undefined, undefined, headlessCtx(workdir)),
|
|
106
|
+
/reviewer role mode is missing or invalid/,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("refuses current-session reviewer mode with a switch-to-delegated message", async () => {
|
|
111
|
+
const workdir = mkWorkdir("gates-current-session");
|
|
112
|
+
initState(workdir);
|
|
113
|
+
setRole(workdir, { role: "reviewer", mode: "current-session" });
|
|
114
|
+
const tool = loadTool();
|
|
115
|
+
await assert.rejects(
|
|
116
|
+
tool.execute("c1", { refs: [{ id: "ref-1", localPath: "." }] }, undefined, undefined, headlessCtx(workdir)),
|
|
117
|
+
/current-session.*delegated-subagent/s,
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("refuses with confirmation guidance when the reviewer model is unconfirmed", async () => {
|
|
122
|
+
const workdir = mkWorkdir("gates-unconfirmed");
|
|
123
|
+
initState(workdir);
|
|
124
|
+
setRole(workdir, { role: "reviewer", mode: "delegated-subagent" });
|
|
125
|
+
const tool = loadTool();
|
|
126
|
+
await assert.rejects(
|
|
127
|
+
tool.execute("c1", { refs: [{ id: "ref-1", localPath: "." }] }, undefined, undefined, headlessCtx(workdir)),
|
|
128
|
+
/model was never confirmed/,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("analyze_refs fanout", () => {
|
|
134
|
+
it("spawns one lane per ref in batches of at most 3, records spawns, and returns sections", async () => {
|
|
135
|
+
const workdir = mkWorkdir("fanout");
|
|
136
|
+
initState(workdir);
|
|
137
|
+
setRole(workdir, { role: "reviewer", mode: "delegated-subagent", modelSelector: "fake/model", confirmed: true });
|
|
138
|
+
startRun(workdir, { topic: "refs", skill: "plan-with-refs", requestText: "x" });
|
|
139
|
+
|
|
140
|
+
const refs = [1, 2, 3, 4, 5].map((n) => {
|
|
141
|
+
const dir = path.join(workdir, `refs`, `repo-${n}`);
|
|
142
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
143
|
+
fs.writeFileSync(path.join(dir, "README.md"), `ref ${n}`);
|
|
144
|
+
return { id: `ref-${n}`, localPath: dir, title: `Ref ${n}`, url: "https://example.com", kind: "project" };
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const restore = withFakePi(
|
|
148
|
+
fakePiScript(
|
|
149
|
+
`const task = process.argv.filter((arg) => arg.startsWith("Task: ")).pop() ?? "";\n` +
|
|
150
|
+
`emit({ type: "message_end", message: { role: "assistant", model: "fake/model", content: [{ type: "text", text: "ANALYSIS from " + process.cwd().split("/").pop() + "\\n" + task }] } });`,
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
const tool = loadTool();
|
|
154
|
+
try {
|
|
155
|
+
const result = await tool.execute("c1", { refs, context: "pi-plans repo" }, undefined, undefined, headlessCtx(workdir));
|
|
156
|
+
const text = result.content[0]!.text;
|
|
157
|
+
for (let n = 1; n <= 5; n += 1) {
|
|
158
|
+
assert.ok(text.includes(`### pi-plans-refs-`), "missing section header");
|
|
159
|
+
assert.ok(text.includes(`ANALYSIS from repo-${n}`), `missing per-ref cwd output for repo-${n}`);
|
|
160
|
+
assert.ok(text.includes(`Reference id: ref-${n}`), `brief must carry the ref id for repo-${n}`);
|
|
161
|
+
}
|
|
162
|
+
assert.ok(text.includes("Target repo context: pi-plans repo"), "brief must carry the target repo context");
|
|
163
|
+
assert.ok(text.includes("## Evidence Gaps"), "brief must carry the seven-section contract");
|
|
164
|
+
assert.match(text, /Persist: paste each reference's analysis into REF_ANALYSIS\.md/);
|
|
165
|
+
assert.equal(result.details.batches, 2, "five refs must run as two batches");
|
|
166
|
+
assert.equal(result.details.role, "ref-analyst");
|
|
167
|
+
|
|
168
|
+
const spawns = subagentLines(workdir);
|
|
169
|
+
assert.equal(spawns.length, 5);
|
|
170
|
+
assert.ok(spawns.every((spawn: any) => spawn.role === "ref-analyst"));
|
|
171
|
+
assert.equal(spawns[0].model, "fake/model");
|
|
172
|
+
const names = spawns.map((spawn: any) => spawn.name).sort();
|
|
173
|
+
for (let n = 1; n <= 5; n += 1) {
|
|
174
|
+
assert.ok(names.some((name: string) => name.endsWith(`-ref-${n}`)), `missing spawn name ref-${n}`);
|
|
175
|
+
}
|
|
176
|
+
} finally {
|
|
177
|
+
restore();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("section-fails missing ref directories without aborting the rest", async () => {
|
|
182
|
+
const workdir = mkWorkdir("fanout-missing");
|
|
183
|
+
initState(workdir);
|
|
184
|
+
setRole(workdir, { role: "reviewer", mode: "delegated-subagent", modelSelector: "fake/model", confirmed: true });
|
|
185
|
+
startRun(workdir, { topic: "refs-missing", skill: "plan-with-refs", requestText: "x" });
|
|
186
|
+
|
|
187
|
+
const good = path.join(workdir, "refs", "good");
|
|
188
|
+
fs.mkdirSync(good, { recursive: true });
|
|
189
|
+
const refs = [
|
|
190
|
+
{ id: "ref-1", localPath: path.join(workdir, "refs", "does-not-exist") },
|
|
191
|
+
{ id: "ref-2", localPath: good },
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
const restore = withFakePi(
|
|
195
|
+
fakePiScript(
|
|
196
|
+
`emit({ type: "message_end", message: { role: "assistant", model: "fake/model", content: [{ type: "text", text: "OK analysis" }] } });`,
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
const tool = loadTool();
|
|
200
|
+
try {
|
|
201
|
+
const result = await tool.execute("c1", { refs }, undefined, undefined, headlessCtx(workdir));
|
|
202
|
+
const text = result.content[0]!.text;
|
|
203
|
+
assert.match(text, /ref-1[^\n]*— FAILED\nreference directory not found:/);
|
|
204
|
+
assert.match(text, /OK analysis/);
|
|
205
|
+
const failed = result.details.outputs.find((output: any) => output.refId === "ref-1");
|
|
206
|
+
assert.equal(failed.ok, false);
|
|
207
|
+
} finally {
|
|
208
|
+
restore();
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("throws when every ref fails", async () => {
|
|
213
|
+
const workdir = mkWorkdir("fanout-all-failed");
|
|
214
|
+
initState(workdir);
|
|
215
|
+
setRole(workdir, { role: "reviewer", mode: "delegated-subagent", modelSelector: "fake/model", confirmed: true });
|
|
216
|
+
const tool = loadTool();
|
|
217
|
+
await assert.rejects(
|
|
218
|
+
tool.execute(
|
|
219
|
+
"c1",
|
|
220
|
+
{ refs: [{ id: "ref-1", localPath: path.join(workdir, "missing-a") }, { id: "ref-2", localPath: path.join(workdir, "missing-b") }] },
|
|
221
|
+
undefined,
|
|
222
|
+
undefined,
|
|
223
|
+
headlessCtx(workdir),
|
|
224
|
+
),
|
|
225
|
+
/all reference analysis subagents failed/,
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("pins the per-batch overlay lifecycle (open before spawn, close in finally, cap 3)", () => {
|
|
230
|
+
const source = fs.readFileSync(path.join(ROOT, "tools", "analyze-refs.ts"), "utf8");
|
|
231
|
+
assert.equal((source.match(/new RefineOverlayController\("refs"/g) ?? []).length, 1, "controller must be constructed per batch inside the loop");
|
|
232
|
+
assert.match(source, /overlay\?\.open\(refineOverlayContext\(ctx\), modelLabel\)/);
|
|
233
|
+
assert.match(source, /await overlay\?\.close\(\);/);
|
|
234
|
+
assert.match(source, /const BATCH_SIZE = 3;/);
|
|
235
|
+
assert.ok(source.indexOf("overlay?.open(") < source.indexOf("await overlay?.close();"), "open must precede close");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("skips recording without an active run (adhoc) and still succeeds", async () => {
|
|
239
|
+
const workdir = mkWorkdir("adhoc");
|
|
240
|
+
initState(workdir);
|
|
241
|
+
setRole(workdir, { role: "reviewer", mode: "delegated-subagent", modelSelector: "fake/model", confirmed: true });
|
|
242
|
+
const refDir = path.join(workdir, "refs", "solo");
|
|
243
|
+
fs.mkdirSync(refDir, { recursive: true });
|
|
244
|
+
|
|
245
|
+
const restore = withFakePi(
|
|
246
|
+
fakePiScript(
|
|
247
|
+
`emit({ type: "message_end", message: { role: "assistant", model: "fake/model", content: [{ type: "text", text: "adhoc analysis" }] } });`,
|
|
248
|
+
),
|
|
249
|
+
);
|
|
250
|
+
const tool = loadTool();
|
|
251
|
+
try {
|
|
252
|
+
const result = await tool.execute("c1", { refs: [{ id: "ref-1", localPath: refDir }] }, undefined, undefined, headlessCtx(workdir));
|
|
253
|
+
assert.match(result.content[0]!.text, /### pi-plans-refs-adhoc-ref-1/);
|
|
254
|
+
assert.equal(readActive(workdir), null);
|
|
255
|
+
const ledger = path.join(workdir, ".git", "pi_plans", "runs");
|
|
256
|
+
const runs = fs.existsSync(ledger) ? fs.readdirSync(ledger) : [];
|
|
257
|
+
const spawnFiles = runs.flatMap((run) =>
|
|
258
|
+
fs.existsSync(path.join(ledger, run, "subagents.jsonl")) ? [fs.readFileSync(path.join(ledger, run, "subagents.jsonl"), "utf8")] : [],
|
|
259
|
+
);
|
|
260
|
+
assert.equal(spawnFiles.join("").trim(), "", "adhoc calls must not record spawns");
|
|
261
|
+
} finally {
|
|
262
|
+
restore();
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
});
|