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.
Files changed (75) hide show
  1. package/README.md +90 -26
  2. package/agents/ref-analyst.md +18 -0
  3. package/index.ts +121 -9
  4. package/package.json +16 -1
  5. package/references/pi-planning-workflow.md +21 -6
  6. package/references/state-and-config.md +52 -5
  7. package/scripts/validate.ts +5 -0
  8. package/skills/plan-with-refs/SKILL.md +3 -3
  9. package/src/code-graph/commands.ts +483 -0
  10. package/src/code-graph/discovery.ts +118 -0
  11. package/src/code-graph/git.ts +108 -0
  12. package/src/code-graph/identity.ts +59 -0
  13. package/src/code-graph/indexer.ts +281 -0
  14. package/src/code-graph/materialize.ts +166 -0
  15. package/src/code-graph/mode.ts +28 -0
  16. package/src/code-graph/mutations.ts +160 -0
  17. package/src/code-graph/parser.ts +51 -0
  18. package/src/code-graph/parsers/javascript.ts +35 -0
  19. package/src/code-graph/parsers/python.ts +160 -0
  20. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  21. package/src/code-graph/paths.ts +85 -0
  22. package/src/code-graph/prompts.ts +18 -0
  23. package/src/code-graph/resolver.ts +69 -0
  24. package/src/code-graph/runtime.ts +158 -0
  25. package/src/code-graph/schema.ts +135 -0
  26. package/src/code-graph/screening.ts +82 -0
  27. package/src/code-graph/store.ts +278 -0
  28. package/src/code-graph/summary.ts +435 -0
  29. package/src/code-graph/types.ts +163 -0
  30. package/src/compaction.ts +1125 -371
  31. package/src/config-command.ts +361 -0
  32. package/src/exec.ts +508 -693
  33. package/src/guard.ts +14 -1
  34. package/src/refine-prompts.ts +109 -0
  35. package/src/refine-ui-helpers.ts +71 -18
  36. package/src/refine-ui-state.ts +88 -22
  37. package/src/refine-ui.ts +210 -102
  38. package/src/state.ts +36 -7
  39. package/src/subagent.ts +164 -61
  40. package/src/termination-prompt.ts +22 -0
  41. package/tests/analyze-refs.test.ts +265 -0
  42. package/tests/ask-choice.test.ts +264 -0
  43. package/tests/autocomplete.test.ts +6 -1
  44. package/tests/code-graph-apply-action.test.ts +173 -0
  45. package/tests/code-graph-apply.test.ts +185 -0
  46. package/tests/code-graph-commands.test.ts +211 -0
  47. package/tests/code-graph-db.test.ts +166 -0
  48. package/tests/code-graph-discovery.test.ts +38 -0
  49. package/tests/code-graph-git.test.ts +94 -0
  50. package/tests/code-graph-index.test.ts +175 -0
  51. package/tests/code-graph-loop.e2e.test.ts +159 -0
  52. package/tests/code-graph-mutations.test.ts +117 -0
  53. package/tests/code-graph-parser.test.ts +85 -0
  54. package/tests/code-graph-rollback.test.ts +100 -0
  55. package/tests/code-graph-summary-batching.test.ts +518 -0
  56. package/tests/code-graph-summary.test.ts +148 -0
  57. package/tests/compaction.test.ts +371 -57
  58. package/tests/config-command.test.ts +263 -0
  59. package/tests/exec.test.ts +808 -241
  60. package/tests/fixtures/code-graph/sample.js +36 -0
  61. package/tests/fixtures/code-graph/sample.py +20 -0
  62. package/tests/fixtures/code-graph/sample.ts +15 -0
  63. package/tests/graph-aware-file-tools.test.ts +411 -0
  64. package/tests/guard.test.ts +27 -1
  65. package/tests/plans.test.ts +10 -0
  66. package/tests/refine-prompts.test.ts +101 -2
  67. package/tests/refine-ui.test.ts +371 -72
  68. package/tests/state.test.ts +32 -0
  69. package/tests/subagent.test.ts +48 -20
  70. package/tools/analyze-refs.ts +263 -0
  71. package/tools/ask-choice.ts +159 -11
  72. package/tools/code-graph.ts +277 -0
  73. package/tools/graph-aware-file-tools.ts +392 -0
  74. package/tools/plans.ts +97 -2
  75. 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: "tool";
18
+ type: "transcript";
18
19
  phase: "start" | "update" | "end";
19
- toolCallId: string;
20
- toolName: string;
21
- detail: string;
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?: Array<{ type: string; text?: string; thinking?: string }> | string;
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
- function messageText(message: MessageLike | undefined, kind: "text" | "thinking" = "text"): string {
103
- if (!message?.content) return "";
104
- if (typeof message.content === "string") return kind === "text" ? message.content : "";
105
- return message.content
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 messageEventText(value: unknown): string {
112
- if (!value || typeof value !== "object") return "";
113
- const event = value as { delta?: unknown; content?: unknown };
114
- return typeof event.delta === "string" ? event.delta : typeof event.content === "string" ? event.content : "";
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 preview(value: unknown, maxLength = 240): string {
118
- if (typeof value === "string") return value.slice(0, maxLength);
119
- if (value === undefined) return "";
120
- try {
121
- const text = JSON.stringify(value);
122
- return text ? text.slice(0, maxLength) : "";
123
- } catch {
124
- return String(value).slice(0, maxLength);
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 | undefined {
134
- if (!value || typeof value !== "object") return undefined;
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 undefined;
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 === "message_start" || event.type === "message_update" || event.type === "message_end") {
142
- if (!event.message && !event.assistantMessageEvent) return undefined;
143
- const role = event.message?.role ?? "assistant";
144
- return {
145
- type: "message",
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
- const toolEvent =
153
- event.type === "tool_execution_start"
154
- ? "start"
155
- : event.type === "tool_execution_update"
156
- ? "update"
157
- : event.type === "tool_execution_end" || event.type === "tool_result_end"
158
- ? "end"
159
- : null;
160
- if (toolEvent) {
161
- return {
162
- type: "tool",
163
- phase: toolEvent,
164
- toolCallId: typeof event.toolCallId === "string" ? event.toolCallId : "unknown",
165
- toolName: typeof event.toolName === "string" ? event.toolName : "tool",
166
- detail: preview(event.args ?? event.partialResult ?? event.result),
167
- ...(typeof event.isError === "boolean" ? { isError: event.isError } : {}),
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 undefined;
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 = 15 * 60 * 1000;
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
- if (progress) emitProgress(options, progress);
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
+ });