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.
@@ -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
+ });
@@ -87,6 +87,7 @@ describe("ask_choice trailing option", () => {
87
87
  const text = result.content[0].text as string;
88
88
  assert.match(text, /User selected Auto-refine loop/);
89
89
  assert.match(text, /until no high-severity finding \(hard cap 5 rounds\)/);
90
+ assert.match(text, /goal wait: continue until no unpassed VCs remain/);
90
91
  assert.match(text, /refine \(role: "reviewer", target: "implementation"\)/);
91
92
  assert.equal(result.details.source, "user");
92
93
  assert.equal(result.details.answer, "Auto-refine loop");
@@ -0,0 +1,173 @@
1
+ /** Tests for the agent-invokable code_graph "apply" action and its gates. */
2
+
3
+ import * as assert from "node:assert/strict";
4
+ import { spawnSync } from "node:child_process";
5
+ import * as fs from "node:fs";
6
+ import * as os from "node:os";
7
+ import * as path from "node:path";
8
+ import * as url from "node:url";
9
+ import { test } from "node:test";
10
+ import { Store } from "../src/code-graph/store.ts";
11
+ import { runIndex } from "../src/code-graph/indexer.ts";
12
+ import { applyGraphCore } from "../src/code-graph/commands.ts";
13
+ import { updateFile } from "../src/code-graph/mutations.ts";
14
+ import { makeBackend } from "../src/code-graph/parsers/javascript.ts";
15
+ import { PythonBackend } from "../src/code-graph/parsers/python.ts";
16
+ import { loadGraphRuntime } from "../src/code-graph/runtime.ts";
17
+ import type { ParserBackend } from "../src/code-graph/parser.ts";
18
+ import type { Language } from "../src/code-graph/types.ts";
19
+ import { initState, setRunStatus, startRun } from "../src/state.ts";
20
+ import { registerCodeGraphTool } from "../tools/code-graph.ts";
21
+
22
+ const ROOT = path.dirname(path.dirname(url.fileURLToPath(import.meta.url)));
23
+
24
+ function git(cwd: string, args: string[]): void {
25
+ const env: Record<string, string | undefined> = { ...process.env };
26
+ for (const key of ["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE"]) delete env[key];
27
+ const result = spawnSync("git", args, { cwd, env, encoding: "utf8" });
28
+ assert.equal(result.status, 0, `${args.join(" ")} failed: ${result.stderr}`);
29
+ }
30
+
31
+ async function setupIndexedRepo(): Promise<{ root: string; store: Store; cleanup: () => void } | null> {
32
+ const runtime = await loadGraphRuntime();
33
+ if (!runtime.status.parserAvailable || !runtime.status.sqliteAvailable) return null;
34
+
35
+ const raw = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-apply-action-"));
36
+ git(raw, ["init", "--initial-branch=main"]);
37
+ git(raw, ["config", "user.email", "test@example.com"]);
38
+ git(raw, ["config", "user.name", "test"]);
39
+ fs.writeFileSync(path.join(raw, "math.js"), "function add(a, b) { return a + b; }\n");
40
+ git(raw, ["add", "-A"]);
41
+ git(raw, ["commit", "-m", "init"]);
42
+ const root = fs.realpathSync(raw);
43
+
44
+ const ParserCtor = runtime.runtime.parser.Parser as unknown as new () => {
45
+ parse(input: string | Buffer): unknown;
46
+ setLanguage(language: unknown): void;
47
+ };
48
+ const parsers: Record<Language, ParserBackend> = {
49
+ javascript: makeBackend("javascript", ParserCtor, runtime.runtime.parser.javascript),
50
+ typescript: makeBackend("typescript", ParserCtor, runtime.runtime.parser.typescript),
51
+ tsx: makeBackend("tsx", ParserCtor, runtime.runtime.parser.tsx),
52
+ python: new PythonBackend(ParserCtor, runtime.runtime.parser.python),
53
+ };
54
+ fs.mkdirSync(path.join(root, ".git", "pi_plans"), { recursive: true });
55
+ const store = new Store(
56
+ { dbPath: path.join(root, ".git", "pi_plans", "code_graph.db"), worktreeRoot: root, gitCommonDir: path.join(root, ".git") },
57
+ runtime.runtime.sqlite,
58
+ );
59
+ runIndex({ store, worktreeRoot: root, parsers, reindex: false });
60
+ return {
61
+ root,
62
+ store,
63
+ cleanup: () => {
64
+ store.close();
65
+ try {
66
+ fs.rmSync(raw, { recursive: true, force: true });
67
+ } catch {
68
+ /* ignore */
69
+ }
70
+ },
71
+ };
72
+ }
73
+
74
+ test("applyGraphCore refuses during planning runs and allows once executing or done", async (t) => {
75
+ const opened = await setupIndexedRepo();
76
+ if (!opened) return;
77
+ t.after(() => opened.cleanup());
78
+ const { root } = opened;
79
+
80
+ initState(root);
81
+ const { run } = startRun(root, { topic: "apply-gate", skill: "plan-small", requestText: "x" });
82
+
83
+ const planning = await applyGraphCore(root);
84
+ assert.match(planning.refused ?? "", /planning run is currently planning or accepted/);
85
+ assert.equal(planning.report, undefined);
86
+
87
+ setRunStatus(root, run.run_id, "executing");
88
+ const mathBefore = fs.readFileSync(path.join(root, "math.js"), "utf8");
89
+ const allowed = await applyGraphCore(root);
90
+ assert.equal(allowed.refused, undefined);
91
+ assert.ok(allowed.report, "expected a report once allowed");
92
+ assert.ok(allowed.drift, "expected a drift summary once allowed");
93
+ // Safe no-op with no pending staged edits: disk untouched, nothing stale or errored.
94
+ assert.equal(fs.readFileSync(path.join(root, "math.js"), "utf8"), mathBefore);
95
+ const noPendingCounts: Record<string, number> = { ok: 0, deleted: 0, stale: 0, "skipped-missing": 0, error: 0 };
96
+ for (const file of allowed.report!.files) noPendingCounts[file.status] = (noPendingCounts[file.status] ?? 0) + 1;
97
+ assert.equal(noPendingCounts.stale, 0);
98
+ assert.equal(noPendingCounts.error, 0);
99
+
100
+ setRunStatus(root, run.run_id, "done");
101
+ const doneAllowed = await applyGraphCore(root);
102
+ assert.equal(doneAllowed.refused, undefined);
103
+ assert.ok(doneAllowed.report, "done runs must still be allowed");
104
+ });
105
+
106
+ test("applyGraphCore materializes pending DB edits and reports counts plus drift", async (t) => {
107
+ const opened = await setupIndexedRepo();
108
+ if (!opened) return;
109
+ t.after(() => opened.cleanup());
110
+ const { root, store } = opened;
111
+ initState(root); // no active run → allowed
112
+
113
+ const next = "function add(a, b) { return a + b + 1; }\n";
114
+ const mutation = updateFile(store, { fileDir: ".", fileName: "math.js", text: next });
115
+ assert.equal(mutation.ok, true);
116
+ // Disk still has the original content before apply.
117
+ assert.equal(fs.readFileSync(path.join(root, "math.js"), "utf8"), "function add(a, b) { return a + b; }\n");
118
+
119
+ const core = await applyGraphCore(root);
120
+ assert.equal(core.refused, undefined);
121
+ assert.ok(core.report);
122
+ const counts: Record<string, number> = { ok: 0, deleted: 0, stale: 0, "skipped-missing": 0, error: 0 };
123
+ for (const file of core.report.files) counts[file.status] = (counts[file.status] ?? 0) + 1;
124
+ assert.ok(counts.ok >= 1, "at least the staged file must materialize");
125
+ assert.equal(fs.readFileSync(path.join(root, "math.js"), "utf8"), next);
126
+ assert.ok(core.drift);
127
+ assert.equal(core.drift.pending, 0);
128
+ assert.equal(core.drift.ok, true);
129
+ });
130
+
131
+ test("code_graph tool apply action: refiner env refuses, planning gate holds, wiring present", async (t) => {
132
+ const source = fs.readFileSync(path.join(ROOT, "tools", "code-graph.ts"), "utf8");
133
+ assert.match(source, /"apply"/);
134
+ assert.match(source, /PI_PLANS_REFINER/);
135
+ assert.match(source, /applyGraphCore/);
136
+
137
+ const opened = await setupIndexedRepo();
138
+ if (!opened) return;
139
+ t.after(() => opened.cleanup());
140
+ const { root } = opened;
141
+ initState(root);
142
+ const { run } = startRun(root, { topic: "apply-tool", skill: "plan-small", requestText: "x" });
143
+
144
+ let captured: {
145
+ execute: (id: string, params: any, signal: unknown, onUpdate: unknown, ctx: any) => Promise<{ content: Array<{ type: string; text: string }> }>;
146
+ } | undefined;
147
+ registerCodeGraphTool({
148
+ registerTool: (definition: never) => {
149
+ captured = definition as typeof captured;
150
+ },
151
+ } as never);
152
+ assert.ok(captured, "registerTool was not called");
153
+
154
+ // Refiner marker refuses before any run-state lookup.
155
+ const previous = process.env.PI_PLANS_REFINER;
156
+ process.env.PI_PLANS_REFINER = "1";
157
+ try {
158
+ const refused = await captured!.execute("c1", { action: "apply", workdir: root }, undefined, undefined, { cwd: root });
159
+ const payload = JSON.parse(refused.content[0]!.text) as { ok: boolean; reason: string };
160
+ assert.equal(payload.ok, false);
161
+ assert.match(payload.reason, /PI_PLANS_REFINER/);
162
+ } finally {
163
+ if (previous === undefined) delete process.env.PI_PLANS_REFINER;
164
+ else process.env.PI_PLANS_REFINER = previous;
165
+ }
166
+
167
+ // Planning run (without the marker) is refused by the run-state gate.
168
+ setRunStatus(root, run.run_id, "planning");
169
+ const gateRefused = await captured!.execute("c2", { action: "apply", workdir: root }, undefined, undefined, { cwd: root });
170
+ const gatePayload = JSON.parse(gateRefused.content[0]!.text) as { ok: boolean; reason: string };
171
+ assert.equal(gatePayload.ok, false);
172
+ assert.match(gatePayload.reason, /planning run is currently planning or accepted/);
173
+ });
@@ -99,6 +99,7 @@ describe("config-pi-plans command", () => {
99
99
  select: (question, labels) => {
100
100
  if (question === "Language?") return labels.find((label) => label.includes("en"));
101
101
  if (question === "Artifact root?") return labels.find((label) => label.includes("./.git/pi_plans/plans"));
102
+ if (question === "Refs root (plan-with-refs downloads)?") return labels.find((label) => label.includes(".git/pi-plans/refs"));
102
103
  if (question === "Code graph?") return labels.find((label) => label.includes("Disable code graph"));
103
104
  if (question === "Reviewer mode?") return labels.find((label) => label.includes("Switch to current-session"));
104
105
  if (question === "Reviewer model?") return labels.find((label) => label.includes("Use current session model (live/pro)"));
@@ -120,6 +121,9 @@ describe("config-pi-plans command", () => {
120
121
  assert.equal(config.language.source, "user");
121
122
  assert.equal(config.artifact_root, "./.git/pi_plans/plans");
122
123
  assert.equal(config.artifact_root_source, "user");
124
+ assert.equal(config.refs_root, ".git/pi-plans/refs");
125
+ assert.equal(config.refs_root_source, "user");
126
+ assert.ok(config.refs_root_updated_at);
123
127
  assert.equal(config.graph_enabled, false);
124
128
  assert.equal(config.reviewer.mode, "current-session");
125
129
  assert.equal(config.reviewer.model_selector, "live/pro");
@@ -141,6 +145,7 @@ describe("config-pi-plans command", () => {
141
145
  select: (question, labels) => {
142
146
  if (question === "Language?") return labels[0];
143
147
  if (question === "Artifact root?") return labels[0];
148
+ if (question === "Refs root (plan-with-refs downloads)?") return labels[0];
144
149
  if (question === "Code graph?") return labels[0];
145
150
  if (question === "Reviewer mode?") return labels[0];
146
151
  if (question === "Reviewer model?") return labels[0];
@@ -170,6 +175,7 @@ describe("config-pi-plans command", () => {
170
175
  select: (question, labels) => {
171
176
  if (question === "Language?") return labels[0];
172
177
  if (question === "Artifact root?") return labels[0];
178
+ if (question === "Refs root (plan-with-refs downloads)?") return labels[0];
173
179
  if (question === "Code graph?") return labels[0];
174
180
  if (question === "Reviewer mode?") return labels[0];
175
181
  if (question === "Reviewer model?") return labels.find((label) => label.includes("Other..."));
@@ -207,6 +213,7 @@ describe("config-pi-plans command", () => {
207
213
  select: (question, labels) => {
208
214
  if (question === "Language?") return labels[0];
209
215
  if (question === "Artifact root?") return labels[0];
216
+ if (question === "Refs root (plan-with-refs downloads)?") return labels[0];
210
217
  if (question === "Code graph?") return labels[0];
211
218
  if (question === "Reviewer mode?") return labels[0];
212
219
  if (question === "Reviewer model?") return labels[0];
@@ -233,6 +240,7 @@ describe("config-pi-plans command", () => {
233
240
  select: (question, labels) => {
234
241
  if (question === "Language?") return labels.find((label) => label.includes("en"));
235
242
  if (question === "Artifact root?") return labels.find((label) => label.includes("./.git/pi_plans/plans"));
243
+ if (question === "Refs root (plan-with-refs downloads)?") return labels.find((label) => label.includes(".git/pi-plans/refs"));
236
244
  if (question === "Code graph?") return labels.find((label) => label.includes("Enable code graph"));
237
245
  if (question === "Reviewer mode?") return labels.find((label) => label.includes("Keep delegated-subagent"));
238
246
  if (question === "Reviewer model?") return labels[0];
@@ -0,0 +1,137 @@
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 { InMemoryCredentialStore, createAssistantMessageEventStream } from "@earendil-works/pi-ai";
7
+ import { createAgentSession, DefaultResourceLoader, initTheme, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
8
+ import { Type } from "typebox";
9
+ import piPlansExtension from "../index.ts";
10
+ import { GOAL_WAIT_CUSTOM_TYPE, getExecution, startExecution } from "../src/exec.ts";
11
+
12
+ initTheme("dark", false);
13
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-lifecycle-"));
14
+ after(() => fs.rmSync(root, { recursive: true, force: true }));
15
+ let serial = 0;
16
+
17
+ async function exercise(mode: "tui" | "rpc" | "print" | "json", needsWake: boolean, commandResume = false) {
18
+ const cwd = path.join(root, String(++serial));
19
+ fs.mkdirSync(cwd);
20
+ const settingsManager = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false } });
21
+ const modelRuntime = await ModelRuntime.create({
22
+ credentials: new InMemoryCredentialStore(), modelsPath: null,
23
+ modelsStorePath: path.join(cwd, "models-store.json"), allowModelNetwork: false, refreshOnCreate: false,
24
+ });
25
+ const inputs: any[] = [];
26
+ let toolCalls = 0;
27
+ modelRuntime.registerProvider("local-lifecycle-test", {
28
+ baseUrl: "http://unused.invalid", api: "openai-completions", apiKey: "not-a-real-key",
29
+ models: [{ id: "fixture", name: "fixture", reasoning: false, input: ["text"],
30
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 100000, maxTokens: 1024 }],
31
+ streamSimple: (model: any, context: any) => {
32
+ inputs.push(structuredClone({ messages: context.messages, systemPrompt: context.systemPrompt }));
33
+ const call = inputs.length;
34
+ assert.ok(call <= 5, "unexpected extra model invocation");
35
+ const tool = call <= 2;
36
+ const text = call === 3
37
+ ? needsWake ? "[DONE:VC-001] More verification remains." : "[DONE:VC-001] [DONE:VC-002]"
38
+ : call === 4 && needsWake ? "[DONE:VC-002]" : "Review awaits explicit user approval.";
39
+ const message: any = {
40
+ role: "assistant", api: model.api, provider: model.provider, model: model.id,
41
+ content: tool ? [
42
+ ...(commandResume && call === 2 ? [{ type: "text", text: "[DONE:VC-001]" }] : []),
43
+ { type: "toolCall", id: `call-${call}`, name: "probe", arguments: {} },
44
+ ] : [{ type: "text", text: commandResume && call === 3 ? "Interrupted." : text }],
45
+ stopReason: tool ? "toolUse" : commandResume && call === 3 ? "aborted" : "stop", timestamp: Date.now(),
46
+ usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, totalTokens: 15,
47
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
48
+ };
49
+ const stream = createAssistantMessageEventStream();
50
+ stream.push({ type: "start", partial: message });
51
+ if (message.stopReason === "aborted") stream.push({ type: "error", reason: "aborted", error: message });
52
+ else stream.push({ type: "done", reason: message.stopReason, message });
53
+ stream.end(message);
54
+ return stream;
55
+ },
56
+ });
57
+ let beforeAgentStarts = 0;
58
+ const events: string[] = [];
59
+ const errors: string[] = [];
60
+ const loader = new DefaultResourceLoader({
61
+ cwd, agentDir: path.join(cwd, "agent"), settingsManager,
62
+ noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true,
63
+ agentsFilesOverride: () => ({ agentsFiles: [] }), systemPromptOverride: () => "Deterministic test.",
64
+ extensionFactories: [piPlansExtension, pi => {
65
+ pi.on("before_agent_start", () => { beforeAgentStarts++; });
66
+ pi.on("session_start", async (_event, ctx) => {
67
+ await startExecution(pi, ctx, path.join(cwd, "PLAN_v1.md"), [
68
+ { id: "VC-001", text: "first", done: false }, { id: "VC-002", text: "second", done: false },
69
+ ]);
70
+ });
71
+ }],
72
+ });
73
+ await loader.reload();
74
+ assert.deepEqual(loader.getExtensions().errors, []);
75
+ const { session } = await createAgentSession({
76
+ cwd, agentDir: path.join(cwd, "agent"), modelRuntime,
77
+ model: modelRuntime.getModel("local-lifecycle-test", "fixture")!, thinkingLevel: "off",
78
+ resourceLoader: loader, settingsManager, sessionManager: SessionManager.inMemory(cwd), tools: ["probe"],
79
+ customTools: [{ name: "probe", label: "Probe", description: "Local test probe", parameters: Type.Object({}),
80
+ execute: async () => { toolCalls++; return { content: [{ type: "text", text: "ok" }], details: {} }; } }],
81
+ });
82
+ const unsubscribe = session.subscribe(event => events.push(event.type));
83
+ try {
84
+ await session.bindExtensions({
85
+ mode,
86
+ ...(mode === "tui" || mode === "rpc" ? { uiContext: {
87
+ setStatus: () => {}, notify: () => {}, theme: { fg: (_c: string, s: string) => s },
88
+ } as any } : {}),
89
+ onError: error => errors.push(error.error),
90
+ });
91
+ await session.prompt("Implement the test plan.");
92
+ await session.waitForIdle();
93
+ if (commandResume) {
94
+ assert.equal(getExecution()?.goalWait?.paused, true);
95
+ assert.deepEqual(getExecution()?.items.map(item => item.done), [true, false]);
96
+ assert.equal(inputs.length, 3);
97
+ await session.prompt("/plans-execute");
98
+ }
99
+ // SDK callers, unlike print mode, own the runtime until all nested wakes settle.
100
+ await session.waitForIdle();
101
+ assert.deepEqual(errors, []);
102
+ assert.deepEqual(session.messages.filter((m: any) => m.role === "assistant" && m.stopReason === "error"), [], "fixture model must run successfully");
103
+ const wakes = session.messages.filter((m: any) => m.customType === GOAL_WAIT_CUSTOM_TYPE) as any[];
104
+ assert.equal(toolCalls, 2);
105
+ assert.equal(beforeAgentStarts, 1, "custom wake must work without before_agent_start");
106
+ const interactive = mode === "tui" || mode === "rpc";
107
+ assert.equal(wakes.length, interactive && needsWake ? 1 : 0);
108
+ assert.equal(inputs.length, interactive ? needsWake ? 5 : 4 : 3);
109
+ if (interactive && needsWake) {
110
+ assert.equal(wakes[0].display, false);
111
+ assert.match(JSON.stringify(inputs[3].messages), /1\/2 verifier items done/);
112
+ assert.match(wakes[0].content, /- `VC-002` second/);
113
+ assert.doesNotMatch(wakes[0].content, /- `VC-001` first/);
114
+ }
115
+ if (interactive || !needsWake) assert.equal(getExecution(), null);
116
+ else assert.deepEqual(getExecution()?.items.map(item => item.done), [true, false]);
117
+ assert.equal(session.pendingMessageCount, 0);
118
+ assert.equal(events.at(-1), "agent_settled");
119
+ return { events, calls: inputs.length, wakes: wakes.length };
120
+ } finally {
121
+ unsubscribe();
122
+ session.dispose();
123
+ }
124
+ }
125
+
126
+ describe("goal-wait on the real Pi host", () => {
127
+ it("dispatches the registered /plans-execute command and preserves completed VCs", { timeout: 15000 }, async () => {
128
+ await exercise("rpc", true, true);
129
+ });
130
+ for (const mode of ["tui", "rpc", "print", "json"] as const) {
131
+ for (const needsWake of [false, true]) {
132
+ it(`${mode}: tools then ${needsWake ? "incomplete stop" : "completion"}`, { timeout: 15000 }, async () => {
133
+ await exercise(mode, needsWake);
134
+ });
135
+ }
136
+ }
137
+ });