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.
Files changed (112) hide show
  1. package/CONTRIBUTING.md +126 -0
  2. package/README.md +49 -39
  3. package/agents/ref-analyst.md +7 -4
  4. package/agents/reviewer.md +12 -3
  5. package/index.ts +74 -40
  6. package/package.json +2 -1
  7. package/references/pi-planning-workflow.md +45 -58
  8. package/references/plan-artifact-template.md +71 -60
  9. package/references/state-and-config.md +63 -47
  10. package/scripts/bench/pi-adapter/pi_plans_bench.py +33 -22
  11. package/scripts/bench/pi-adapter/rpc_driver.mjs +4 -4
  12. package/scripts/run-tests.ts +12 -1
  13. package/scripts/validate.ts +22 -10
  14. package/skills/debug-and-plan/SKILL.md +4 -4
  15. package/skills/plan-big/SKILL.md +5 -5
  16. package/skills/plan-normal/SKILL.md +5 -5
  17. package/skills/plan-small/SKILL.md +5 -5
  18. package/skills/plan-with-refs/SKILL.md +8 -8
  19. package/skills/planning/SKILL.md +1 -1
  20. package/src/ask-form.ts +4 -4
  21. package/src/auditor.ts +126 -0
  22. package/src/auto-approve.ts +1 -1
  23. package/src/autocomplete.ts +19 -17
  24. package/src/code-graph/commands.ts +2 -2
  25. package/src/code-graph/community.ts +1 -1
  26. package/src/code-graph/paths.ts +1 -1
  27. package/src/code-graph/watch.ts +2 -2
  28. package/src/compaction.ts +3 -3
  29. package/src/config-command.ts +154 -76
  30. package/src/dashboard.ts +257 -0
  31. package/src/exec.ts +709 -705
  32. package/src/global-state.ts +304 -0
  33. package/src/guard.ts +16 -3
  34. package/src/messaging.ts +44 -0
  35. package/src/plan.ts +421 -112
  36. package/src/query-hook.ts +4 -4
  37. package/src/refine-prompts.ts +14 -72
  38. package/src/refine-ui-helpers.ts +24 -5
  39. package/src/refine-ui-state.ts +1 -1
  40. package/src/refine-ui.ts +1 -1
  41. package/src/resume-command.ts +40 -130
  42. package/src/resume.ts +15 -17
  43. package/src/role-panels.ts +542 -0
  44. package/src/run-context.ts +5 -4
  45. package/src/run-picker.ts +98 -0
  46. package/src/state.ts +380 -77
  47. package/src/subagent.ts +32 -1
  48. package/src/task-tool.ts +100 -0
  49. package/src/tasks.ts +189 -0
  50. package/src/thinking-levels.ts +67 -0
  51. package/src/ui-language.ts +3 -54
  52. package/src/workflow-state.ts +78 -57
  53. package/tests/analyze-refs.test.ts +35 -18
  54. package/tests/ask-choice-pros-cons.test.ts +147 -0
  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 +111 -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 +268 -0
  72. package/tests/exec-lifecycle.test.ts +181 -115
  73. package/tests/exec-panel-lifecycle.test.ts +106 -251
  74. package/tests/exec.test.ts +617 -1706
  75. package/tests/execute-plan.test.ts +44 -19
  76. package/tests/extension-load.test.ts +48 -0
  77. package/tests/global-state.test.ts +371 -0
  78. package/tests/graph-aware-file-tools.test.ts +5 -5
  79. package/tests/guard.test.ts +1 -1
  80. package/tests/multi-run.test.ts +184 -0
  81. package/tests/plan.test.ts +139 -62
  82. package/tests/plans.test.ts +7 -79
  83. package/tests/refine-prompts.test.ts +20 -71
  84. package/tests/refine-resume.test.ts +27 -22
  85. package/tests/refine-ui.test.ts +6 -15
  86. package/tests/resume-lifecycle.test.ts +37 -22
  87. package/tests/resume.test.ts +43 -88
  88. package/tests/role-panels.test.ts +391 -0
  89. package/tests/run-context.test.ts +1 -1
  90. package/tests/run-ownership.test.ts +1 -1
  91. package/tests/stale-ctx.test.ts +218 -0
  92. package/tests/state.test.ts +151 -32
  93. package/tests/subagent-thinking.test.ts +65 -0
  94. package/tests/subagent-usage.test.ts +1 -1
  95. package/tests/task-tool.test.ts +61 -0
  96. package/tests/thinking-levels.test.ts +77 -0
  97. package/tests/ui-language.test.ts +2 -17
  98. package/tests/workflow-state.test.ts +17 -99
  99. package/tools/analyze-refs.ts +67 -32
  100. package/tools/ask-choice.ts +19 -49
  101. package/tools/code-graph.ts +2 -2
  102. package/tools/execute-plan.ts +63 -33
  103. package/tools/graph-aware-file-tools.ts +6 -4
  104. package/tools/plans.ts +40 -66
  105. package/tools/refine.ts +101 -164
  106. package/agents/criticizer.md +0 -18
  107. package/scripts/bench/pi-adapter/__pycache__/pi_plans_bench.cpython-312.pyc +0 -0
  108. package/src/panel.ts +0 -473
  109. package/src/termination-prompt.ts +0 -73
  110. package/tests/goal-wait.test.ts +0 -269
  111. package/tests/panel-i-zero.test.ts +0 -420
  112. package/tests/panel.test.ts +0 -355
@@ -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", () => {
@@ -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), "pi_plans", "config.json");
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 = readConfig(workdir).reviewer;
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 = readConfig(workdir).reviewer;
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 = readConfig(workdir).reviewer;
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 = readConfig(workdir).reviewer;
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
- // Confirmed-inherit is distinguishable from never-confirmed.
323
- setRole(workdir, { role: "criticizer", confirmed: true });
324
- role = readConfig(workdir).criticizer;
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
- assert.throws(() => setRole(workdir, { role: "reviewer", confirmed: true, resetConfirmation: true }), StateError);
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", "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
+ });
@@ -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", "pi_plans");
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/panel/exec strings byte-identical to 0.5.6", () => {
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}`);