pi-plans 0.3.0 → 0.3.2

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.
@@ -6,6 +6,7 @@ import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import { after, before, describe, it } from "node:test";
8
8
  import {
9
+ AMELIORATION_PROMPT_TEXT,
9
10
  applyDoneMarkers,
10
11
  applyImplMarkers,
11
12
  applyCurrentIMarker,
@@ -59,6 +60,7 @@ interface Recorded {
59
60
  current: { provider: string; id: string } | null;
60
61
  thinking: string | null;
61
62
  userMessages: string[];
63
+ userMessageOptions: Array<Record<string, unknown> | null>;
62
64
  compacts?: { customInstructions?: string }[];
63
65
  }
64
66
 
@@ -83,6 +85,7 @@ function makeHarness(workdir: string): Harness {
83
85
  current: { provider: "p", id: "m" },
84
86
  thinking: "high",
85
87
  userMessages: [],
88
+ userMessageOptions: [],
86
89
  };
87
90
  let contextPercent: number | null = 0;
88
91
  const registryModels = [
@@ -113,8 +116,9 @@ function makeHarness(workdir: string): Harness {
113
116
  sendMessage: (message: { customType: string; content: string }, options?: { triggerTurn?: boolean }) => {
114
117
  recorded.messages.push({ ...message, options });
115
118
  },
116
- sendUserMessage: async (content: string) => {
119
+ sendUserMessage: async (content: string, options?: Record<string, unknown>) => {
117
120
  recorded.userMessages.push(content);
121
+ recorded.userMessageOptions.push(options ?? null);
118
122
  },
119
123
  setModel: async (model: { provider: string; id: string }) => {
120
124
  recorded.models.push({ provider: model.provider, id: model.id });
@@ -269,7 +273,8 @@ describe("execution loop", () => {
269
273
  const completeMessage = recorded.messages.find((message) => message.customType === "pi-plans-complete");
270
274
  assert.ok(completeMessage);
271
275
  assert.match(completeMessage.content, /Goal-running continuation/);
272
- assert.match(completeMessage.content, /termination condition of the implementation-review loop/);
276
+ assert.match(completeMessage.content, /How should the implementation-review loop terminate\?/);
277
+ assert.match(completeMessage.content, /goal wait: continue until no unpassed VCs remain/);
273
278
  assert.doesNotMatch(completeMessage.content, /Run a post-execution amelioration round/);
274
279
  assert.equal(completeMessage.options?.triggerTurn, true);
275
280
  const ameliorateEntry = recorded.entries.find((entry) => entry.customType === "pi-plans-ameliorate");
@@ -1451,3 +1456,11 @@ function makePreparation(reason: "manual" | "threshold" | "overflow", previousSu
1451
1456
  settings: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
1452
1457
  };
1453
1458
  }
1459
+
1460
+ describe("amelioration termination prompt", () => {
1461
+ it("recommends goal-wait first and keeps the round options", () => {
1462
+ assert.match(AMELIORATION_PROMPT_TEXT, /goal wait: continue until no unpassed VCs remain/);
1463
+ assert.match(AMELIORATION_PROMPT_TEXT, /until no high-severity finding \(hard cap 5 rounds\)/);
1464
+ assert.match(AMELIORATION_PROMPT_TEXT, /How should the implementation-review loop terminate\?/);
1465
+ });
1466
+ });
@@ -0,0 +1,269 @@
1
+ import * as assert from "node:assert/strict";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { after, describe, it } from "node:test";
6
+ import {
7
+ GOAL_WAIT_CUSTOM_TYPE, filterGoalWaitMessages, getExecution, noteCompactionStarted,
8
+ registerExecutionTurnHandlers, restoreFromSession, startExecution, stopExecution,
9
+ } from "../src/exec.ts";
10
+ import { executeCommand, executeHandoff, setCurrentApi } from "../tools/execute-plan.ts";
11
+
12
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
13
+ after(() => fs.rmSync(root, { recursive: true, force: true }));
14
+ let serial = 0;
15
+
16
+ async function setup(mode = "tui") {
17
+ const cwd = path.join(root, String(++serial));
18
+ fs.mkdirSync(cwd);
19
+ const planPath = path.join(cwd, "PLAN_v1.md");
20
+ fs.writeFileSync(planPath, "## Verifier Checklist\n- [ ] `VC-001` first\n- [ ] `VC-002` second\n");
21
+ const handlers = new Map<string, Array<(event: any, ctx: any) => any>>();
22
+ const messages: any[] = [];
23
+ const entries: any[] = [];
24
+ const notices: string[] = [];
25
+ const pending: unknown[] = [];
26
+ let idle = true;
27
+ let status = "";
28
+ const ctx: any = {
29
+ cwd, mode, hasUI: mode === "tui" || mode === "rpc", sessionManager: {},
30
+ isIdle: () => idle, hasPendingMessages: () => pending.length > 0,
31
+ ui: { setStatus: (_key: string, s: string) => { status = s; },
32
+ notify: (s: string) => notices.push(s), theme: { fg: (_c: string, s: string) => s },
33
+ confirm: async () => { throw new Error("same-plan resume must not re-enter handoff"); } },
34
+ };
35
+ const pi: any = {
36
+ on: (name: string, handler: any) => handlers.set(name, [...(handlers.get(name) ?? []), handler]),
37
+ appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data: structuredClone(data) }),
38
+ sendUserMessage: () => { throw new Error("goal-wait must not impersonate a user"); },
39
+ sendMessage: (message: any, options: any) => {
40
+ messages.push({ ...message, options });
41
+ if (message.customType === GOAL_WAIT_CUSTOM_TYPE && options?.triggerTurn) idle = false;
42
+ },
43
+ };
44
+ registerExecutionTurnHandlers(pi);
45
+ setCurrentApi(pi);
46
+ await startExecution(pi, ctx, planPath, [
47
+ { id: "VC-001", text: "first", done: false }, { id: "VC-002", text: "second", done: false },
48
+ ]);
49
+ const emit = async (name: string, event = {}) => {
50
+ for (const handler of handlers.get(name) ?? []) await handler(event, ctx);
51
+ };
52
+ const begin = async () => { idle = false; await emit("agent_start"); };
53
+ const turn = async (text = "working", stopReason = "stop") => emit("turn_end", {
54
+ message: { role: "assistant", stopReason, content: [{ type: "text", text },
55
+ ...(stopReason === "toolUse" ? [{ type: "toolCall", id: "call", name: "read", arguments: {} }] : [])],
56
+ usage: { input: 10, output: 5 } }, toolResults: [],
57
+ });
58
+ const settle = async () => { idle = true; await emit("agent_settled"); };
59
+ const run = async (text = "working", stopReason = "stop") => { await begin(); await turn(text, stopReason); await settle(); };
60
+ return { pi, ctx, planPath, messages, entries, notices, pending, emit, begin, turn, settle, run,
61
+ wakes: () => messages.filter(m => m.customType === GOAL_WAIT_CUSTOM_TYPE),
62
+ status: () => status, setIdle: (value: boolean) => { idle = value; } };
63
+ }
64
+
65
+ describe("goal-wait settled lifecycle", () => {
66
+ it("never queues on ten tool turns and never wakes after completion", async () => {
67
+ const h = await setup();
68
+ await h.begin();
69
+ for (let i = 0; i < 10; i++) await h.turn("working", "toolUse");
70
+ assert.equal(h.wakes().length, 0);
71
+ assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
72
+ await h.turn("[DONE:VC-001] [DONE:VC-002]");
73
+ await h.settle();
74
+ await h.settle();
75
+ assert.equal(getExecution(), null);
76
+ assert.equal(h.wakes().length, 0);
77
+ assert.equal(h.messages.filter(m => m.customType === "pi-plans-complete").length, 1);
78
+ });
79
+
80
+ for (const mode of ["tui", "rpc"]) {
81
+ it(`${mode}: sends one hidden fresh wake and deduplicates settled`, async () => {
82
+ const h = await setup(mode);
83
+ await h.run("[DONE:VC-001]");
84
+ await h.settle();
85
+ const [wake] = h.wakes();
86
+ assert.equal(h.wakes().length, 1);
87
+ assert.equal(wake.display, false);
88
+ assert.equal(wake.options.triggerTurn, true);
89
+ assert.match(wake.content, /1\/2 verifier items done/);
90
+ assert.match(wake.content, /- `VC-002` second/);
91
+ assert.doesNotMatch(wake.content, /- `VC-001` first/);
92
+ assert.deepEqual(filterGoalWaitMessages([wake]), [wake]);
93
+ assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
94
+ });
95
+ }
96
+
97
+ for (const mode of ["print", "json"]) {
98
+ it(`${mode}: tracks markers without any automatic wake`, async () => {
99
+ const h = await setup(mode);
100
+ await h.run("[DONE:VC-001]");
101
+ assert.equal(getExecution()?.items[0].done, true);
102
+ await h.run("failed", "error");
103
+ await h.run("[DONE:VC-002]");
104
+ assert.equal(h.wakes().length, 0);
105
+ assert.equal(getExecution(), null);
106
+ assert.equal(h.messages.find(m => m.customType === "pi-plans-complete").options.triggerTurn, false);
107
+ });
108
+ }
109
+
110
+ for (const gate of ["busy", "pending", "inFlight", "resumeGuard", "pendingFollowUpPrompt", "lifecycle"]) {
111
+ it(`does not send or count when ${gate} owns continuation`, async () => {
112
+ const h = await setup();
113
+ await h.begin();
114
+ await h.turn();
115
+ h.setIdle(gate !== "busy");
116
+ if (gate === "pending") h.pending.push("user message", { customType: "another-extension" });
117
+ else if (gate === "lifecycle") noteCompactionStarted(h.ctx, undefined);
118
+ else if (gate !== "busy") h.ctx.sessionManager.__executionCompaction = { [gate]: gate === "pendingFollowUpPrompt" ? "follow-up" : true };
119
+ const original = structuredClone(h.pending);
120
+ await h.emit("agent_settled");
121
+ assert.equal(h.wakes().length, 0);
122
+ assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
123
+ assert.deepEqual(h.pending, original);
124
+ if (gate === "busy") {
125
+ await h.settle();
126
+ assert.equal(h.wakes().length, 1, "a busy notification must not consume the real settled cycle");
127
+ }
128
+ });
129
+ }
130
+
131
+ for (const stopReason of ["error", "aborted"]) {
132
+ it(`${stopReason}: pauses once and only genuine input resumes`, async () => {
133
+ const h = await setup();
134
+ await h.run("failed", stopReason);
135
+ assert.equal(getExecution()?.goalWait?.paused, true);
136
+ await h.settle();
137
+ assert.equal(h.notices.length, 1);
138
+ await h.emit("input", { source: "extension" });
139
+ await h.emit("before_agent_start");
140
+ assert.equal(getExecution()?.goalWait?.paused, true);
141
+ assert.equal(h.wakes().length, 0);
142
+ await h.emit("input", { source: "rpc" });
143
+ assert.equal(getExecution()?.goalWait?.paused, false);
144
+ assert.equal(h.wakes().length, 0, "input is already owned by Pi");
145
+ await h.run();
146
+ assert.equal(h.wakes().length, 1);
147
+ });
148
+ }
149
+
150
+ it("leaves retries to Pi and refuses unknown or intentional tool termination", async () => {
151
+ const h = await setup();
152
+ await h.begin();
153
+ await h.turn("retryable failure", "error");
154
+ await h.emit("agent_end");
155
+ assert.equal(h.wakes().length, 0);
156
+ assert.equal(getExecution()?.goalWait?.paused, false);
157
+ await h.turn("recovered");
158
+ await h.settle();
159
+ assert.equal(h.wakes().length, 1);
160
+ for (const reason of ["toolUse", "length", "unknown"]) await h.run("intentional stop", reason);
161
+ assert.equal(h.wakes().length, 1);
162
+ });
163
+
164
+ for (const [text, count, field] of [["working", 3, "noProgressRounds"], ["waiting for CI", 6, "waitRounds"]] as const) {
165
+ it(`pauses at ${count} ${field} settled cycles, not intermediate turns`, async () => {
166
+ const h = await setup();
167
+ for (let i = 1; i <= count; i++) {
168
+ await h.begin();
169
+ for (let j = 0; j < 4; j++) await h.turn("tool work", "toolUse");
170
+ assert.equal(getExecution()?.goalWait?.[field], i - 1);
171
+ await h.turn(text);
172
+ await h.settle();
173
+ await h.settle();
174
+ assert.equal(getExecution()?.goalWait?.[field], i);
175
+ }
176
+ assert.equal(h.wakes().length, count - 1);
177
+ assert.equal(getExecution()?.goalWait?.paused, true);
178
+ assert.match(h.status(), /goal-wait paused/);
179
+ });
180
+ }
181
+
182
+ it("real progress resets both nonzero counters through registered handlers", async () => {
183
+ const h = await setup();
184
+ await h.run();
185
+ await h.run("waiting for tests");
186
+ assert.equal(getExecution()?.goalWait?.noProgressRounds, 1);
187
+ assert.equal(getExecution()?.goalWait?.waitRounds, 1);
188
+ await h.run("[DONE:VC-001]");
189
+ assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
190
+ assert.equal(getExecution()?.goalWait?.waitRounds, 0);
191
+ const persisted = h.entries.filter(e => e.customType === "pi-plans-exec").at(-1).data;
192
+ assert.equal(persisted.items[0].done, true);
193
+ assert.equal(persisted.goalWait.noProgressRounds, 0);
194
+ });
195
+
196
+ it("same-plan explicit handoff resumes without losing verified progress", async () => {
197
+ const h = await setup();
198
+ await h.run("[DONE:VC-001]");
199
+ await h.run("cancelled", "aborted");
200
+ const ex = getExecution()!;
201
+ const before = structuredClone(ex);
202
+ const outcome = await executeCommand(h.ctx, h.planPath);
203
+ assert.equal(outcome.status, "executing");
204
+ assert.equal(getExecution(), ex);
205
+ assert.deepEqual(ex.items, before.items);
206
+ assert.deepEqual(ex.usage, before.usage);
207
+ assert.equal(ex.startedAt, before.startedAt);
208
+ assert.equal(ex.goalWait?.paused, false);
209
+ assert.equal(h.wakes().length, 2);
210
+ await executeCommand(h.ctx, h.planPath);
211
+ assert.equal(h.wakes().length, 2);
212
+ await assert.rejects(executeHandoff(h.ctx, h.planPath), /must not re-enter handoff/);
213
+ const other = path.join(h.ctx.cwd, "PLAN_v2.md");
214
+ fs.copyFileSync(h.planPath, other);
215
+ await assert.rejects(executeCommand(h.ctx, other), /must not re-enter handoff/);
216
+ });
217
+
218
+ it("a paused command during another run preserves its next settled opportunity", async () => {
219
+ const h = await setup();
220
+ await h.run("interrupted", "aborted");
221
+ await h.begin();
222
+ const outcome = await executeCommand(h.ctx, h.planPath);
223
+ assert.equal(outcome.status, "executing");
224
+ assert.equal(getExecution()?.goalWait?.paused, false);
225
+ assert.equal(h.wakes().length, 0, "busy command must not enqueue a kick");
226
+ await h.turn("still incomplete");
227
+ await h.settle();
228
+ assert.equal(h.wakes().length, 1);
229
+ });
230
+
231
+ it("restore preserves pause and counters but cannot replay a wake", async () => {
232
+ const h = await setup();
233
+ for (let i = 0; i < 3; i++) await h.run();
234
+ const oldWake = h.wakes().at(-1);
235
+ const before = structuredClone(getExecution()?.goalWait);
236
+ await restoreFromSession(h.pi, h.ctx, h.entries);
237
+ await h.settle();
238
+ assert.deepEqual(getExecution()?.goalWait, before);
239
+ assert.equal(h.wakes().length, 2);
240
+ assert.deepEqual(filterGoalWaitMessages([oldWake]), []);
241
+ });
242
+
243
+ for (const exit of ["stop", "complete", "replacement", "shutdown"]) {
244
+ it(`${exit} invalidates wake identity without filtering user messages`, async () => {
245
+ const h = await setup();
246
+ await h.run();
247
+ const wake = h.wakes()[0];
248
+ if (exit === "stop") await stopExecution(h.pi, h.ctx, "test");
249
+ if (exit === "complete") await h.turn("[DONE:VC-001] [DONE:VC-002]");
250
+ if (exit === "replacement") await startExecution(h.pi, h.ctx, h.planPath, [{ id: "VC-003", text: "new", done: false }]);
251
+ if (exit === "shutdown") await h.emit("session_shutdown");
252
+ await h.settle();
253
+ const user = { role: "user", content: "Goal wait: this is my text" };
254
+ const other = { customType: "another-extension", content: "continue" };
255
+ assert.deepEqual(filterGoalWaitMessages([wake, user, other]), [user, other]);
256
+ assert.equal(h.wakes().length, 1);
257
+ });
258
+ }
259
+
260
+ it("a synchronous dispatch failure pauses once instead of leaving a retry lock", async () => {
261
+ const h = await setup();
262
+ h.pi.sendMessage = () => { throw new Error("dispatch failed"); };
263
+ await h.run();
264
+ await h.settle();
265
+ assert.equal(getExecution()?.goalWait?.paused, true);
266
+ assert.match(h.notices[0], /dispatch failed/);
267
+ assert.equal(h.notices.length, 1);
268
+ });
269
+ });
@@ -6,7 +6,7 @@ import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import { after, before, describe, it } from "node:test";
8
8
  import { planningWriteBlockReason } from "../src/guard.ts";
9
- import { initState, setRunStatus, startRun } from "../src/state.ts";
9
+ import { initState, setRefsRoot, setRunStatus, startRun } from "../src/state.ts";
10
10
 
11
11
  let tmpRoot: string;
12
12
 
@@ -61,6 +61,32 @@ describe("planning write guard", () => {
61
61
  assert.equal(planningWriteBlockReason({ workdir, toolName: "write", rawPath: "src/main.ts" }), null);
62
62
  });
63
63
 
64
+ it("allows writes under a configured refs_root and stays strict without one", () => {
65
+ const workdir = path.join(tmpRoot, "refs-root-guard");
66
+ fs.mkdirSync(workdir);
67
+ initState(workdir);
68
+ startRun(workdir, { topic: "refs guard", skill: "plan-with-refs", requestText: "x" });
69
+
70
+ // Without a configured refs_root, ./refs/ writes stay blocked.
71
+ assert.ok(planningWriteBlockReason({ workdir, toolName: "write", rawPath: "./refs/paper.md" }));
72
+
73
+ setRefsRoot(workdir, "./refs", "user");
74
+ assert.equal(planningWriteBlockReason({ workdir, toolName: "write", rawPath: "./refs/paper.md" }), null);
75
+ assert.equal(
76
+ planningWriteBlockReason({ workdir, toolName: "edit", rawPath: path.join(workdir, "refs", "notes.md") }),
77
+ null,
78
+ );
79
+ // The refs root does not open up the whole worktree.
80
+ assert.ok(planningWriteBlockReason({ workdir, toolName: "write", rawPath: "src/main.ts" }));
81
+
82
+ // The .git/pi-plans/refs (hyphenated) recommendation is covered by an absolute entry too.
83
+ setRefsRoot(workdir, ".git/pi-plans/refs", "user");
84
+ assert.equal(
85
+ planningWriteBlockReason({ workdir, toolName: "write", rawPath: ".git/pi-plans/refs/repo-a/" }),
86
+ null,
87
+ );
88
+ });
89
+
64
90
  it("is inactive without an active run", () => {
65
91
  const workdir = path.join(tmpRoot, "no-run");
66
92
  fs.mkdirSync(workdir);
@@ -34,4 +34,14 @@ describe("plans tool source", () => {
34
34
  assert.doesNotMatch(source, /case "set-execution-model"/);
35
35
  assert.match(source, /params\.artifactRootSource/);
36
36
  });
37
+
38
+ it("declares refsRootSource and wires the set-refs-root action plus ref-analyst role", () => {
39
+ const source = readPlansSource();
40
+ assert.match(source, /refsRoot:\s*Type\.Optional/);
41
+ assert.match(source, /refsRootSource:\s*Type\.Optional/);
42
+ assert.match(source, /import \{[\s\S]*setRefsRoot,[\s\S]*\} from "\.\.\/src\/state\.ts";/);
43
+ assert.match(source, /case "set-refs-root"/);
44
+ assert.match(source, /params\.refsRootSource/);
45
+ assert.match(source, /"reviewer", "criticizer", "ref-analyst"/);
46
+ });
37
47
  });
@@ -2,7 +2,7 @@ import * as assert from "node:assert/strict";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { describe, it } from "node:test";
5
- import { buildCriticizerTask, buildImplementationCriticizerTask, buildImplementationReviewerTask, buildReviewerTask, reviewerLanes } from "../src/refine-prompts.ts";
5
+ import { buildCriticizerTask, buildImplementationCriticizerTask, buildImplementationReviewerTask, buildRefAnalystTask, buildReviewerTask, refAnalystSections, reviewerLanes } from "../src/refine-prompts.ts";
6
6
 
7
7
  describe("reviewerLanes", () => {
8
8
  it("uses stable lane ids for the big-plan fanout", () => {
@@ -34,6 +34,40 @@ describe("buildReviewerTask", () => {
34
34
  });
35
35
  });
36
36
 
37
+ describe("buildRefAnalystTask", () => {
38
+ it("carries the seven-section contract, ref metadata, and language instruction", () => {
39
+ const text = buildRefAnalystTask({
40
+ refId: "ref-1",
41
+ localPath: "/cache/refs/some-repo",
42
+ title: "Some Repo",
43
+ url: "https://github.com/x/some-repo",
44
+ kind: "project",
45
+ context: "pi-plans extension",
46
+ languageTag: "zh-Hans",
47
+ });
48
+
49
+ assert.match(text, /Reference id: ref-1/);
50
+ assert.match(text, /Title: Some Repo/);
51
+ assert.match(text, /URL: https:\/\/github\.com\/x\/some-repo/);
52
+ assert.match(text, /Local path \(your working directory\): \/cache\/refs\/some-repo/);
53
+ assert.match(text, /Authority boundary: read-only analysis only\./);
54
+ assert.match(text, /Target repo context: pi-plans extension/);
55
+ assert.match(text, /BCP47 tag "zh-Hans"/);
56
+ for (const section of refAnalystSections()) {
57
+ assert.ok(text.includes(`## ${section}`), `missing section: ${section}`);
58
+ }
59
+ assert.equal(refAnalystSections().length, 7);
60
+ });
61
+
62
+ it("omits optional lines when metadata is absent", () => {
63
+ const text = buildRefAnalystTask({ refId: "ref-2", localPath: "/tmp/r" });
64
+ assert.doesNotMatch(text, /Title:/);
65
+ assert.doesNotMatch(text, /URL:/);
66
+ assert.doesNotMatch(text, /Kind:/);
67
+ assert.doesNotMatch(text, /BCP47 tag/);
68
+ });
69
+ });
70
+
37
71
  describe("buildCriticizerTask", () => {
38
72
  it("asks for short adversarial questions only", () => {
39
73
  const text = buildCriticizerTask({
@@ -127,6 +127,40 @@ describe("refine overlay viewport", () => {
127
127
  assert.ok(lines.some((line) => line.includes("output")));
128
128
  });
129
129
 
130
+ it("renders the Refs title for the refs role and keeps legacy titles distinct", () => {
131
+ const refs = new RefineOverlayComponent(
132
+ fakeTheme,
133
+ "refs",
134
+ [readyLane("ref-1", "ref-1", "analysis output")],
135
+ () => {},
136
+ undefined,
137
+ "zai/glm-5.3-flash:high",
138
+ );
139
+ const refLines = refs.render(120);
140
+ assert.ok(refLines.some((line) => line.includes("Refs (zai/glm-5.3-flash:high)")));
141
+ assert.ok(refLines.every((line) => !line.includes("Criticizer") && !line.includes("Reviewer")));
142
+
143
+ const reviewer = new RefineOverlayComponent(
144
+ fakeTheme,
145
+ "reviewer",
146
+ [readyLane("lane-1", "general", "ok")],
147
+ () => {},
148
+ undefined,
149
+ "m1",
150
+ );
151
+ assert.ok(reviewer.render(120).some((line) => line.includes("Reviewer (m1)")));
152
+
153
+ const criticizer = new RefineOverlayComponent(
154
+ fakeTheme,
155
+ "criticizer",
156
+ [readyLane("lane-1", "criticizer", "ok")],
157
+ () => {},
158
+ undefined,
159
+ "m2",
160
+ );
161
+ assert.ok(criticizer.render(120).some((line) => line.includes("Criticizer (m2)")));
162
+ });
163
+
130
164
  it("renders model-aware titles and footer hints", () => {
131
165
  const lanes = [
132
166
  readyLane("lane-1", "correctness", "correctness output"),
@@ -13,6 +13,7 @@ import {
13
13
  recordDecision,
14
14
  setLanguage,
15
15
  setArtifactRoot,
16
+ setRefsRoot,
16
17
  setRole,
17
18
  setRunStatus,
18
19
  showConfig,
@@ -72,6 +73,37 @@ describe("init", () => {
72
73
  assert.equal(config.artifact_root, "./docs/pi-plans");
73
74
  assert.equal(config.artifact_root_source, "unset");
74
75
  assert.equal(config.artifact_root_updated_at, null);
76
+ assert.equal(config.refs_root, null);
77
+ assert.equal(config.refs_root_source, "unset");
78
+ assert.equal(config.refs_root_updated_at, null);
79
+ });
80
+
81
+ it("normalizes old configs missing the refs_root trio", () => {
82
+ const workdir = mkWorkdir("refs-root-normalize");
83
+ initState(workdir);
84
+ const configPath = path.join(commonDir(workdir), "pi_plans", "config.json");
85
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
86
+ delete config.refs_root;
87
+ delete config.refs_root_source;
88
+ delete config.refs_root_updated_at;
89
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, "\t")}\n`, "utf8");
90
+ const updated = initState(workdir);
91
+ assert.equal(updated.config.refs_root, null);
92
+ assert.equal(updated.config.refs_root_source, "unset");
93
+ assert.equal(updated.config.refs_root_updated_at, null);
94
+ assert.equal(readConfig(workdir).refs_root, null);
95
+ });
96
+
97
+ it("setRefsRoot persists the trio", () => {
98
+ const workdir = mkWorkdir("refs-root-set");
99
+ initState(workdir);
100
+ setRefsRoot(workdir, ".git/pi-plans/refs", "user");
101
+ assert.equal(readConfig(workdir).refs_root, ".git/pi-plans/refs");
102
+ assert.equal(readConfig(workdir).refs_root_source, "user");
103
+ assert.ok(typeof readConfig(workdir).refs_root_updated_at === "string");
104
+ const shown = showConfig(workdir);
105
+ assert.equal(shown.refs_root, ".git/pi-plans/refs");
106
+ assert.equal(shown.refs_root_source, "user");
75
107
  });
76
108
 
77
109
  it("migrates legacy artifact roots to ./docs/pi-plans", () => {
@@ -60,6 +60,28 @@ describe("subagent runner lifecycle", () => {
60
60
  assert.equal(result.turns, 1);
61
61
  });
62
62
 
63
+ it("marks refiner children with PI_PLANS_REFINER=1", async () => {
64
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-fake-pi-env-"));
65
+ const script = path.join(dir, "fake-pi-env.mjs");
66
+ fs.writeFileSync(
67
+ script,
68
+ [
69
+ 'const emit = (event) => process.stdout.write(JSON.stringify(event) + "\\n");',
70
+ 'emit({ type: "message_end", message: { role: "assistant", model: "fake/model", content: [{ type: "text", text: "marker=" + String(process.env.PI_PLANS_REFINER) }] } });',
71
+ ].join("\n"),
72
+ );
73
+ const previousScript = process.argv[1];
74
+ process.argv[1] = script;
75
+ try {
76
+ const result = await runPiSubagent({ systemPrompt: "p", task: "env", cwd: process.cwd(), timeoutMs: 2000 });
77
+ assert.equal(result.ok, true);
78
+ assert.equal(result.output, "marker=1");
79
+ } finally {
80
+ process.argv[1] = previousScript;
81
+ fs.rmSync(dir, { recursive: true, force: true });
82
+ }
83
+ });
84
+
63
85
  it("returns a cancelled result after aborting the child", async () => {
64
86
  const abort = new AbortController();
65
87
  const promise = withFakePi("slow", { signal: abort.signal, timeoutMs: 2000 });