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.
Files changed (116) hide show
  1. package/AGENTS.md +58 -0
  2. package/CONTRIBUTING.md +8 -15
  3. package/README.md +39 -37
  4. package/agents/execution-reviewer.md +40 -0
  5. package/agents/reviewer.md +12 -3
  6. package/index.ts +55 -58
  7. package/package.json +2 -1
  8. package/references/pi-planning-workflow.md +50 -60
  9. package/references/plan-artifact-template.md +81 -60
  10. package/references/state-and-config.md +60 -44
  11. package/scripts/bench/pi-adapter/pi_plans_bench.py +33 -22
  12. package/scripts/bench/pi-adapter/rpc_driver.mjs +4 -4
  13. package/scripts/run-tests.ts +12 -1
  14. package/scripts/validate.ts +39 -11
  15. package/skills/debug-and-plan/SKILL.md +3 -3
  16. package/skills/plan-big/SKILL.md +3 -3
  17. package/skills/plan-normal/SKILL.md +3 -3
  18. package/skills/plan-small/SKILL.md +4 -4
  19. package/skills/plan-with-refs/SKILL.md +6 -6
  20. package/skills/planning/SKILL.md +1 -1
  21. package/src/ask-form.ts +4 -4
  22. package/src/auditor.ts +227 -0
  23. package/src/auto-approve.ts +1 -1
  24. package/src/autocomplete.ts +19 -17
  25. package/src/code-graph/commands.ts +8 -3
  26. package/src/code-graph/community.ts +1 -1
  27. package/src/code-graph/paths.ts +1 -1
  28. package/src/code-graph/watch.ts +2 -2
  29. package/src/compaction.ts +3 -3
  30. package/src/config-command.ts +146 -73
  31. package/src/dashboard.ts +303 -0
  32. package/src/exec.ts +1185 -924
  33. package/src/global-state.ts +304 -0
  34. package/src/guard.ts +18 -19
  35. package/src/messaging.ts +44 -0
  36. package/src/plan.ts +421 -112
  37. package/src/query-hook.ts +4 -4
  38. package/src/refine-prompts.ts +12 -70
  39. package/src/refine-ui-helpers.ts +24 -5
  40. package/src/refine-ui-state.ts +1 -1
  41. package/src/refine-ui.ts +19 -3
  42. package/src/resume-command.ts +45 -129
  43. package/src/resume.ts +5 -1
  44. package/src/role-panels.ts +542 -0
  45. package/src/run-context.ts +3 -10
  46. package/src/staleness.ts +53 -0
  47. package/src/state.ts +273 -72
  48. package/src/subagent.ts +19 -29
  49. package/src/task-tool.ts +100 -0
  50. package/src/tasks.ts +223 -0
  51. package/src/thinking-levels.ts +67 -0
  52. package/src/ui-language.ts +7 -54
  53. package/src/workflow-state.ts +76 -58
  54. package/tests/analyze-refs.test.ts +35 -18
  55. package/tests/ask-choice-schema.test.ts +0 -12
  56. package/tests/ask-choice.test.ts +2 -49
  57. package/tests/ask-form-tool.test.ts +4 -5
  58. package/tests/ask-form.test.ts +2 -2
  59. package/tests/auditor.test.ts +210 -0
  60. package/tests/auto-approve.test.ts +7 -10
  61. package/tests/autocomplete.test.ts +8 -11
  62. package/tests/code-graph-apply-action.test.ts +2 -2
  63. package/tests/code-graph-commands.test.ts +2 -2
  64. package/tests/code-graph-index.test.ts +2 -2
  65. package/tests/code-graph-loop.e2e.test.ts +1 -1
  66. package/tests/code-graph-mutations.test.ts +1 -1
  67. package/tests/code-graph-rollback.test.ts +1 -1
  68. package/tests/code-graph-v05.test.ts +2 -2
  69. package/tests/compaction.test.ts +1 -1
  70. package/tests/config-command.test.ts +103 -100
  71. package/tests/dashboard.test.ts +402 -0
  72. package/tests/exec-lifecycle.test.ts +181 -115
  73. package/tests/exec-panel-lifecycle.test.ts +106 -251
  74. package/tests/exec-review-loop.test.ts +331 -0
  75. package/tests/exec.test.ts +771 -1706
  76. package/tests/execute-plan.test.ts +44 -19
  77. package/tests/extension-load.test.ts +48 -0
  78. package/tests/global-state.test.ts +371 -0
  79. package/tests/graph-aware-file-tools.test.ts +5 -5
  80. package/tests/guard.test.ts +1 -1
  81. package/tests/multi-run.test.ts +3 -103
  82. package/tests/plan.test.ts +139 -62
  83. package/tests/plans.test.ts +7 -79
  84. package/tests/refine-prompts.test.ts +20 -71
  85. package/tests/refine-resume.test.ts +27 -22
  86. package/tests/refine-ui.test.ts +6 -15
  87. package/tests/resume-lifecycle.test.ts +41 -22
  88. package/tests/resume.test.ts +39 -81
  89. package/tests/role-panels.test.ts +391 -0
  90. package/tests/run-context.test.ts +1 -1
  91. package/tests/run-ownership.test.ts +1 -1
  92. package/tests/stale-ctx.test.ts +218 -0
  93. package/tests/staleness.test.ts +76 -0
  94. package/tests/state.test.ts +155 -32
  95. package/tests/subagent-thinking.test.ts +65 -0
  96. package/tests/subagent-usage.test.ts +1 -1
  97. package/tests/task-tool.test.ts +61 -0
  98. package/tests/tasks.test.ts +142 -0
  99. package/tests/thinking-levels.test.ts +77 -0
  100. package/tests/ui-language.test.ts +2 -17
  101. package/tests/workflow-state.test.ts +73 -90
  102. package/tools/analyze-refs.ts +67 -32
  103. package/tools/ask-choice.ts +7 -53
  104. package/tools/code-graph.ts +2 -2
  105. package/tools/execute-plan.ts +55 -99
  106. package/tools/graph-aware-file-tools.ts +4 -10
  107. package/tools/plans.ts +41 -67
  108. package/tools/refine.ts +101 -164
  109. package/agents/criticizer.md +0 -18
  110. package/agents/executor.md +0 -26
  111. package/scripts/bench/pi-adapter/__pycache__/pi_plans_bench.cpython-312.pyc +0 -0
  112. package/src/panel.ts +0 -473
  113. package/src/termination-prompt.ts +0 -73
  114. package/tests/goal-wait.test.ts +0 -269
  115. package/tests/panel-i-zero.test.ts +0 -420
  116. 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
+ });
@@ -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), "pi_plans", "config.json"), "utf8"));
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", "pi_plans");
66
- assert.equal(path.join(commonDir(workdir), "pi_plans"), state);
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
- assert.equal(config.reviewer.mode, "delegated-subagent");
71
- assert.equal(config.reviewer.confirmed_at, null);
72
- assert.equal(config.criticizer.confirmed_at, null);
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, "./docs/pi-plans");
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), "pi_plans", "config.json");
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("migrates legacy artifact roots to ./docs/pi-plans", () => {
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), "pi_plans", "config.json");
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, "./docs/pi-plans");
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, "./docs/pi-plans");
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), "pi_plans", "config.json")));
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/pi_plans/plans", () => {
203
+ it("supports private planning docs under .git/pi-plans/plans", () => {
167
204
  const workdir = mkWorkdir("private-artifact-root");
168
- setArtifactRoot(workdir, "./.git/pi_plans/plans", "user");
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/pi_plans/plans");
176
- assert.equal(run.artifact_dir, path.join(workdir, ".git", "pi_plans", "plans", `${run.created_at.slice(0, 10)}-private-docs`));
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", "pi_plans", "config.json")));
195
- assert.ok(!fs.existsSync(path.join(repoA, ".git", "pi_plans")));
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), "pi_plans", "config.json");
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 = readConfig(workdir).reviewer;
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 = readConfig(workdir).reviewer;
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 = readConfig(workdir).reviewer;
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 = readConfig(workdir).reviewer;
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
- // Confirmed-inherit is distinguishable from never-confirmed.
323
- setRole(workdir, { role: "criticizer", confirmed: true });
324
- role = readConfig(workdir).criticizer;
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
- assert.throws(() => setRole(workdir, { role: "reviewer", confirmed: true, resetConfirmation: true }), StateError);
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", "pi_plans", "runs", startRunId, "subagents.jsonl"), "utf8")
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
+ });