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
@@ -2,9 +2,11 @@ import * as assert from "node:assert/strict";
2
2
  import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
+ import { parseChecklist, parsePlanTasks } from "../src/plan.ts";
5
6
  import { after, before, describe, it } from "node:test";
6
7
 
7
8
  import { getExecution, startExecution, stopExecution } from "../src/exec.ts";
9
+ import { setMessagingApi } from "../src/messaging.ts";
8
10
 
9
11
  let tmpRoot: string;
10
12
 
@@ -23,24 +25,28 @@ function mkWorkdir(name: string): string {
23
25
  }
24
26
 
25
27
  describe("execute handoff", () => {
28
+ it("rejects an inconsistent task tree at the handoff gate (lint hard-reject)", async () => {
29
+ const workdir = mkWorkdir("lint-gate");
30
+ const planPath = path.join(workdir, "PLAN_v1.md");
31
+ fs.writeFileSync(
32
+ planPath,
33
+ "## Tasks\n\n- Task-1: a — files: src/a.ts; wave: 1\n- Task-2: b — deps: Task-9\n\n## Verification Checks\n\n- [ ] \\`VC-001\\` covers \\`Task-1\\`; pass condition: x\n",
34
+ "utf8",
35
+ );
36
+ const { executeHandoff } = await import("../tools/execute-plan.ts");
37
+ const ctx = { cwd: workdir, hasUI: false } as never;
38
+ setMessagingApi(ctx);
39
+ const outcome = await executeHandoff(ctx, planPath);
40
+ assert.equal(outcome.status, "error");
41
+ assert.match(outcome.message, /consistency gate/);
42
+ assert.match(outcome.message, /deps 引用未知任务/);
43
+ });
44
+
26
45
  it("starts execution without switching models", async () => {
27
46
  const workdir = mkWorkdir("handoff");
28
47
  const recorded = {
29
48
  messages: [] as string[],
30
49
  entries: [] as Array<{ customType: string; data: unknown }>,
31
- setModelCalls: 0,
32
- };
33
- const pi = {
34
- appendEntry: (customType: string, data: unknown) => {
35
- recorded.entries.push({ customType, data });
36
- },
37
- sendMessage: (message: { customType: string; content: string }) => {
38
- recorded.messages.push(message.content);
39
- },
40
- setModel: async () => {
41
- recorded.setModelCalls += 1;
42
- return true;
43
- },
44
50
  };
45
51
  const ctx = {
46
52
  cwd: workdir,
@@ -52,14 +58,33 @@ describe("execute handoff", () => {
52
58
  },
53
59
  },
54
60
  } as any;
61
+ setMessagingApi({
62
+ appendEntry: (customType: string, data: unknown) => {
63
+ recorded.entries.push({ customType, data });
64
+ },
65
+ sendMessage: (message: { customType: string; content: string }) => {
66
+ recorded.messages.push(message.content);
67
+ },
68
+ sendUserMessage: async () => {},
69
+ });
55
70
 
56
- await startExecution(pi as any, ctx, path.join(workdir, "PLAN_v1.md"), [
57
- { id: "VC-001", text: "first item", done: false },
58
- ] as any);
59
- assert.equal(recorded.setModelCalls, 0);
60
- assert.ok(getExecution());
71
+ const planPath = path.join(workdir, "PLAN_v1.md");
72
+ fs.writeFileSync(
73
+ planPath,
74
+ "## Tasks\n\n- Task-1: first — files: src/a.ts; wave: 1\n\n## Verification Checks\n\n- [ ] \\`VC-001\\` covers \\`Task-1\\`; pass condition: first item\n",
75
+ "utf8",
76
+ );
77
+ await startExecution(ctx, {
78
+ planPath,
79
+ planTasks: parsePlanTasks(fs.readFileSync(planPath, "utf8")),
80
+ items: parseChecklist(fs.readFileSync(planPath, "utf8")),
81
+ });
82
+ const execution = getExecution();
83
+ assert.ok(execution);
84
+ assert.equal(execution!.tasks.length, 1);
85
+ assert.match(recorded.messages.at(-1) ?? "", /plans_update_task/);
61
86
 
62
- await stopExecution(pi as any, ctx, "cleanup");
87
+ await stopExecution(ctx, "cleanup");
63
88
  assert.equal(getExecution(), null);
64
89
  });
65
90
  });
@@ -0,0 +1,48 @@
1
+ /** Extension-load smoke test (audit round 1 fix).
2
+ *
3
+ * `npm test` runs under node --experimental-strip-types, which erases types
4
+ * WITHOUT typechecking — duplicate type/interface declarations pass silently.
5
+ * pi's extension loader parses TypeScript for real and refused the whole
6
+ * extension ("Identifier 'RoleConfig' has already been declared"), which made
7
+ * every spawned subagent (including the completion auditor) fail while the
8
+ * whole suite stayed green.
9
+ *
10
+ * Guard: spawn `pi` from the repo root with a deliberately missing model.
11
+ * Extension loading happens BEFORE model resolution, so:
12
+ * - a parse/registration failure prints "Failed to load extension" and aborts;
13
+ * - a healthy extension reaches model resolution and fails differently.
14
+ * No API call is made either way. */
15
+
16
+ import assert from "node:assert/strict";
17
+ import { spawnSync } from "node:child_process";
18
+ import * as fs from "node:fs";
19
+ import * as os from "node:os";
20
+ import * as path from "node:path";
21
+ import * as url from "node:url";
22
+ import { describe, it } from "node:test";
23
+
24
+ const ROOT = path.dirname(path.dirname(url.fileURLToPath(import.meta.url)));
25
+
26
+ function piBinary(): string {
27
+ return process.platform === "win32" ? "pi.cmd" : "pi";
28
+ }
29
+
30
+ describe("extension load smoke (audit round 1)", () => {
31
+ it("pi's real TypeScript loader accepts the extension sources", () => {
32
+ assert.ok(fs.existsSync(path.join(ROOT, "index.ts")), "index.ts must exist");
33
+ const result = spawnSync(
34
+ piBinary(),
35
+ ["--mode", "json", "-p", "--no-session", "--model", "definitely/missing-model", "Task: x"],
36
+ { cwd: ROOT, encoding: "utf8", timeout: 60_000 },
37
+ );
38
+ const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
39
+ assert.ok(
40
+ !output.includes("Failed to load extension"),
41
+ `pi's TS loader rejected the extension sources:\n${output.slice(0, 2000)}`,
42
+ );
43
+ // Sanity: the run must have failed for the MODEL reason (proving it got
44
+ // PAST extension loading), or succeeded entirely on a machine without pi.
45
+ if (result.error?.code === "ENOENT") return; // pi not installed: skip silently
46
+ assert.notEqual(result.status, null, "the smoke run must terminate");
47
+ });
48
+ });
@@ -0,0 +1,371 @@
1
+ /** Global reviewer-role config tests (node:test, stdlib only).
2
+ *
3
+ * Covers the v0.7.0 global config layer: read/write isolation via
4
+ * PI_PLANS_GLOBAL_DIR, corrupt-file tolerance (never clobbered), legacy
5
+ * workspace migration seeding rules (Q-1=A), read-path effective
6
+ * resolution, and reviewerReady semantics (F-003). */
7
+
8
+ import * as assert from "node:assert/strict";
9
+ import { spawnSync } from "node:child_process";
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ import { after, before, beforeEach, describe, it } from "node:test";
14
+ import {
15
+ initState,
16
+ resolveEffectiveReviewer,
17
+ setRole,
18
+ showStateView,
19
+ startRun,
20
+ StateError,
21
+ } from "../src/state.ts";
22
+ import {
23
+ applyGlobalRoleOptions,
24
+ DEFAULT_GLOBAL_ROLE,
25
+ loadGlobalConfig,
26
+ legacyReviewerHasUserIntent,
27
+ resolveGlobalConfigPath,
28
+ reviewerReady,
29
+ seedGlobalRoleFromLegacy,
30
+ setGlobalRole,
31
+ writeGlobalConfig,
32
+ GlobalStateError,
33
+ } from "../src/global-state.ts";
34
+
35
+ let tmpRoot: string;
36
+ let globalDir: string;
37
+ let previousGlobalDir: string | undefined;
38
+
39
+ function mkWorkdir(name: string): string {
40
+ const dir = path.join(tmpRoot, name);
41
+ fs.mkdirSync(dir, { recursive: true });
42
+ return dir;
43
+ }
44
+
45
+ function git(workdir: string, ...args: string[]): void {
46
+ const result = spawnSync("git", args, { cwd: workdir, encoding: "utf8" });
47
+ assert.equal(result.status, 0, result.stderr ?? "");
48
+ }
49
+
50
+ function commonDir(workdir: string): string {
51
+ const result = spawnSync("git", ["rev-parse", "--git-common-dir"], { cwd: workdir, encoding: "utf8" });
52
+ assert.equal(result.status, 0, result.stderr ?? "");
53
+ return path.resolve(workdir, result.stdout.trim());
54
+ }
55
+
56
+ function workspaceConfigPath(workdir: string): string {
57
+ return path.join(commonDir(workdir), "pi-plans", "config.json");
58
+ }
59
+
60
+ function readWorkspaceConfig(workdir: string): Record<string, any> {
61
+ return JSON.parse(fs.readFileSync(workspaceConfigPath(workdir), "utf8"));
62
+ }
63
+
64
+ function writeWorkspaceConfig(workdir: string, config: unknown): void {
65
+ fs.writeFileSync(workspaceConfigPath(workdir), `${JSON.stringify(config, null, "\t")}\n`, "utf8");
66
+ }
67
+
68
+ function readGlobal(): Record<string, any> {
69
+ return JSON.parse(fs.readFileSync(path.join(globalDir, "config.json"), "utf8"));
70
+ }
71
+
72
+ before(() => {
73
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-global-test-"));
74
+ previousGlobalDir = process.env.PI_PLANS_GLOBAL_DIR;
75
+ // Each run gets an isolated global dir; nested scopes override per-test.
76
+ globalDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-global-dir-"));
77
+ process.env.PI_PLANS_GLOBAL_DIR = globalDir;
78
+ });
79
+
80
+ after(() => {
81
+ if (previousGlobalDir === undefined) delete process.env.PI_PLANS_GLOBAL_DIR;
82
+ else process.env.PI_PLANS_GLOBAL_DIR = previousGlobalDir;
83
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
84
+ fs.rmSync(globalDir, { recursive: true, force: true });
85
+ });
86
+
87
+ describe("global config IO", () => {
88
+ beforeEach(() => {
89
+ fs.rmSync(path.join(globalDir, "config.json"), { force: true });
90
+ });
91
+
92
+ it("resolves the config path under PI_PLANS_GLOBAL_DIR", () => {
93
+ assert.equal(resolveGlobalConfigPath(), path.join(globalDir, "config.json"));
94
+ });
95
+
96
+ it("returns defaults when the file is missing (fresh)", () => {
97
+ const loaded = loadGlobalConfig();
98
+ assert.equal(loaded.fresh, true);
99
+ assert.equal(loaded.corrupt, false);
100
+ assert.deepEqual(loaded.config.reviewer, DEFAULT_GLOBAL_ROLE);
101
+ });
102
+
103
+ it("invalid JSON yields defaults + notice and is never clobbered", () => {
104
+ fs.writeFileSync(path.join(globalDir, "config.json"), "{ not json", "utf8");
105
+ const loaded = loadGlobalConfig();
106
+ assert.equal(loaded.corrupt, true);
107
+ assert.deepEqual(loaded.config.reviewer, DEFAULT_GLOBAL_ROLE);
108
+ assert.ok(loaded.notices.some((n) => n.includes("invalid JSON")));
109
+ // A later write is refused? No — explicit writes replace it; but reads
110
+ // never rewrite. Verify the corrupt bytes are still on disk after load.
111
+ assert.equal(fs.readFileSync(path.join(globalDir, "config.json"), "utf8"), "{ not json");
112
+ });
113
+
114
+ it("wrong schema is treated as corrupt", () => {
115
+ fs.writeFileSync(path.join(globalDir, "config.json"), JSON.stringify({ schema: 99 }), "utf8");
116
+ const loaded = loadGlobalConfig();
117
+ assert.equal(loaded.corrupt, true);
118
+ assert.ok(loaded.notices.some((n) => n.includes("schema")));
119
+ });
120
+
121
+ it("invalid thinking_level normalizes to null with a notice", () => {
122
+ writeGlobalConfig({
123
+ schema: 1,
124
+ reviewer: { ...DEFAULT_GLOBAL_ROLE, thinking_level: "ultra" as never },
125
+ });
126
+ const loaded = loadGlobalConfig();
127
+ assert.equal(loaded.config.reviewer.thinking_level, null);
128
+ assert.ok(loaded.notices.some((n) => n.includes("thinking_level")));
129
+ });
130
+
131
+ it("roundtrips a valid config", () => {
132
+ const { global } = setGlobalRole({ modelSelector: "devin/claude-sonnet-5.5", thinkingLevel: "high", confirmed: true });
133
+ assert.equal(global.reviewer.model_selector, "devin/claude-sonnet-5.5");
134
+ assert.equal(global.reviewer.thinking_level, "high");
135
+ assert.ok(global.reviewer.confirmed_at);
136
+ const loaded = loadGlobalConfig();
137
+ assert.deepEqual(loaded.config, global);
138
+ });
139
+ });
140
+
141
+ describe("applyGlobalRoleOptions invariants (F-003)", () => {
142
+ it("reviewerReady requires a concrete confirmed selector for delegated mode", () => {
143
+ assert.equal(reviewerReady({ ...DEFAULT_GLOBAL_ROLE }), false);
144
+ assert.equal(
145
+ reviewerReady({ ...DEFAULT_GLOBAL_ROLE, confirmed_at: "2026-01-01T00:00:00Z" }),
146
+ false,
147
+ "confirmed-inherit must not pass",
148
+ );
149
+ assert.equal(
150
+ reviewerReady({
151
+ ...DEFAULT_GLOBAL_ROLE,
152
+ model_selector: "devin/claude-sonnet-5.5",
153
+ confirmed_at: "2026-01-01T00:00:00Z",
154
+ }),
155
+ true,
156
+ );
157
+ assert.equal(reviewerReady({ ...DEFAULT_GLOBAL_ROLE, mode: "current-session" }), true);
158
+ });
159
+
160
+ it("throws before mutation lands (no partial writes)", () => {
161
+ setGlobalRole({ modelSelector: "devin/claude-sonnet-5.5", confirmed: true });
162
+ const before = readGlobal();
163
+ assert.throws(
164
+ () => applyGlobalRoleOptions({ ...before.reviewer }, { confirmed: true, resetConfirmation: true }),
165
+ GlobalStateError,
166
+ );
167
+ assert.throws(
168
+ () => setGlobalRole({ modelSelector: "other/model", thinkingLevel: "ultra" }),
169
+ GlobalStateError,
170
+ );
171
+ assert.deepEqual(readGlobal(), before);
172
+ });
173
+ });
174
+
175
+ describe("legacy workspace migration (Q-1=A)", () => {
176
+ beforeEach(() => {
177
+ fs.rmSync(path.join(globalDir, "config.json"), { force: true });
178
+ });
179
+
180
+ it("seeds the global file once from an intent block on the first mutating call", () => {
181
+ const workdir = mkWorkdir("migrate-intent");
182
+ git(workdir, "init", "-q");
183
+ initState(workdir);
184
+ // Simulate a pre-0.7.0 workspace block: confirmed current-session.
185
+ const config = readWorkspaceConfig(workdir);
186
+ config.reviewer = {
187
+ mode: "current-session",
188
+ model_selector: null,
189
+ name_prefix: "pi-plans-reviewer",
190
+ confirmed_at: "2025-12-31T00:00:00Z",
191
+ };
192
+ writeWorkspaceConfig(workdir, config);
193
+
194
+ const ensured = initState(workdir);
195
+ assert.ok(ensured.notices.some((n) => n.includes("migrated the reviewer role")));
196
+ assert.equal(readGlobal().reviewer.mode, "current-session");
197
+ assert.equal("reviewer" in readWorkspaceConfig(workdir), false, "workspace key stripped");
198
+ });
199
+
200
+ it("confirmed-inherit seeds WITHOUT confirmation (re-asked once)", () => {
201
+ const workdir = mkWorkdir("migrate-inherit");
202
+ git(workdir, "init", "-q");
203
+ initState(workdir);
204
+ const config = readWorkspaceConfig(workdir);
205
+ config.reviewer = {
206
+ mode: "delegated-subagent",
207
+ model_selector: null,
208
+ name_prefix: "pi-plans-reviewer",
209
+ confirmed_at: "2025-12-31T00:00:00Z",
210
+ };
211
+ writeWorkspaceConfig(workdir, config);
212
+ initState(workdir);
213
+ const reviewer = readGlobal().reviewer;
214
+ assert.equal(reviewer.model_selector, null);
215
+ assert.equal(reviewer.confirmed_at, null);
216
+ assert.equal(reviewerReady(reviewer), false);
217
+ });
218
+
219
+ it("an existing global file wins: intent block gets the ignored notice", () => {
220
+ setGlobalRole({ modelSelector: "devin/claude-opus-5.5", confirmed: true });
221
+ const workdir = mkWorkdir("migrate-ignored");
222
+ git(workdir, "init", "-q");
223
+ initState(workdir);
224
+ const config = readWorkspaceConfig(workdir);
225
+ config.reviewer = { mode: "current-session", model_selector: null, name_prefix: "pi-plans-reviewer", confirmed_at: "2025-12-31T00:00:00Z" };
226
+ writeWorkspaceConfig(workdir, config);
227
+ const ensured = initState(workdir);
228
+ assert.ok(ensured.notices.some((n) => n.includes("source of truth")));
229
+ assert.equal(readGlobal().reviewer.model_selector, "devin/claude-opus-5.5");
230
+ assert.equal("reviewer" in readWorkspaceConfig(workdir), false);
231
+ });
232
+
233
+ it("scaffold-only blocks are dropped silently", () => {
234
+ fs.rmSync(path.join(globalDir, "config.json"), { force: true });
235
+ const workdir = mkWorkdir("migrate-scaffold");
236
+ git(workdir, "init", "-q");
237
+ initState(workdir);
238
+ const config = readWorkspaceConfig(workdir);
239
+ config.reviewer = { mode: "delegated-subagent", model_selector: null, name_prefix: "pi-plans-reviewer", confirmed_at: null };
240
+ writeWorkspaceConfig(workdir, config);
241
+ const ensured = initState(workdir);
242
+ assert.ok(!ensured.notices.some((n) => n.includes("reviewer")));
243
+ assert.equal(fs.existsSync(path.join(globalDir, "config.json")), false);
244
+ assert.equal("reviewer" in readWorkspaceConfig(workdir), false);
245
+ });
246
+
247
+ it("first touched workspace wins across repos (seed order)", () => {
248
+ fs.rmSync(path.join(globalDir, "config.json"), { force: true });
249
+ const first = mkWorkdir("order-first");
250
+ git(first, "init", "-q");
251
+ initState(first);
252
+ const configA = readWorkspaceConfig(first);
253
+ configA.reviewer = { mode: "current-session", model_selector: null, name_prefix: "pi-plans-reviewer", confirmed_at: "2025-12-31T00:00:00Z" };
254
+ writeWorkspaceConfig(first, configA);
255
+ const second = mkWorkdir("order-second");
256
+ git(second, "init", "-q");
257
+ initState(second);
258
+ const configB = readWorkspaceConfig(second);
259
+ configB.reviewer = { mode: "delegated-subagent", model_selector: "devin/glm-5.3", name_prefix: "pi-plans-reviewer", confirmed_at: "2025-12-31T00:00:00Z" };
260
+ writeWorkspaceConfig(second, configB);
261
+
262
+ initState(first);
263
+ assert.equal(readGlobal().reviewer.mode, "current-session");
264
+ const ensuredSecond = initState(second);
265
+ assert.ok(ensuredSecond.notices.some((n) => n.includes("source of truth")));
266
+ assert.equal(readGlobal().reviewer.mode, "current-session", "second repo does not overwrite");
267
+ });
268
+
269
+ it("read paths resolve the legacy block in memory and never write", () => {
270
+ fs.rmSync(path.join(globalDir, "config.json"), { force: true });
271
+ const workdir = mkWorkdir("read-effective");
272
+ git(workdir, "init", "-q");
273
+ initState(workdir);
274
+ const config = readWorkspaceConfig(workdir);
275
+ config.reviewer = { mode: "delegated-subagent", model_selector: "devin/kimi-k3", name_prefix: "pi-plans-reviewer", confirmed_at: "2025-12-31T00:00:00Z" };
276
+ writeWorkspaceConfig(workdir, config);
277
+ // No mutating call yet: effective reviewer comes from the workspace block.
278
+ const stateRoot = path.join(commonDir(workdir), "pi-plans");
279
+ const effective = resolveEffectiveReviewer(stateRoot);
280
+ assert.equal(effective.reviewer.model_selector, "devin/kimi-k3");
281
+ assert.equal(effective.reviewer.confirmed_at, "2025-12-31T00:00:00Z");
282
+ assert.equal(effective.reviewer.thinking_level, null);
283
+ // And nothing was written to the global dir.
284
+ assert.equal(fs.existsSync(path.join(globalDir, "config.json")), false);
285
+ // The workspace block is still there (untouched by the read).
286
+ assert.equal(readWorkspaceConfig(workdir).reviewer.model_selector, "devin/kimi-k3");
287
+ });
288
+
289
+ it("global wins over legacy once it exists (read paths)", () => {
290
+ setGlobalRole({ modelSelector: "devin/grok-4.7", confirmed: true });
291
+ const workdir = mkWorkdir("read-global-wins");
292
+ git(workdir, "init", "-q");
293
+ initState(workdir);
294
+ const config = readWorkspaceConfig(workdir);
295
+ config.reviewer = { mode: "delegated-subagent", model_selector: "devin/kimi-k3", name_prefix: "pi-plans-reviewer", confirmed_at: "2025-12-31T00:00:00Z" };
296
+ writeWorkspaceConfig(workdir, config);
297
+ const stateRoot = path.join(commonDir(workdir), "pi-plans");
298
+ const effective = resolveEffectiveReviewer(stateRoot);
299
+ assert.equal(effective.reviewer.model_selector, "devin/grok-4.7");
300
+ });
301
+
302
+ it("start-run migrates too (every mutating entry goes through ensureState)", () => {
303
+ fs.rmSync(path.join(globalDir, "config.json"), { force: true });
304
+ const workdir = mkWorkdir("migrate-start-run");
305
+ git(workdir, "init", "-q");
306
+ initState(workdir);
307
+ const config = readWorkspaceConfig(workdir);
308
+ config.reviewer = { mode: "current-session", model_selector: null, name_prefix: "pi-plans-reviewer", confirmed_at: "2025-12-31T00:00:00Z" };
309
+ writeWorkspaceConfig(workdir, config);
310
+ const { notices } = startRun(workdir, { topic: "migration", skill: "plan-small", requestText: "x" });
311
+ assert.ok(notices.some((n) => n.includes("migrated the reviewer role")));
312
+ assert.equal(readGlobal().reviewer.mode, "current-session");
313
+ });
314
+ });
315
+
316
+ describe("setRole workspace stripping (F-013)", () => {
317
+ it("setRole writes the global config and strips a legacy workspace key without git-init side effects", () => {
318
+ const workdir = mkWorkdir("setrole-strip");
319
+ git(workdir, "init", "-q");
320
+ initState(workdir);
321
+ const config = readWorkspaceConfig(workdir);
322
+ config.reviewer = { mode: "current-session", model_selector: null, name_prefix: "pi-plans-reviewer", confirmed_at: "2025-12-31T00:00:00Z" };
323
+ writeWorkspaceConfig(workdir, config);
324
+ const result = setRole(workdir, { role: "reviewer", modelSelector: "devin/claude-sonnet-5.5", thinkingLevel: "medium", confirmed: true });
325
+ assert.equal(result.global.reviewer.model_selector, "devin/claude-sonnet-5.5");
326
+ assert.equal("reviewer" in readWorkspaceConfig(workdir), false);
327
+ // No auto git-init in a non-repo: setRole works outside any repository.
328
+ const nonRepo = mkWorkdir("setrole-nonrepo");
329
+ const outside = setRole(nonRepo, { role: "reviewer", modelSelector: "devin/claude-sonnet-5.5", confirmed: true });
330
+ assert.equal(outside.stateRoot, null);
331
+ assert.equal(outside.config, null);
332
+ assert.equal(fs.existsSync(path.join(nonRepo, ".git")), false, "set-role never auto-inits a repo");
333
+ });
334
+ });
335
+
336
+ describe("showStateView", () => {
337
+ it("exposes the effective reviewer and notices without writing", () => {
338
+ fs.rmSync(path.join(globalDir, "config.json"), { force: true });
339
+ const workdir = mkWorkdir("show-view");
340
+ git(workdir, "init", "-q");
341
+ initState(workdir);
342
+ const config = readWorkspaceConfig(workdir);
343
+ config.reviewer = { mode: "delegated-subagent", model_selector: "devin/kimi-k3", name_prefix: "pi-plans-reviewer", confirmed_at: "2025-12-31T00:00:00Z" };
344
+ writeWorkspaceConfig(workdir, config);
345
+ const view = showStateView(workdir);
346
+ assert.equal(view.reviewer.model_selector, "devin/kimi-k3");
347
+ assert.equal(view.globalConfigPath, path.join(globalDir, "config.json"));
348
+ assert.ok(view.notices.some((n) => n.includes("not yet migrated")));
349
+ assert.ok(readWorkspaceConfig(workdir).reviewer, "show does not strip the legacy block");
350
+ assert.equal(fs.existsSync(path.join(globalDir, "config.json")), false, "show never writes the global file");
351
+ });
352
+ });
353
+
354
+ describe("seedGlobalRoleFromLegacy", () => {
355
+ it("drops thinking_level and demotes confirmed-inherit", () => {
356
+ assert.equal(legacyReviewerHasUserIntent({ mode: "delegated-subagent", model_selector: null, confirmed_at: null }), false);
357
+ assert.equal(legacyReviewerHasUserIntent({ mode: "current-session", model_selector: null, confirmed_at: null }), true);
358
+ const { role } = seedGlobalRoleFromLegacy({ mode: "delegated-subagent", model_selector: "a/b", thinking_level: "high", confirmed_at: "2025-12-31T00:00:00Z" });
359
+ assert.equal(role.thinking_level, null, "legacy blocks never carry a level");
360
+ assert.equal(role.confirmed_at, "2025-12-31T00:00:00Z");
361
+ const inherit = seedGlobalRoleFromLegacy({ mode: "delegated-subagent", model_selector: null, confirmed_at: "2025-12-31T00:00:00Z" });
362
+ assert.equal(inherit.role.confirmed_at, null);
363
+ });
364
+ });
365
+
366
+ describe("StateError surface", () => {
367
+ it("setRole surfaces GlobalStateError as StateError", () => {
368
+ const workdir = mkWorkdir("stateerror");
369
+ assert.throws(() => setRole(workdir, { role: "reviewer", mode: "bogus" as string }), StateError);
370
+ });
371
+ });
@@ -63,7 +63,7 @@ async function loadParsers() {
63
63
 
64
64
  function openStore(root: string, sqlite: typeof import("node:sqlite")): Store {
65
65
  const canonicalRoot = fs.realpathSync(root);
66
- const dbPath = path.join(canonicalRoot, ".git", "pi_plans", "code_graph.db");
66
+ const dbPath = path.join(canonicalRoot, ".git", "pi-plans", "code_graph.db");
67
67
  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
68
68
  return new Store({ dbPath, worktreeRoot: canonicalRoot, gitCommonDir: path.join(canonicalRoot, ".git") }, sqlite);
69
69
  }
@@ -145,7 +145,7 @@ function makeRepoWith(files: Record<string, string>): { root: string } {
145
145
  }
146
146
  git(root0, ["add", "-A"]);
147
147
  git(root0, ["commit", "-m", "init"]);
148
- fs.mkdirSync(path.join(root0, ".git", "pi_plans"), { recursive: true });
148
+ fs.mkdirSync(path.join(root0, ".git", "pi-plans"), { recursive: true });
149
149
  return { root: fs.realpathSync(root0) };
150
150
  }
151
151
 
@@ -285,14 +285,14 @@ test("graph read marks unexpected fallbacks and stays silent for flag-off", asyn
285
285
  assert.doesNotMatch(off, /^\[graph-read fallback/);
286
286
  assert.match(off, /return a \+ b;/);
287
287
 
288
- fs.writeFileSync(path.join(root, ".git", "pi_plans", "config.json"), "{broken");
288
+ fs.writeFileSync(path.join(root, ".git", "pi-plans", "config.json"), "{broken");
289
289
  const broken = firstText(await tools.read.execute("f4", { path: "math.ts" }, undefined, undefined, ctx));
290
290
  assert.match(broken, /^\[graph-read fallback: config read failed → native\]/);
291
291
 
292
292
  // runtime unavailable: DB whose stored worktree is another directory.
293
293
  // ensureRuntime caches by db path within a process, so probe in a child
294
294
  // process with fresh module state: checkWorktree must reject the foreign DB.
295
- fs.writeFileSync(path.join(root, ".git", "pi_plans", "config.json"), JSON.stringify({ schema: 1, graph_enabled: true }));
295
+ fs.writeFileSync(path.join(root, ".git", "pi-plans", "config.json"), JSON.stringify({ schema: 1, graph_enabled: true }));
296
296
  const otherRoot0 = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-graph-other-"));
297
297
  git(otherRoot0, ["init", "--initial-branch=main"]);
298
298
  const otherRoot = fs.realpathSync(otherRoot0);
@@ -301,7 +301,7 @@ test("graph read marks unexpected fallbacks and stays silent for flag-off", asyn
301
301
  runtime.runtime.sqlite,
302
302
  );
303
303
  foreignStore.close();
304
- fs.copyFileSync(path.join(otherRoot, "foreign.db"), path.join(root, ".git", "pi_plans", "code_graph.db"));
304
+ fs.copyFileSync(path.join(otherRoot, "foreign.db"), path.join(root, ".git", "pi-plans", "code_graph.db"));
305
305
  fs.writeFileSync(path.join(root, "math.ts"), "export function add(a: number, b: number): number { return a + b; }\n");
306
306
  const probeScript = [
307
307
  "const fs = await import('node:fs');",
@@ -48,7 +48,7 @@ describe("planning write guard", () => {
48
48
 
49
49
  // State dir writes are allowed; @-prefixed paths are normalized.
50
50
  assert.equal(
51
- planningWriteBlockReason({ workdir, toolName: "write", rawPath: ".git/pi_plans/tmp/note" }),
51
+ planningWriteBlockReason({ workdir, toolName: "write", rawPath: ".git/pi-plans/tmp/note" }),
52
52
  null,
53
53
  );
54
54
  assert.ok(planningWriteBlockReason({ workdir, toolName: "write", rawPath: "@/src/main.ts" }));