pi-plans 0.5.7 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +126 -0
- package/README.md +49 -39
- package/agents/ref-analyst.md +7 -4
- package/agents/reviewer.md +12 -3
- package/index.ts +74 -40
- package/package.json +2 -1
- package/references/pi-planning-workflow.md +45 -58
- package/references/plan-artifact-template.md +71 -60
- package/references/state-and-config.md +63 -47
- package/scripts/bench/pi-adapter/pi_plans_bench.py +33 -22
- package/scripts/bench/pi-adapter/rpc_driver.mjs +4 -4
- package/scripts/run-tests.ts +12 -1
- package/scripts/validate.ts +22 -10
- package/skills/debug-and-plan/SKILL.md +4 -4
- package/skills/plan-big/SKILL.md +5 -5
- package/skills/plan-normal/SKILL.md +5 -5
- package/skills/plan-small/SKILL.md +5 -5
- package/skills/plan-with-refs/SKILL.md +8 -8
- package/skills/planning/SKILL.md +1 -1
- package/src/ask-form.ts +4 -4
- package/src/auditor.ts +126 -0
- package/src/auto-approve.ts +1 -1
- package/src/autocomplete.ts +19 -17
- package/src/code-graph/commands.ts +2 -2
- package/src/code-graph/community.ts +1 -1
- package/src/code-graph/paths.ts +1 -1
- package/src/code-graph/watch.ts +2 -2
- package/src/compaction.ts +3 -3
- package/src/config-command.ts +154 -76
- package/src/dashboard.ts +257 -0
- package/src/exec.ts +709 -705
- package/src/global-state.ts +304 -0
- package/src/guard.ts +16 -3
- package/src/messaging.ts +44 -0
- package/src/plan.ts +421 -112
- package/src/query-hook.ts +4 -4
- package/src/refine-prompts.ts +14 -72
- package/src/refine-ui-helpers.ts +24 -5
- package/src/refine-ui-state.ts +1 -1
- package/src/refine-ui.ts +1 -1
- package/src/resume-command.ts +40 -130
- package/src/resume.ts +15 -17
- package/src/role-panels.ts +542 -0
- package/src/run-context.ts +5 -4
- package/src/run-picker.ts +98 -0
- package/src/state.ts +380 -77
- package/src/subagent.ts +32 -1
- package/src/task-tool.ts +100 -0
- package/src/tasks.ts +189 -0
- package/src/thinking-levels.ts +67 -0
- package/src/ui-language.ts +3 -54
- package/src/workflow-state.ts +78 -57
- package/tests/analyze-refs.test.ts +35 -18
- package/tests/ask-choice-pros-cons.test.ts +147 -0
- package/tests/ask-choice-schema.test.ts +0 -12
- package/tests/ask-choice.test.ts +2 -49
- package/tests/ask-form-tool.test.ts +4 -5
- package/tests/ask-form.test.ts +2 -2
- package/tests/auditor.test.ts +111 -0
- package/tests/auto-approve.test.ts +7 -10
- package/tests/autocomplete.test.ts +8 -11
- package/tests/code-graph-apply-action.test.ts +2 -2
- package/tests/code-graph-commands.test.ts +2 -2
- package/tests/code-graph-index.test.ts +2 -2
- package/tests/code-graph-loop.e2e.test.ts +1 -1
- package/tests/code-graph-mutations.test.ts +1 -1
- package/tests/code-graph-rollback.test.ts +1 -1
- package/tests/code-graph-v05.test.ts +2 -2
- package/tests/compaction.test.ts +1 -1
- package/tests/config-command.test.ts +103 -100
- package/tests/dashboard.test.ts +268 -0
- package/tests/exec-lifecycle.test.ts +181 -115
- package/tests/exec-panel-lifecycle.test.ts +106 -251
- package/tests/exec.test.ts +617 -1706
- package/tests/execute-plan.test.ts +44 -19
- package/tests/extension-load.test.ts +48 -0
- package/tests/global-state.test.ts +371 -0
- package/tests/graph-aware-file-tools.test.ts +5 -5
- package/tests/guard.test.ts +1 -1
- package/tests/multi-run.test.ts +184 -0
- package/tests/plan.test.ts +139 -62
- package/tests/plans.test.ts +7 -79
- package/tests/refine-prompts.test.ts +20 -71
- package/tests/refine-resume.test.ts +27 -22
- package/tests/refine-ui.test.ts +6 -15
- package/tests/resume-lifecycle.test.ts +37 -22
- package/tests/resume.test.ts +43 -88
- package/tests/role-panels.test.ts +391 -0
- package/tests/run-context.test.ts +1 -1
- package/tests/run-ownership.test.ts +1 -1
- package/tests/stale-ctx.test.ts +218 -0
- package/tests/state.test.ts +151 -32
- package/tests/subagent-thinking.test.ts +65 -0
- package/tests/subagent-usage.test.ts +1 -1
- package/tests/task-tool.test.ts +61 -0
- package/tests/thinking-levels.test.ts +77 -0
- package/tests/ui-language.test.ts +2 -17
- package/tests/workflow-state.test.ts +17 -99
- package/tools/analyze-refs.ts +67 -32
- package/tools/ask-choice.ts +19 -49
- package/tools/code-graph.ts +2 -2
- package/tools/execute-plan.ts +63 -33
- package/tools/graph-aware-file-tools.ts +6 -4
- package/tools/plans.ts +40 -66
- package/tools/refine.ts +101 -164
- package/agents/criticizer.md +0 -18
- package/scripts/bench/pi-adapter/__pycache__/pi_plans_bench.cpython-312.pyc +0 -0
- package/src/panel.ts +0 -473
- package/src/termination-prompt.ts +0 -73
- package/tests/goal-wait.test.ts +0 -269
- package/tests/panel-i-zero.test.ts +0 -420
- package/tests/panel.test.ts +0 -355
|
@@ -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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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(
|
|
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", "
|
|
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", "
|
|
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", "
|
|
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", "
|
|
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", "
|
|
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');",
|
package/tests/guard.test.ts
CHANGED
|
@@ -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/
|
|
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" }));
|