pi-plans 0.5.7 → 0.7.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/CONTRIBUTING.md +126 -0
- package/README.md +49 -39
- package/agents/ref-analyst.md +7 -4
- package/agents/reviewer.md +12 -3
- package/index.ts +74 -40
- package/package.json +2 -1
- package/references/pi-planning-workflow.md +45 -58
- package/references/plan-artifact-template.md +71 -60
- package/references/state-and-config.md +63 -47
- 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 +22 -10
- package/skills/debug-and-plan/SKILL.md +4 -4
- package/skills/plan-big/SKILL.md +5 -5
- package/skills/plan-normal/SKILL.md +5 -5
- package/skills/plan-small/SKILL.md +5 -5
- package/skills/plan-with-refs/SKILL.md +8 -8
- package/skills/planning/SKILL.md +1 -1
- package/src/ask-form.ts +4 -4
- package/src/auditor.ts +126 -0
- package/src/auto-approve.ts +1 -1
- package/src/autocomplete.ts +19 -17
- package/src/code-graph/commands.ts +2 -2
- 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 +154 -76
- package/src/dashboard.ts +257 -0
- package/src/exec.ts +709 -705
- package/src/global-state.ts +304 -0
- package/src/guard.ts +16 -3
- package/src/messaging.ts +44 -0
- package/src/plan.ts +421 -112
- package/src/query-hook.ts +4 -4
- package/src/refine-prompts.ts +14 -72
- package/src/refine-ui-helpers.ts +24 -5
- package/src/refine-ui-state.ts +1 -1
- package/src/refine-ui.ts +1 -1
- package/src/resume-command.ts +40 -130
- package/src/resume.ts +15 -17
- package/src/role-panels.ts +542 -0
- package/src/run-context.ts +5 -4
- package/src/run-picker.ts +98 -0
- package/src/state.ts +380 -77
- package/src/subagent.ts +32 -1
- package/src/task-tool.ts +100 -0
- package/src/tasks.ts +189 -0
- package/src/thinking-levels.ts +67 -0
- package/src/ui-language.ts +3 -54
- package/src/workflow-state.ts +78 -57
- package/tests/analyze-refs.test.ts +35 -18
- package/tests/ask-choice-pros-cons.test.ts +147 -0
- 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 +111 -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 +268 -0
- package/tests/exec-lifecycle.test.ts +181 -115
- package/tests/exec-panel-lifecycle.test.ts +106 -251
- package/tests/exec.test.ts +617 -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 +184 -0
- 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 +37 -22
- package/tests/resume.test.ts +43 -88
- 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/state.test.ts +151 -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/thinking-levels.test.ts +77 -0
- package/tests/ui-language.test.ts +2 -17
- package/tests/workflow-state.test.ts +17 -99
- package/tools/analyze-refs.ts +67 -32
- package/tools/ask-choice.ts +19 -49
- package/tools/code-graph.ts +2 -2
- package/tools/execute-plan.ts +63 -33
- package/tools/graph-aware-file-tools.ts +6 -4
- package/tools/plans.ts +40 -66
- package/tools/refine.ts +101 -164
- package/agents/criticizer.md +0 -18
- 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
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", () => {
|
|
@@ -256,8 +293,6 @@ describe("runs", () => {
|
|
|
256
293
|
artifact_root_updated_at: new Date().toISOString(),
|
|
257
294
|
schema: 1,
|
|
258
295
|
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
296
|
execution: { model_selector: null, source: "unset", updated_at: null },
|
|
262
297
|
}, {
|
|
263
298
|
topic: "" as string,
|
|
@@ -275,7 +310,7 @@ describe("legacy execution config", () => {
|
|
|
275
310
|
const workdir = mkWorkdir("legacy-exec");
|
|
276
311
|
git(workdir, "init", "-q");
|
|
277
312
|
initState(workdir);
|
|
278
|
-
const configPath = path.join(commonDir(workdir), "
|
|
313
|
+
const configPath = path.join(commonDir(workdir), "pi-plans", "config.json");
|
|
279
314
|
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
280
315
|
config.execution = { model_selector: "zai/glm-5.3-flash:high", source: "user", updated_at: null };
|
|
281
316
|
fs.writeFileSync(configPath, `${JSON.stringify(config, null, "\t")}\n`, "utf8");
|
|
@@ -286,14 +321,40 @@ describe("legacy execution config", () => {
|
|
|
286
321
|
});
|
|
287
322
|
});
|
|
288
323
|
|
|
289
|
-
describe("set-role invariants", () => {
|
|
324
|
+
describe("set-role invariants (global config)", () => {
|
|
325
|
+
// Each test gets its own throwaway global dir so tests never touch the
|
|
326
|
+
// developer's real ~/.pi/pi-plans/config.json (F-002).
|
|
327
|
+
let globalDir: string;
|
|
328
|
+
let previousGlobalDir: string | undefined;
|
|
329
|
+
|
|
330
|
+
before(() => {
|
|
331
|
+
previousGlobalDir = process.env.PI_PLANS_GLOBAL_DIR;
|
|
332
|
+
globalDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-global-test-"));
|
|
333
|
+
process.env.PI_PLANS_GLOBAL_DIR = globalDir;
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
beforeEach(() => {
|
|
337
|
+
// Each case starts from a pristine global file (no cross-test seeding).
|
|
338
|
+
fs.rmSync(path.join(globalDir, "config.json"), { force: true });
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
after(() => {
|
|
342
|
+
if (previousGlobalDir === undefined) delete process.env.PI_PLANS_GLOBAL_DIR;
|
|
343
|
+
else process.env.PI_PLANS_GLOBAL_DIR = previousGlobalDir;
|
|
344
|
+
fs.rmSync(globalDir, { recursive: true, force: true });
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
function readGlobal(): Record<string, any> {
|
|
348
|
+
return JSON.parse(fs.readFileSync(path.join(globalDir, "config.json"), "utf8"));
|
|
349
|
+
}
|
|
350
|
+
|
|
290
351
|
it("mode-only edits never forge or discard confirmations", () => {
|
|
291
352
|
const workdir = mkWorkdir("roles");
|
|
292
353
|
git(workdir, "init", "-q");
|
|
293
354
|
initState(workdir);
|
|
294
355
|
|
|
295
356
|
setRole(workdir, { role: "reviewer", mode: "current-session" });
|
|
296
|
-
let role =
|
|
357
|
+
let role = readGlobal().reviewer;
|
|
297
358
|
assert.equal(role.mode, "current-session");
|
|
298
359
|
assert.equal(role.confirmed_at, null);
|
|
299
360
|
|
|
@@ -303,29 +364,87 @@ describe("set-role invariants", () => {
|
|
|
303
364
|
modelSelector: "deepseek/deepseek-v4-flash",
|
|
304
365
|
confirmed: true,
|
|
305
366
|
});
|
|
306
|
-
role =
|
|
367
|
+
role = readGlobal().reviewer;
|
|
307
368
|
assert.equal(role.model_selector, "deepseek/deepseek-v4-flash");
|
|
308
369
|
const stamped = role.confirmed_at;
|
|
309
370
|
assert.ok(stamped);
|
|
371
|
+
// No workspace reviewer key: the global config is the only home.
|
|
372
|
+
assert.equal("reviewer" in readConfig(workdir), false);
|
|
310
373
|
|
|
311
374
|
setRole(workdir, { role: "reviewer", mode: "current-session" });
|
|
312
|
-
role =
|
|
375
|
+
role = readGlobal().reviewer;
|
|
313
376
|
assert.equal(role.mode, "current-session");
|
|
314
377
|
assert.equal(role.model_selector, "deepseek/deepseek-v4-flash");
|
|
315
378
|
assert.equal(role.confirmed_at, stamped);
|
|
316
379
|
|
|
317
380
|
setRole(workdir, { role: "reviewer", resetConfirmation: true });
|
|
318
|
-
role =
|
|
381
|
+
role = readGlobal().reviewer;
|
|
319
382
|
assert.equal(role.confirmed_at, null);
|
|
320
383
|
assert.equal(role.model_selector, "deepseek/deepseek-v4-flash");
|
|
321
384
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
385
|
+
assert.throws(() => setRole(workdir, { role: "reviewer", confirmed: true, resetConfirmation: true }), StateError);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("confirmed requires a concrete selector in delegated mode", () => {
|
|
389
|
+
const workdir = mkWorkdir("roles-confirm");
|
|
390
|
+
git(workdir, "init", "-q");
|
|
391
|
+
initState(workdir);
|
|
392
|
+
assert.throws(
|
|
393
|
+
() => setRole(workdir, { role: "reviewer", confirmed: true }),
|
|
394
|
+
/exact provider\/model selector/,
|
|
395
|
+
);
|
|
396
|
+
assert.equal(fs.existsSync(path.join(globalDir, "config.json")), false);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("'inherit' is a full reset and never confirmable", () => {
|
|
400
|
+
const workdir = mkWorkdir("roles-inherit");
|
|
401
|
+
git(workdir, "init", "-q");
|
|
402
|
+
initState(workdir);
|
|
403
|
+
setRole(workdir, { role: "reviewer", modelSelector: "devin/claude-sonnet-5.5", thinkingLevel: "high", confirmed: true });
|
|
404
|
+
setRole(workdir, { role: "reviewer", modelSelector: "inherit" });
|
|
405
|
+
const role = readGlobal().reviewer;
|
|
325
406
|
assert.equal(role.model_selector, null);
|
|
407
|
+
assert.equal(role.confirmed_at, null);
|
|
408
|
+
assert.throws(
|
|
409
|
+
() => setRole(workdir, { role: "reviewer", modelSelector: "inherit", confirmed: true }),
|
|
410
|
+
/cannot be combined with confirmed/,
|
|
411
|
+
);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("current-session mode is confirmable without a model", () => {
|
|
415
|
+
const workdir = mkWorkdir("roles-current-session");
|
|
416
|
+
git(workdir, "init", "-q");
|
|
417
|
+
initState(workdir);
|
|
418
|
+
setRole(workdir, { role: "reviewer", mode: "current-session", confirmed: true });
|
|
419
|
+
const role = readGlobal().reviewer;
|
|
420
|
+
assert.equal(role.mode, "current-session");
|
|
326
421
|
assert.ok(role.confirmed_at);
|
|
422
|
+
assert.equal(role.model_selector, null);
|
|
423
|
+
});
|
|
327
424
|
|
|
328
|
-
|
|
425
|
+
it("thinkingLevel: 'default' stores null; model switch resets the level unless passed", () => {
|
|
426
|
+
const workdir = mkWorkdir("roles-thinking");
|
|
427
|
+
git(workdir, "init", "-q");
|
|
428
|
+
initState(workdir);
|
|
429
|
+
setRole(workdir, { role: "reviewer", modelSelector: "devin/glm-5.3", thinkingLevel: "high", confirmed: true });
|
|
430
|
+
assert.equal(readGlobal().reviewer.thinking_level, "high");
|
|
431
|
+
|
|
432
|
+
setRole(workdir, { role: "reviewer", thinkingLevel: "default" });
|
|
433
|
+
assert.equal(readGlobal().reviewer.thinking_level, null);
|
|
434
|
+
|
|
435
|
+
setRole(workdir, { role: "reviewer", thinkingLevel: "off" });
|
|
436
|
+
assert.equal(readGlobal().reviewer.thinking_level, "off");
|
|
437
|
+
|
|
438
|
+
setRole(workdir, { role: "reviewer", modelSelector: "devin/claude-opus-5.5", confirmed: true });
|
|
439
|
+
assert.equal(readGlobal().reviewer.thinking_level, null, "model switch resets the level");
|
|
440
|
+
|
|
441
|
+
setRole(workdir, { role: "reviewer", modelSelector: "devin/glm-5.3", thinkingLevel: "xhigh", confirmed: true });
|
|
442
|
+
assert.equal(readGlobal().reviewer.thinking_level, "xhigh");
|
|
443
|
+
|
|
444
|
+
assert.throws(
|
|
445
|
+
() => setRole(workdir, { role: "reviewer", thinkingLevel: "ultra" as string }),
|
|
446
|
+
/thinkingLevel must be one of/,
|
|
447
|
+
);
|
|
329
448
|
});
|
|
330
449
|
});
|
|
331
450
|
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** Thinking-level adapter tests (F-012): the wrapper delegates level-domain
|
|
2
|
+
* rules to pi-ai and only owns the "default" sentinel + spawn resolution. */
|
|
3
|
+
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_LEVEL_DESCRIPTION,
|
|
9
|
+
DEFAULT_LEVEL_SENTINEL,
|
|
10
|
+
isLevelSupported,
|
|
11
|
+
levelsForModel,
|
|
12
|
+
resolveReviewerSpawn,
|
|
13
|
+
roleModelLabel,
|
|
14
|
+
spawnThinkingFlag,
|
|
15
|
+
} from "../src/thinking-levels.ts";
|
|
16
|
+
|
|
17
|
+
const reasoningModel = {
|
|
18
|
+
reasoning: true,
|
|
19
|
+
thinkingLevelMap: { off: null, minimal: null, low: "low", medium: null, high: "high", xhigh: null, max: "max" },
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
const plainModel = { reasoning: false } as const;
|
|
23
|
+
|
|
24
|
+
describe("levelsForModel (delegates to pi-ai)", () => {
|
|
25
|
+
it("keeps only supported levels; xhigh/max require explicit mappings", () => {
|
|
26
|
+
const levels = levelsForModel(reasoningModel as never);
|
|
27
|
+
assert.deepEqual(levels, ["low", "high", "max"]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("non-reasoning models only support off", () => {
|
|
31
|
+
assert.deepEqual(levelsForModel(plainModel as never), ["off"]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("matches pi-ai's own output (no drift)", () => {
|
|
35
|
+
assert.deepEqual(levelsForModel(reasoningModel as never), getSupportedThinkingLevels(reasoningModel as never));
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("default sentinel", () => {
|
|
40
|
+
it("null stored level means: omit the --thinking flag", () => {
|
|
41
|
+
assert.equal(spawnThinkingFlag(null), null);
|
|
42
|
+
assert.equal(spawnThinkingFlag("high"), "high");
|
|
43
|
+
assert.equal(spawnThinkingFlag("off"), "off");
|
|
44
|
+
assert.equal(DEFAULT_LEVEL_SENTINEL, "default");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("isLevelSupported treats null as always valid", () => {
|
|
48
|
+
assert.equal(isLevelSupported(reasoningModel as never, null), true);
|
|
49
|
+
assert.equal(isLevelSupported(reasoningModel as never, "high"), true);
|
|
50
|
+
assert.equal(isLevelSupported(reasoningModel as never, "xhigh"), false);
|
|
51
|
+
assert.equal(isLevelSupported(plainModel as never, "high"), false);
|
|
52
|
+
assert.equal(isLevelSupported(plainModel as never, "off"), true);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("labels and spawn resolution", () => {
|
|
57
|
+
it("roleModelLabel suffixes the level or the default sentinel", () => {
|
|
58
|
+
assert.equal(roleModelLabel("devin/claude-sonnet-5.5", null), "devin/claude-sonnet-5.5:default");
|
|
59
|
+
assert.equal(roleModelLabel("devin/claude-sonnet-5.5", "high"), "devin/claude-sonnet-5.5:high");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("resolveReviewerSpawn returns concrete model + optional level", () => {
|
|
63
|
+
const spawn = resolveReviewerSpawn({ model_selector: "devin/glm-5.3", thinking_level: "xhigh" });
|
|
64
|
+
assert.deepEqual(spawn, { modelSelector: "devin/glm-5.3", thinkingLevel: "xhigh", label: "devin/glm-5.3:xhigh" });
|
|
65
|
+
const defaulted = resolveReviewerSpawn({ model_selector: "devin/glm-5.3", thinking_level: null });
|
|
66
|
+
assert.deepEqual(defaulted, {
|
|
67
|
+
modelSelector: "devin/glm-5.3",
|
|
68
|
+
thinkingLevel: null,
|
|
69
|
+
label: "devin/glm-5.3:default",
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("the default row documents the child pi default chain, not the session level", () => {
|
|
74
|
+
assert.match(DEFAULT_LEVEL_DESCRIPTION, /child pi/);
|
|
75
|
+
assert.match(DEFAULT_LEVEL_DESCRIPTION, /defaultThinkingLevel/);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -7,9 +7,7 @@ import * as os from "node:os";
|
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { after, before, describe, it } from "node:test";
|
|
9
9
|
import {
|
|
10
|
-
execChrome,
|
|
11
10
|
formChrome,
|
|
12
|
-
panelChrome,
|
|
13
11
|
refineChrome,
|
|
14
12
|
resolveUiLanguage,
|
|
15
13
|
uiLanguageFromTag,
|
|
@@ -73,7 +71,7 @@ describe("resolveUiLanguage (best-effort config read)", () => {
|
|
|
73
71
|
it("returns en for a corrupt config without throwing", () => {
|
|
74
72
|
const workdir = freshWorkdir("cfg-corrupt");
|
|
75
73
|
gitInit(workdir);
|
|
76
|
-
const stateRoot = path.join(workdir, ".git", "
|
|
74
|
+
const stateRoot = path.join(workdir, ".git", "pi-plans");
|
|
77
75
|
fs.mkdirSync(stateRoot, { recursive: true });
|
|
78
76
|
fs.writeFileSync(path.join(stateRoot, "config.json"), "{ not json", "utf8");
|
|
79
77
|
assert.equal(resolveUiLanguage(workdir), "en");
|
|
@@ -109,21 +107,12 @@ describe("chrome tables", () => {
|
|
|
109
107
|
assert.equal(zh.submitAllHint, "[Enter] 提交全部 [←→] 返回修改 [Esc] 取消");
|
|
110
108
|
});
|
|
111
109
|
|
|
112
|
-
it("keeps the zh refine
|
|
110
|
+
it("keeps the zh refine strings byte-identical to 0.5.6", () => {
|
|
113
111
|
const refine = refineChrome("zh");
|
|
114
112
|
assert.equal(refine.close, "Esc 关闭");
|
|
115
113
|
assert.equal(refine.scroll, "↑/↓ 滚动");
|
|
116
114
|
assert.equal(refine.page, "PgUp/PgDn 翻页");
|
|
117
115
|
assert.equal(refine.switchLane, "Tab & Shift + Tab 切换 lane");
|
|
118
|
-
const panel = panelChrome("zh");
|
|
119
|
-
assert.equal(panel.badgeStatus("t", 0, 1), "t · ⚠ I 解析 0 项 · VC 0/1");
|
|
120
|
-
assert.equal(panel.progressWarning(), "⚠ plan 格式:Implementation Items 解析 0 项(面板无法计 I 进度)");
|
|
121
|
-
assert.equal(
|
|
122
|
-
panel.summaryWarning("t", 0, 1, "next"),
|
|
123
|
-
"plans: t ▸ ⚠ Implementation Items 解析 0 项 · VC 0/1 · next: next",
|
|
124
|
-
);
|
|
125
|
-
const exec = execChrome("zh");
|
|
126
|
-
assert.equal(exec.goalWait(1, 2), " · 🔁 goal-wait · 无进展 1/3 · 等待 2/6");
|
|
127
116
|
});
|
|
128
117
|
|
|
129
118
|
it("keeps every en string free of CJK", () => {
|
|
@@ -143,10 +132,6 @@ describe("chrome tables", () => {
|
|
|
143
132
|
]);
|
|
144
133
|
const r = refineChrome("en");
|
|
145
134
|
groups.push([r.close, r.scroll, r.page, r.switchLane]);
|
|
146
|
-
const p = panelChrome("en");
|
|
147
|
-
groups.push([p.badgeStatus("t", 0, 1), p.progressWarning(), p.summaryWarning("t", 0, 1, "next")]);
|
|
148
|
-
const e = execChrome("en");
|
|
149
|
-
groups.push([e.goalWait(1, 2)]);
|
|
150
135
|
for (const group of groups) {
|
|
151
136
|
for (const text of group) {
|
|
152
137
|
assert.ok(!CJK.test(text), `CJK leaked in en chrome: ${text}`);
|