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.
- package/README.md +22 -11
- package/agents/ref-analyst.md +18 -0
- package/index.ts +12 -14
- package/package.json +11 -2
- package/references/pi-planning-workflow.md +26 -5
- package/references/state-and-config.md +21 -6
- package/scripts/validate.ts +1 -0
- package/skills/plan-with-refs/SKILL.md +3 -3
- package/src/code-graph/commands.ts +73 -27
- package/src/code-graph/prompts.ts +1 -1
- package/src/config-command.ts +35 -0
- package/src/exec.ts +237 -5
- package/src/guard.ts +14 -1
- package/src/refine-prompts.ts +59 -0
- package/src/refine-ui-state.ts +1 -1
- package/src/refine-ui.ts +1 -1
- package/src/state.ts +17 -1
- package/src/subagent.ts +1 -0
- package/src/termination-prompt.ts +22 -0
- package/tests/analyze-refs.test.ts +265 -0
- package/tests/ask-choice.test.ts +1 -0
- package/tests/code-graph-apply-action.test.ts +173 -0
- package/tests/config-command.test.ts +8 -0
- package/tests/exec-lifecycle.test.ts +137 -0
- package/tests/exec.test.ts +15 -2
- package/tests/goal-wait.test.ts +269 -0
- package/tests/guard.test.ts +27 -1
- package/tests/plans.test.ts +10 -0
- package/tests/refine-prompts.test.ts +35 -1
- package/tests/refine-ui.test.ts +34 -0
- package/tests/state.test.ts +32 -0
- package/tests/subagent.test.ts +22 -0
- package/tools/analyze-refs.ts +263 -0
- package/tools/ask-choice.ts +2 -1
- package/tools/code-graph.ts +25 -2
- package/tools/execute-plan.ts +18 -0
- package/tools/graph-aware-file-tools.ts +2 -2
- package/tools/plans.ts +14 -2
|
@@ -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
|
+
});
|
package/tests/ask-choice.test.ts
CHANGED
|
@@ -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
|
+
});
|