pi-plans 0.6.0 → 0.8.0
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/AGENTS.md +58 -0
- package/CONTRIBUTING.md +8 -15
- package/README.md +39 -37
- package/agents/execution-reviewer.md +40 -0
- package/agents/reviewer.md +12 -3
- package/index.ts +55 -58
- package/package.json +2 -1
- package/references/pi-planning-workflow.md +50 -60
- package/references/plan-artifact-template.md +81 -60
- package/references/state-and-config.md +60 -44
- package/scripts/bench/pi-adapter/pi_plans_bench.py +33 -22
- package/scripts/bench/pi-adapter/rpc_driver.mjs +4 -4
- package/scripts/run-tests.ts +12 -1
- package/scripts/validate.ts +39 -11
- package/skills/debug-and-plan/SKILL.md +3 -3
- package/skills/plan-big/SKILL.md +3 -3
- package/skills/plan-normal/SKILL.md +3 -3
- package/skills/plan-small/SKILL.md +4 -4
- package/skills/plan-with-refs/SKILL.md +6 -6
- package/skills/planning/SKILL.md +1 -1
- package/src/ask-form.ts +4 -4
- package/src/auditor.ts +227 -0
- package/src/auto-approve.ts +1 -1
- package/src/autocomplete.ts +19 -17
- package/src/code-graph/commands.ts +8 -3
- package/src/code-graph/community.ts +1 -1
- package/src/code-graph/paths.ts +1 -1
- package/src/code-graph/watch.ts +2 -2
- package/src/compaction.ts +3 -3
- package/src/config-command.ts +146 -73
- package/src/dashboard.ts +303 -0
- package/src/exec.ts +1185 -924
- package/src/global-state.ts +304 -0
- package/src/guard.ts +18 -19
- package/src/messaging.ts +44 -0
- package/src/plan.ts +421 -112
- package/src/query-hook.ts +4 -4
- package/src/refine-prompts.ts +12 -70
- package/src/refine-ui-helpers.ts +24 -5
- package/src/refine-ui-state.ts +1 -1
- package/src/refine-ui.ts +19 -3
- package/src/resume-command.ts +45 -129
- package/src/resume.ts +5 -1
- package/src/role-panels.ts +542 -0
- package/src/run-context.ts +3 -10
- package/src/staleness.ts +53 -0
- package/src/state.ts +273 -72
- package/src/subagent.ts +19 -29
- package/src/task-tool.ts +100 -0
- package/src/tasks.ts +223 -0
- package/src/thinking-levels.ts +67 -0
- package/src/ui-language.ts +7 -54
- package/src/workflow-state.ts +76 -58
- package/tests/analyze-refs.test.ts +35 -18
- package/tests/ask-choice-schema.test.ts +0 -12
- package/tests/ask-choice.test.ts +2 -49
- package/tests/ask-form-tool.test.ts +4 -5
- package/tests/ask-form.test.ts +2 -2
- package/tests/auditor.test.ts +210 -0
- package/tests/auto-approve.test.ts +7 -10
- package/tests/autocomplete.test.ts +8 -11
- package/tests/code-graph-apply-action.test.ts +2 -2
- package/tests/code-graph-commands.test.ts +2 -2
- package/tests/code-graph-index.test.ts +2 -2
- package/tests/code-graph-loop.e2e.test.ts +1 -1
- package/tests/code-graph-mutations.test.ts +1 -1
- package/tests/code-graph-rollback.test.ts +1 -1
- package/tests/code-graph-v05.test.ts +2 -2
- package/tests/compaction.test.ts +1 -1
- package/tests/config-command.test.ts +103 -100
- package/tests/dashboard.test.ts +402 -0
- package/tests/exec-lifecycle.test.ts +181 -115
- package/tests/exec-panel-lifecycle.test.ts +106 -251
- package/tests/exec-review-loop.test.ts +331 -0
- package/tests/exec.test.ts +771 -1706
- package/tests/execute-plan.test.ts +44 -19
- package/tests/extension-load.test.ts +48 -0
- package/tests/global-state.test.ts +371 -0
- package/tests/graph-aware-file-tools.test.ts +5 -5
- package/tests/guard.test.ts +1 -1
- package/tests/multi-run.test.ts +3 -103
- package/tests/plan.test.ts +139 -62
- package/tests/plans.test.ts +7 -79
- package/tests/refine-prompts.test.ts +20 -71
- package/tests/refine-resume.test.ts +27 -22
- package/tests/refine-ui.test.ts +6 -15
- package/tests/resume-lifecycle.test.ts +41 -22
- package/tests/resume.test.ts +39 -81
- package/tests/role-panels.test.ts +391 -0
- package/tests/run-context.test.ts +1 -1
- package/tests/run-ownership.test.ts +1 -1
- package/tests/stale-ctx.test.ts +218 -0
- package/tests/staleness.test.ts +76 -0
- package/tests/state.test.ts +155 -32
- package/tests/subagent-thinking.test.ts +65 -0
- package/tests/subagent-usage.test.ts +1 -1
- package/tests/task-tool.test.ts +61 -0
- package/tests/tasks.test.ts +142 -0
- package/tests/thinking-levels.test.ts +77 -0
- package/tests/ui-language.test.ts +2 -17
- package/tests/workflow-state.test.ts +73 -90
- package/tools/analyze-refs.ts +67 -32
- package/tools/ask-choice.ts +7 -53
- package/tools/code-graph.ts +2 -2
- package/tools/execute-plan.ts +55 -99
- package/tools/graph-aware-file-tools.ts +4 -10
- package/tools/plans.ts +41 -67
- package/tools/refine.ts +101 -164
- package/agents/criticizer.md +0 -18
- package/agents/executor.md +0 -26
- package/scripts/bench/pi-adapter/__pycache__/pi_plans_bench.cpython-312.pyc +0 -0
- package/src/panel.ts +0 -473
- package/src/termination-prompt.ts +0 -73
- package/tests/goal-wait.test.ts +0 -269
- package/tests/panel-i-zero.test.ts +0 -420
- package/tests/panel.test.ts +0 -355
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Tests for the stale-extension probe shared by /plans and the completion
|
|
2
|
+
* auditor. */
|
|
3
|
+
|
|
4
|
+
import * as assert from "node:assert/strict";
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as os from "node:os";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import { after, before, describe, it } from "node:test";
|
|
9
|
+
import { newestSourceMtime, staleReloadHint, stalenessLine } from "../src/staleness.ts";
|
|
10
|
+
|
|
11
|
+
let root: string;
|
|
12
|
+
|
|
13
|
+
before(() => {
|
|
14
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-stale-"));
|
|
15
|
+
fs.mkdirSync(path.join(root, "src"));
|
|
16
|
+
fs.mkdirSync(path.join(root, "tools"));
|
|
17
|
+
fs.writeFileSync(path.join(root, "index.ts"), "export const x = 1;\n");
|
|
18
|
+
fs.writeFileSync(path.join(root, "src", "a.ts"), "export const a = 1;\n");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
after(() => {
|
|
22
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** Set every scanned file's mtime to `when`. */
|
|
26
|
+
function touchAll(when: number): void {
|
|
27
|
+
for (const rel of ["index.ts", "src/a.ts", "tools"]) {
|
|
28
|
+
const full = path.join(root, rel);
|
|
29
|
+
if (fs.statSync(full).isDirectory()) {
|
|
30
|
+
fs.writeFileSync(path.join(full, "b.ts"), "export const b = 1;\n");
|
|
31
|
+
fs.utimesSync(path.join(full, "b.ts"), when / 1000, when / 1000);
|
|
32
|
+
} else {
|
|
33
|
+
fs.utimesSync(full, when / 1000, when / 1000);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("staleness probe", () => {
|
|
39
|
+
it("finds the newest .ts mtime across the root, src and tools", () => {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
touchAll(now - 60_000);
|
|
42
|
+
const newest = newestSourceMtime(root);
|
|
43
|
+
assert.ok(newest >= now - 61_000 && newest <= now + 1_000, `unexpected mtime ${newest}`);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("reports the copy as current when disk is older than the load time", () => {
|
|
47
|
+
touchAll(Date.now() - 60_000);
|
|
48
|
+
const line = stalenessLine(root, new Date());
|
|
49
|
+
assert.match(line, /up to date/);
|
|
50
|
+
assert.equal(staleReloadHint(root, new Date()), null, "no /reload advice when current");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("reports the copy as stale and advises /reload when disk is newer", () => {
|
|
54
|
+
const future = new Date(Date.now() + 10 * 60_000);
|
|
55
|
+
touchAll(future.getTime());
|
|
56
|
+
const line = stalenessLine(root, new Date(Date.now() - 60_000));
|
|
57
|
+
assert.match(line, /newer than the loaded copy/);
|
|
58
|
+
assert.match(line, /run \/reload/);
|
|
59
|
+
const hint = staleReloadHint(root, new Date(Date.now() - 60_000));
|
|
60
|
+
assert.ok(hint && hint.includes("/reload"), "the hint carries the /reload advice");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("tolerates a same-second write instead of calling it stale", () => {
|
|
64
|
+
// Without the 2s tolerance, a file written moments after load reads as
|
|
65
|
+
// staleness and users chase a /reload that changes nothing.
|
|
66
|
+
touchAll(Date.now());
|
|
67
|
+
const line = stalenessLine(root, new Date());
|
|
68
|
+
assert.match(line, /up to date/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("degrades to a loaded-timestamp line when the tree cannot be walked", () => {
|
|
72
|
+
const line = stalenessLine(path.join(root, "does-not-exist"), new Date("2026-01-01T00:00:00.000Z"));
|
|
73
|
+
assert.match(line, /Extension loaded: 2026-01-01T00:00:00.000Z/);
|
|
74
|
+
assert.equal(staleReloadHint(path.join(root, "does-not-exist"), new Date()), null);
|
|
75
|
+
});
|
|
76
|
+
});
|
package/tests/state.test.ts
CHANGED
|
@@ -5,12 +5,13 @@ import { spawnSync } from "node:child_process";
|
|
|
5
5
|
import * as fs from "node:fs";
|
|
6
6
|
import * as os from "node:os";
|
|
7
7
|
import * as path from "node:path";
|
|
8
|
-
import { after, before, describe, it } from "node:test";
|
|
8
|
+
import { after, before, beforeEach, describe, it } from "node:test";
|
|
9
9
|
import {
|
|
10
10
|
initState,
|
|
11
11
|
getRun,
|
|
12
12
|
readActive,
|
|
13
13
|
recordDecision,
|
|
14
|
+
resolveArtifactRoot,
|
|
14
15
|
setLanguage,
|
|
15
16
|
setArtifactRoot,
|
|
16
17
|
setRefsRoot,
|
|
@@ -45,7 +46,7 @@ function commonDir(workdir: string): string {
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
function readConfig(workdir: string): Record<string, any> {
|
|
48
|
-
return JSON.parse(fs.readFileSync(path.join(commonDir(workdir), "
|
|
49
|
+
return JSON.parse(fs.readFileSync(path.join(commonDir(workdir), "pi-plans", "config.json"), "utf8"));
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
before(() => {
|
|
@@ -62,16 +63,17 @@ describe("init", () => {
|
|
|
62
63
|
const workdir = mkWorkdir("fresh");
|
|
63
64
|
const result = initState(workdir);
|
|
64
65
|
assert.ok(result.notices.some((notice) => notice.includes("git init")));
|
|
65
|
-
const state = path.join(workdir, ".git", "
|
|
66
|
-
assert.equal(path.join(commonDir(workdir), "
|
|
66
|
+
const state = path.join(workdir, ".git", "pi-plans");
|
|
67
|
+
assert.equal(path.join(commonDir(workdir), "pi-plans"), state);
|
|
67
68
|
const config = JSON.parse(fs.readFileSync(path.join(state, "config.json"), "utf8"));
|
|
68
69
|
assert.equal(config.schema, 1);
|
|
69
70
|
assert.equal(config.language.tag, null);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
assert.equal(config
|
|
71
|
+
// v0.7.0: the reviewer role lives in the GLOBAL config, never in the
|
|
72
|
+
// workspace config (the legacy key is stripped on every write).
|
|
73
|
+
assert.equal("reviewer" in config, false);
|
|
74
|
+
assert.equal("criticizer" in config, false);
|
|
73
75
|
assert.equal("execution" in config, false);
|
|
74
|
-
assert.equal(config.artifact_root, "
|
|
76
|
+
assert.equal(config.artifact_root, "./.git/pi-plans/plans");
|
|
75
77
|
assert.equal(config.artifact_root_source, "unset");
|
|
76
78
|
assert.equal(config.artifact_root_updated_at, null);
|
|
77
79
|
assert.equal(config.refs_root, null);
|
|
@@ -82,7 +84,7 @@ describe("init", () => {
|
|
|
82
84
|
it("normalizes old configs missing the refs_root trio", () => {
|
|
83
85
|
const workdir = mkWorkdir("refs-root-normalize");
|
|
84
86
|
initState(workdir);
|
|
85
|
-
const configPath = path.join(commonDir(workdir), "
|
|
87
|
+
const configPath = path.join(commonDir(workdir), "pi-plans", "config.json");
|
|
86
88
|
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
87
89
|
delete config.refs_root;
|
|
88
90
|
delete config.refs_root_source;
|
|
@@ -107,19 +109,54 @@ describe("init", () => {
|
|
|
107
109
|
assert.equal(shown.refs_root_source, "user");
|
|
108
110
|
});
|
|
109
111
|
|
|
110
|
-
it("
|
|
112
|
+
it("resolves a .git/-prefixed artifact root against the git common dir", () => {
|
|
113
|
+
const workdir = mkWorkdir("artifact-root-gitdir");
|
|
114
|
+
initState(workdir);
|
|
115
|
+
// A plain relative root stays workdir-relative.
|
|
116
|
+
assert.equal(resolveArtifactRoot(workdir, "./docs/pi-plans"), path.join(workdir, "docs", "pi-plans"));
|
|
117
|
+
// A `.git/` root lands in the common dir, not `<workdir>/.git/...`.
|
|
118
|
+
assert.equal(resolveArtifactRoot(workdir, "./.git/pi-plans/plans"), path.join(commonDir(workdir), "pi-plans", "plans"));
|
|
119
|
+
assert.equal(resolveArtifactRoot(workdir, ".git/pi-plans/plans"), path.join(commonDir(workdir), "pi-plans", "plans"));
|
|
120
|
+
// Absolute roots pass through.
|
|
121
|
+
assert.equal(resolveArtifactRoot(workdir, "/tmp/abs-root"), "/tmp/abs-root");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("default artifact root lands in the state dir of a linked worktree", () => {
|
|
125
|
+
const main = mkWorkdir("wt-main");
|
|
126
|
+
git(main, "init", "-q");
|
|
127
|
+
// Older git (<=2.41, e.g. Apple Git 2.39.2) cannot `worktree add` from an
|
|
128
|
+
// unborn HEAD ("not a valid object name: 'HEAD'"); git >=2.42 DWIMs the
|
|
129
|
+
// add to --orphan. Seed one commit (inline identity keeps this hermetic;
|
|
130
|
+
// same commit-then-worktree-add pattern as tests/resume.test.ts) so both
|
|
131
|
+
// generations behave the same.
|
|
132
|
+
git(main, "-c", "user.name=MaxInGaussian", "-c", "user.email=maxingaussian@gmail.com", "commit", "--allow-empty", "-m", "init");
|
|
133
|
+
initState(main);
|
|
134
|
+
const linked = path.join(tmpRoot, "wt-linked");
|
|
135
|
+
git(main, "worktree", "add", linked, "-b", "wt-branch");
|
|
136
|
+
// A linked worktree's `.git` is a file, so the default must resolve via
|
|
137
|
+
// the common dir or mkdirSync would fail with ENOTDIR. Compare on the
|
|
138
|
+
// realpath: on macOS /var is a symlink to /private/var and `git rev-parse`
|
|
139
|
+
// reports the short form while the resolver returns the real one.
|
|
140
|
+
const expected = path.join(fs.realpathSync(commonDir(main)), "pi-plans", "plans");
|
|
141
|
+
const { run } = startRun(linked, { topic: "linked run", skill: "plan-small", requestText: "x" });
|
|
142
|
+
assert.equal(path.dirname(run.artifact_dir), expected);
|
|
143
|
+
assert.ok(fs.existsSync(run.artifact_dir), "artifact directory created inside the shared state dir");
|
|
144
|
+
assert.equal(getRun(linked, run.run_id)?.artifact_dir, run.artifact_dir);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("migrates legacy artifact roots to ./.git/pi-plans/plans", () => {
|
|
111
148
|
const workdir = mkWorkdir("artifact-root-migration");
|
|
112
149
|
initState(workdir);
|
|
113
|
-
const configPath = path.join(commonDir(workdir), "
|
|
150
|
+
const configPath = path.join(commonDir(workdir), "pi-plans", "config.json");
|
|
114
151
|
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
115
152
|
config.artifact_root = "docs/plans";
|
|
116
153
|
delete config.artifact_root_source;
|
|
117
154
|
delete config.artifact_root_updated_at;
|
|
118
155
|
fs.writeFileSync(configPath, `${JSON.stringify(config, null, "\t")}\n`, "utf8");
|
|
119
156
|
const updated = initState(workdir);
|
|
120
|
-
assert.equal(updated.config.artifact_root, "
|
|
157
|
+
assert.equal(updated.config.artifact_root, "./.git/pi-plans/plans");
|
|
121
158
|
assert.equal(updated.config.artifact_root_source, "unset");
|
|
122
|
-
assert.equal(readConfig(workdir).artifact_root, "
|
|
159
|
+
assert.equal(readConfig(workdir).artifact_root, "./.git/pi-plans/plans");
|
|
123
160
|
assert.equal(readConfig(workdir).artifact_root_source, "unset");
|
|
124
161
|
});
|
|
125
162
|
|
|
@@ -159,21 +196,21 @@ describe("init", () => {
|
|
|
159
196
|
fs.mkdirSync(sub, { recursive: true });
|
|
160
197
|
const result = initState(sub);
|
|
161
198
|
assert.ok(result.notices.some((notice) => notice.includes("enclosing repository")));
|
|
162
|
-
assert.ok(fs.existsSync(path.join(commonDir(repo), "
|
|
199
|
+
assert.ok(fs.existsSync(path.join(commonDir(repo), "pi-plans", "config.json")));
|
|
163
200
|
assert.ok(!fs.existsSync(path.join(sub, ".git")));
|
|
164
201
|
});
|
|
165
202
|
|
|
166
|
-
it("supports private planning docs under .git/
|
|
203
|
+
it("supports private planning docs under .git/pi-plans/plans", () => {
|
|
167
204
|
const workdir = mkWorkdir("private-artifact-root");
|
|
168
|
-
setArtifactRoot(workdir, "./.git/
|
|
205
|
+
setArtifactRoot(workdir, "./.git/pi-plans/plans", "user");
|
|
169
206
|
const { run } = startRun(workdir, {
|
|
170
207
|
topic: "Private Docs",
|
|
171
208
|
skill: "plan-small",
|
|
172
209
|
requestText: "Plan the example",
|
|
173
210
|
});
|
|
174
211
|
assert.ok(fs.statSync(run.artifact_dir).isDirectory());
|
|
175
|
-
assert.equal(readConfig(workdir).artifact_root, "./.git/
|
|
176
|
-
assert.equal(run.artifact_dir, path.join(workdir, ".git", "
|
|
212
|
+
assert.equal(readConfig(workdir).artifact_root, "./.git/pi-plans/plans");
|
|
213
|
+
assert.equal(run.artifact_dir, path.join(workdir, ".git", "pi-plans", "plans", `${run.created_at.slice(0, 10)}-private-docs`));
|
|
177
214
|
});
|
|
178
215
|
|
|
179
216
|
it("scrubs leaked GIT_DIR env", () => {
|
|
@@ -191,8 +228,8 @@ describe("init", () => {
|
|
|
191
228
|
encoding: "utf8",
|
|
192
229
|
});
|
|
193
230
|
assert.equal(result.status, 0, result.stderr ?? "");
|
|
194
|
-
assert.ok(fs.existsSync(path.join(workdir, ".git", "
|
|
195
|
-
assert.ok(!fs.existsSync(path.join(repoA, ".git", "
|
|
231
|
+
assert.ok(fs.existsSync(path.join(workdir, ".git", "pi-plans", "config.json")));
|
|
232
|
+
assert.ok(!fs.existsSync(path.join(repoA, ".git", "pi-plans")));
|
|
196
233
|
});
|
|
197
234
|
|
|
198
235
|
it("refuses broken .git entries and bare repos", () => {
|
|
@@ -226,6 +263,10 @@ describe("runs", () => {
|
|
|
226
263
|
const updated = setRunStatus(workdir, run.run_id, "accepted");
|
|
227
264
|
assert.equal(updated.status, "accepted");
|
|
228
265
|
assert.equal(getRun(workdir, run.run_id)?.status, "accepted");
|
|
266
|
+
// v0.8: verifying is a valid non-terminal lifecycle status (execution-review loop).
|
|
267
|
+
const verifying = setRunStatus(workdir, run.run_id, "verifying");
|
|
268
|
+
assert.equal(verifying.status, "verifying");
|
|
269
|
+
assert.equal(getRun(workdir, run.run_id)?.status, "verifying");
|
|
229
270
|
assert.throws(() => setRunStatus(workdir, run.run_id, "bogus"), StateError);
|
|
230
271
|
});
|
|
231
272
|
|
|
@@ -256,8 +297,6 @@ describe("runs", () => {
|
|
|
256
297
|
artifact_root_updated_at: new Date().toISOString(),
|
|
257
298
|
schema: 1,
|
|
258
299
|
language: { tag: null, source: "unset", updated_at: null },
|
|
259
|
-
reviewer: { mode: "delegated-subagent", model_selector: null, name_prefix: "pi-plans-reviewer", confirmed_at: null },
|
|
260
|
-
criticizer: { mode: "delegated-subagent", model_selector: null, name_prefix: "pi-plans-criticizer", confirmed_at: null },
|
|
261
300
|
execution: { model_selector: null, source: "unset", updated_at: null },
|
|
262
301
|
}, {
|
|
263
302
|
topic: "" as string,
|
|
@@ -275,7 +314,7 @@ describe("legacy execution config", () => {
|
|
|
275
314
|
const workdir = mkWorkdir("legacy-exec");
|
|
276
315
|
git(workdir, "init", "-q");
|
|
277
316
|
initState(workdir);
|
|
278
|
-
const configPath = path.join(commonDir(workdir), "
|
|
317
|
+
const configPath = path.join(commonDir(workdir), "pi-plans", "config.json");
|
|
279
318
|
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
280
319
|
config.execution = { model_selector: "zai/glm-5.3-flash:high", source: "user", updated_at: null };
|
|
281
320
|
fs.writeFileSync(configPath, `${JSON.stringify(config, null, "\t")}\n`, "utf8");
|
|
@@ -286,14 +325,40 @@ describe("legacy execution config", () => {
|
|
|
286
325
|
});
|
|
287
326
|
});
|
|
288
327
|
|
|
289
|
-
describe("set-role invariants", () => {
|
|
328
|
+
describe("set-role invariants (global config)", () => {
|
|
329
|
+
// Each test gets its own throwaway global dir so tests never touch the
|
|
330
|
+
// developer's real ~/.pi/pi-plans/config.json (F-002).
|
|
331
|
+
let globalDir: string;
|
|
332
|
+
let previousGlobalDir: string | undefined;
|
|
333
|
+
|
|
334
|
+
before(() => {
|
|
335
|
+
previousGlobalDir = process.env.PI_PLANS_GLOBAL_DIR;
|
|
336
|
+
globalDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-global-test-"));
|
|
337
|
+
process.env.PI_PLANS_GLOBAL_DIR = globalDir;
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
beforeEach(() => {
|
|
341
|
+
// Each case starts from a pristine global file (no cross-test seeding).
|
|
342
|
+
fs.rmSync(path.join(globalDir, "config.json"), { force: true });
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
after(() => {
|
|
346
|
+
if (previousGlobalDir === undefined) delete process.env.PI_PLANS_GLOBAL_DIR;
|
|
347
|
+
else process.env.PI_PLANS_GLOBAL_DIR = previousGlobalDir;
|
|
348
|
+
fs.rmSync(globalDir, { recursive: true, force: true });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
function readGlobal(): Record<string, any> {
|
|
352
|
+
return JSON.parse(fs.readFileSync(path.join(globalDir, "config.json"), "utf8"));
|
|
353
|
+
}
|
|
354
|
+
|
|
290
355
|
it("mode-only edits never forge or discard confirmations", () => {
|
|
291
356
|
const workdir = mkWorkdir("roles");
|
|
292
357
|
git(workdir, "init", "-q");
|
|
293
358
|
initState(workdir);
|
|
294
359
|
|
|
295
360
|
setRole(workdir, { role: "reviewer", mode: "current-session" });
|
|
296
|
-
let role =
|
|
361
|
+
let role = readGlobal().reviewer;
|
|
297
362
|
assert.equal(role.mode, "current-session");
|
|
298
363
|
assert.equal(role.confirmed_at, null);
|
|
299
364
|
|
|
@@ -303,29 +368,87 @@ describe("set-role invariants", () => {
|
|
|
303
368
|
modelSelector: "deepseek/deepseek-v4-flash",
|
|
304
369
|
confirmed: true,
|
|
305
370
|
});
|
|
306
|
-
role =
|
|
371
|
+
role = readGlobal().reviewer;
|
|
307
372
|
assert.equal(role.model_selector, "deepseek/deepseek-v4-flash");
|
|
308
373
|
const stamped = role.confirmed_at;
|
|
309
374
|
assert.ok(stamped);
|
|
375
|
+
// No workspace reviewer key: the global config is the only home.
|
|
376
|
+
assert.equal("reviewer" in readConfig(workdir), false);
|
|
310
377
|
|
|
311
378
|
setRole(workdir, { role: "reviewer", mode: "current-session" });
|
|
312
|
-
role =
|
|
379
|
+
role = readGlobal().reviewer;
|
|
313
380
|
assert.equal(role.mode, "current-session");
|
|
314
381
|
assert.equal(role.model_selector, "deepseek/deepseek-v4-flash");
|
|
315
382
|
assert.equal(role.confirmed_at, stamped);
|
|
316
383
|
|
|
317
384
|
setRole(workdir, { role: "reviewer", resetConfirmation: true });
|
|
318
|
-
role =
|
|
385
|
+
role = readGlobal().reviewer;
|
|
319
386
|
assert.equal(role.confirmed_at, null);
|
|
320
387
|
assert.equal(role.model_selector, "deepseek/deepseek-v4-flash");
|
|
321
388
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
389
|
+
assert.throws(() => setRole(workdir, { role: "reviewer", confirmed: true, resetConfirmation: true }), StateError);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("confirmed requires a concrete selector in delegated mode", () => {
|
|
393
|
+
const workdir = mkWorkdir("roles-confirm");
|
|
394
|
+
git(workdir, "init", "-q");
|
|
395
|
+
initState(workdir);
|
|
396
|
+
assert.throws(
|
|
397
|
+
() => setRole(workdir, { role: "reviewer", confirmed: true }),
|
|
398
|
+
/exact provider\/model selector/,
|
|
399
|
+
);
|
|
400
|
+
assert.equal(fs.existsSync(path.join(globalDir, "config.json")), false);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("'inherit' is a full reset and never confirmable", () => {
|
|
404
|
+
const workdir = mkWorkdir("roles-inherit");
|
|
405
|
+
git(workdir, "init", "-q");
|
|
406
|
+
initState(workdir);
|
|
407
|
+
setRole(workdir, { role: "reviewer", modelSelector: "devin/claude-sonnet-5.5", thinkingLevel: "high", confirmed: true });
|
|
408
|
+
setRole(workdir, { role: "reviewer", modelSelector: "inherit" });
|
|
409
|
+
const role = readGlobal().reviewer;
|
|
325
410
|
assert.equal(role.model_selector, null);
|
|
411
|
+
assert.equal(role.confirmed_at, null);
|
|
412
|
+
assert.throws(
|
|
413
|
+
() => setRole(workdir, { role: "reviewer", modelSelector: "inherit", confirmed: true }),
|
|
414
|
+
/cannot be combined with confirmed/,
|
|
415
|
+
);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("current-session mode is confirmable without a model", () => {
|
|
419
|
+
const workdir = mkWorkdir("roles-current-session");
|
|
420
|
+
git(workdir, "init", "-q");
|
|
421
|
+
initState(workdir);
|
|
422
|
+
setRole(workdir, { role: "reviewer", mode: "current-session", confirmed: true });
|
|
423
|
+
const role = readGlobal().reviewer;
|
|
424
|
+
assert.equal(role.mode, "current-session");
|
|
326
425
|
assert.ok(role.confirmed_at);
|
|
426
|
+
assert.equal(role.model_selector, null);
|
|
427
|
+
});
|
|
327
428
|
|
|
328
|
-
|
|
429
|
+
it("thinkingLevel: 'default' stores null; model switch resets the level unless passed", () => {
|
|
430
|
+
const workdir = mkWorkdir("roles-thinking");
|
|
431
|
+
git(workdir, "init", "-q");
|
|
432
|
+
initState(workdir);
|
|
433
|
+
setRole(workdir, { role: "reviewer", modelSelector: "devin/glm-5.3", thinkingLevel: "high", confirmed: true });
|
|
434
|
+
assert.equal(readGlobal().reviewer.thinking_level, "high");
|
|
435
|
+
|
|
436
|
+
setRole(workdir, { role: "reviewer", thinkingLevel: "default" });
|
|
437
|
+
assert.equal(readGlobal().reviewer.thinking_level, null);
|
|
438
|
+
|
|
439
|
+
setRole(workdir, { role: "reviewer", thinkingLevel: "off" });
|
|
440
|
+
assert.equal(readGlobal().reviewer.thinking_level, "off");
|
|
441
|
+
|
|
442
|
+
setRole(workdir, { role: "reviewer", modelSelector: "devin/claude-opus-5.5", confirmed: true });
|
|
443
|
+
assert.equal(readGlobal().reviewer.thinking_level, null, "model switch resets the level");
|
|
444
|
+
|
|
445
|
+
setRole(workdir, { role: "reviewer", modelSelector: "devin/glm-5.3", thinkingLevel: "xhigh", confirmed: true });
|
|
446
|
+
assert.equal(readGlobal().reviewer.thinking_level, "xhigh");
|
|
447
|
+
|
|
448
|
+
assert.throws(
|
|
449
|
+
() => setRole(workdir, { role: "reviewer", thinkingLevel: "ultra" as string }),
|
|
450
|
+
/thinkingLevel must be one of/,
|
|
451
|
+
);
|
|
329
452
|
});
|
|
330
453
|
});
|
|
331
454
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Subagent spawn argument tests (v0.7.0): the child pi receives the exact
|
|
2
|
+
* --model selector and, only when a level is stored, --thinking. */
|
|
3
|
+
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { runPiSubagent } from "../src/subagent.ts";
|
|
10
|
+
|
|
11
|
+
/** Fake `pi` that echoes its raw argv (after the script path) as the final
|
|
12
|
+
* assistant message so tests can assert the exact flags. */
|
|
13
|
+
async function spawnEchoingArgs(extra: { model?: string; thinkingLevel?: string }): Promise<string[]> {
|
|
14
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-fake-pi-"));
|
|
15
|
+
const script = path.join(dir, "fake-pi.mjs");
|
|
16
|
+
fs.writeFileSync(
|
|
17
|
+
script,
|
|
18
|
+
[
|
|
19
|
+
'const emit = (event) => process.stdout.write(JSON.stringify(event) + "\\n");',
|
|
20
|
+
'emit({ type: "turn_start" });',
|
|
21
|
+
'const args = process.argv.slice(2).join(" ");',
|
|
22
|
+
'emit({ type: "message_end", message: { role: "assistant", model: "fake/model", content: [{ type: "text", text: args }] } });',
|
|
23
|
+
].join("\n"),
|
|
24
|
+
);
|
|
25
|
+
const previousScript = process.argv[1];
|
|
26
|
+
process.argv[1] = script;
|
|
27
|
+
try {
|
|
28
|
+
const result = await runPiSubagent({
|
|
29
|
+
systemPrompt: "test system prompt",
|
|
30
|
+
task: "echo",
|
|
31
|
+
cwd: process.cwd(),
|
|
32
|
+
timeoutMs: 10_000,
|
|
33
|
+
...extra,
|
|
34
|
+
});
|
|
35
|
+
assert.ok(result.ok, result.errorMessage ?? result.stderr);
|
|
36
|
+
return (result.output ?? "").split(" ");
|
|
37
|
+
} finally {
|
|
38
|
+
process.argv[1] = previousScript;
|
|
39
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("subagent spawn flags", () => {
|
|
44
|
+
it("passes the concrete --model selector", async () => {
|
|
45
|
+
const argv = await spawnEchoingArgs({ model: "devin/claude-sonnet-5.5" });
|
|
46
|
+
const modelIndex = argv.indexOf("--model");
|
|
47
|
+
assert.ok(modelIndex >= 0);
|
|
48
|
+
assert.equal(argv[modelIndex + 1], "devin/claude-sonnet-5.5");
|
|
49
|
+
assert.equal(argv.includes("--thinking"), false, "null level must not add --thinking");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("passes --thinking only when a level is stored", async () => {
|
|
53
|
+
const argv = await spawnEchoingArgs({ model: "devin/glm-5.3", thinkingLevel: "high" });
|
|
54
|
+
const thinkingIndex = argv.indexOf("--thinking");
|
|
55
|
+
assert.ok(thinkingIndex >= 0);
|
|
56
|
+
assert.equal(argv[thinkingIndex + 1], "high");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("explicit 'off' is a real flag (distinct from the null default)", async () => {
|
|
60
|
+
const argv = await spawnEchoingArgs({ model: "devin/adaptive", thinkingLevel: "off" });
|
|
61
|
+
const thinkingIndex = argv.indexOf("--thinking");
|
|
62
|
+
assert.ok(thinkingIndex >= 0);
|
|
63
|
+
assert.equal(argv[thinkingIndex + 1], "off");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -91,7 +91,7 @@ describe("recordSubagent usage persistence (I-010)", () => {
|
|
|
91
91
|
});
|
|
92
92
|
assert.equal(entry.usage?.input, 11);
|
|
93
93
|
const line = fs
|
|
94
|
-
.readFileSync(path.join(dir, ".git", "
|
|
94
|
+
.readFileSync(path.join(dir, ".git", "pi-plans", "runs", startRunId, "subagents.jsonl"), "utf8")
|
|
95
95
|
.trim()
|
|
96
96
|
.split("\n")
|
|
97
97
|
.map((l) => JSON.parse(l))
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Tests for the plans_update_task tool core (v0.6.1): transition
|
|
2
|
+
* validation and the audit-only rollback boundary. */
|
|
3
|
+
|
|
4
|
+
import * as assert from "node:assert/strict";
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import { parsePlanTasks } from "../src/plan.ts";
|
|
7
|
+
import { buildTaskView } from "../src/tasks.ts";
|
|
8
|
+
import { applyTaskUpdate } from "../src/task-tool.ts";
|
|
9
|
+
|
|
10
|
+
const PLAN = `## Tasks
|
|
11
|
+
|
|
12
|
+
- Task-1: parser — files: src/a.ts; wave: 1
|
|
13
|
+
- Task-2: tool — files: src/b.ts; wave: 1
|
|
14
|
+
- Task-2.1: schema
|
|
15
|
+
|
|
16
|
+
## Verification Checks
|
|
17
|
+
|
|
18
|
+
- [ ] \`VC-001\` covers \`Task-1\`; pass condition: ok
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
function tasks(progress?: Record<string, { status: "complete" | "skipped" }>) {
|
|
22
|
+
return buildTaskView(parsePlanTasks(PLAN), progress);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("applyTaskUpdate validation", () => {
|
|
26
|
+
it("completes a pending task and records evidence", () => {
|
|
27
|
+
const tree = tasks();
|
|
28
|
+
const result = applyTaskUpdate(tree, "Task-1", "complete", "npm test green");
|
|
29
|
+
assert.equal(result.ok, true);
|
|
30
|
+
const task = tree.find((t) => t.id === "Task-1")!;
|
|
31
|
+
assert.equal(task.status, "complete");
|
|
32
|
+
assert.equal(task.evidence, "npm test green");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("skipping requires a skipReason", () => {
|
|
36
|
+
const result = applyTaskUpdate(tasks(), "Task-1", "skipped");
|
|
37
|
+
assert.equal(result.ok, false);
|
|
38
|
+
assert.match(result.message, /skipReason/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("unknown task ids are rejected", () => {
|
|
42
|
+
const result = applyTaskUpdate(tasks(), "Task-9", "complete", "x");
|
|
43
|
+
assert.equal(result.ok, false);
|
|
44
|
+
assert.match(result.message, /unknown task/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("closed tasks are immutable outside the audit channel", () => {
|
|
48
|
+
const tree = tasks({ "Task-1": { status: "complete" } });
|
|
49
|
+
const again = applyTaskUpdate(tree, "Task-1", "complete", "again");
|
|
50
|
+
assert.equal(again.ok, false);
|
|
51
|
+
assert.match(again.message, /immutable/);
|
|
52
|
+
const reopen = applyTaskUpdate(tree, "Task-1", "skipped", undefined, "reconsidered");
|
|
53
|
+
assert.equal(reopen.ok, false);
|
|
54
|
+
assert.match(reopen.message, /immutable/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("subtasks are addressable", () => {
|
|
58
|
+
const result = applyTaskUpdate(tasks(), "Task-2.1", "complete", "schema tests");
|
|
59
|
+
assert.equal(result.ok, true);
|
|
60
|
+
});
|
|
61
|
+
});
|