pi-plans 0.6.0 → 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 +3 -3
- package/README.md +39 -37
- package/agents/reviewer.md +12 -3
- package/index.ts +42 -35
- package/package.json +1 -1
- package/references/pi-planning-workflow.md +44 -60
- package/references/plan-artifact-template.md +71 -60
- package/references/state-and-config.md +59 -43
- 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 +20 -9
- package/skills/debug-and-plan/SKILL.md +3 -3
- package/skills/plan-big/SKILL.md +3 -3
- package/skills/plan-normal/SKILL.md +3 -3
- package/skills/plan-small/SKILL.md +4 -4
- package/skills/plan-with-refs/SKILL.md +6 -6
- 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 +146 -73
- package/src/dashboard.ts +257 -0
- package/src/exec.ts +692 -919
- package/src/global-state.ts +304 -0
- package/src/guard.ts +18 -19
- package/src/messaging.ts +44 -0
- package/src/plan.ts +421 -112
- package/src/query-hook.ts +4 -4
- package/src/refine-prompts.ts +12 -70
- 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 +34 -128
- package/src/role-panels.ts +542 -0
- package/src/run-context.ts +3 -10
- package/src/state.ts +272 -72
- package/src/subagent.ts +19 -29
- 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 +63 -58
- package/tests/analyze-refs.test.ts +35 -18
- 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 +3 -103
- 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 +33 -81
- 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 +7 -53
- package/tools/code-graph.ts +2 -2
- package/tools/execute-plan.ts +48 -99
- package/tools/graph-aware-file-tools.ts +4 -10
- package/tools/plans.ts +40 -66
- package/tools/refine.ts +101 -164
- package/agents/criticizer.md +0 -18
- package/agents/executor.md +0 -26
- 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
|
@@ -3,7 +3,7 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import * as url from "node:url";
|
|
6
|
-
import { after, before, describe, it } from "node:test";
|
|
6
|
+
import { after, before, beforeEach, describe, it } from "node:test";
|
|
7
7
|
|
|
8
8
|
import { configPiPlansCommand } from "../src/config-command.ts";
|
|
9
9
|
import { startRun } from "../src/state.ts";
|
|
@@ -25,13 +25,25 @@ interface HarnessOptions {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
let root: string;
|
|
28
|
+
let globalDir: string;
|
|
29
|
+
let previousGlobalDir: string | undefined;
|
|
28
30
|
|
|
29
31
|
before(() => {
|
|
30
32
|
root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-config-command-"));
|
|
33
|
+
previousGlobalDir = process.env.PI_PLANS_GLOBAL_DIR;
|
|
34
|
+
globalDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-global-config-command-"));
|
|
35
|
+
process.env.PI_PLANS_GLOBAL_DIR = globalDir;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
fs.rmSync(path.join(globalDir, "config.json"), { force: true });
|
|
31
40
|
});
|
|
32
41
|
|
|
33
42
|
after(() => {
|
|
43
|
+
if (previousGlobalDir === undefined) delete process.env.PI_PLANS_GLOBAL_DIR;
|
|
44
|
+
else process.env.PI_PLANS_GLOBAL_DIR = previousGlobalDir;
|
|
34
45
|
fs.rmSync(root, { recursive: true, force: true });
|
|
46
|
+
fs.rmSync(globalDir, { recursive: true, force: true });
|
|
35
47
|
});
|
|
36
48
|
|
|
37
49
|
function workdir(name: string): string {
|
|
@@ -41,11 +53,15 @@ function workdir(name: string): string {
|
|
|
41
53
|
}
|
|
42
54
|
|
|
43
55
|
function configPath(workdirPath: string): string {
|
|
44
|
-
return path.join(workdirPath, ".git", "
|
|
56
|
+
return path.join(workdirPath, ".git", "pi-plans", "config.json");
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
function runPath(workdirPath: string, runId: string): string {
|
|
48
|
-
return path.join(workdirPath, ".git", "
|
|
60
|
+
return path.join(workdirPath, ".git", "pi-plans", "runs", runId, "run.json");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readGlobal(): any {
|
|
64
|
+
return JSON.parse(fs.readFileSync(path.join(globalDir, "config.json"), "utf8"));
|
|
49
65
|
}
|
|
50
66
|
|
|
51
67
|
function readJson(filePath: string): any {
|
|
@@ -61,6 +77,7 @@ function makeContext(workdirPath: string, options: HarnessOptions) {
|
|
|
61
77
|
const ctx = {
|
|
62
78
|
cwd: workdirPath,
|
|
63
79
|
hasUI: true,
|
|
80
|
+
mode: "rpc",
|
|
64
81
|
model: options.model ?? null,
|
|
65
82
|
scopedModels: options.scopedModels ?? [],
|
|
66
83
|
modelRegistry: {
|
|
@@ -83,6 +100,15 @@ function makeContext(workdirPath: string, options: HarnessOptions) {
|
|
|
83
100
|
return { ctx, recorded };
|
|
84
101
|
}
|
|
85
102
|
|
|
103
|
+
/** Default answers for the four workspace menus (language/artifact/refs/graph). */
|
|
104
|
+
function workspaceDefaults(question: string, labels: string[]): string | undefined {
|
|
105
|
+
if (question === "Language?") return labels[0];
|
|
106
|
+
if (question === "Artifact root?") return labels[0];
|
|
107
|
+
if (question === "Refs root (plan-with-refs downloads)?") return labels[0];
|
|
108
|
+
if (question === "Code graph?") return labels[0];
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
86
112
|
describe("config-pi-plans command", () => {
|
|
87
113
|
it("registers the slash command in index.ts and delegates to the helper", () => {
|
|
88
114
|
const source = fs.readFileSync(path.join(ROOT, "index.ts"), "utf8");
|
|
@@ -91,25 +117,14 @@ describe("config-pi-plans command", () => {
|
|
|
91
117
|
assert.match(source, /await configPiPlansCommand\(args, ctx\);/);
|
|
92
118
|
});
|
|
93
119
|
|
|
94
|
-
it("writes the
|
|
120
|
+
it("writes the workspace settings and switches the reviewer mode globally; current-session skips the model step (Q-3)", async () => {
|
|
95
121
|
const dir = workdir("full-wizard");
|
|
96
|
-
const { ctx } = makeContext(dir, {
|
|
122
|
+
const { ctx, recorded } = makeContext(dir, {
|
|
97
123
|
model: { provider: "live", id: "pro" },
|
|
98
124
|
availableModels: [{ provider: "registry", id: "critic" }],
|
|
99
125
|
select: (question, labels) => {
|
|
100
|
-
if (question === "Language?") return labels.find((label) => label.includes("en"));
|
|
101
|
-
if (question === "Artifact root?") return labels.find((label) => label.includes("./.git/pi_plans/plans"));
|
|
102
|
-
if (question === "Refs root (plan-with-refs downloads)?") return labels.find((label) => label.includes(".git/pi-plans/refs"));
|
|
103
|
-
if (question === "Code graph?") return labels.find((label) => label.includes("Disable code graph"));
|
|
104
126
|
if (question === "Reviewer mode?") return labels.find((label) => label.includes("Switch to current-session"));
|
|
105
|
-
|
|
106
|
-
if (question === "Criticizer mode?") return labels.find((label) => label.includes("Keep delegated-subagent"));
|
|
107
|
-
if (question === "Criticizer model?") return labels.find((label) => label.includes("Other..."));
|
|
108
|
-
return undefined;
|
|
109
|
-
},
|
|
110
|
-
input: (prompt) => {
|
|
111
|
-
if (prompt === "Criticizer model selector:") return "custom/critic-model";
|
|
112
|
-
return undefined;
|
|
127
|
+
return workspaceDefaults(question, labels);
|
|
113
128
|
},
|
|
114
129
|
});
|
|
115
130
|
|
|
@@ -117,24 +132,44 @@ describe("config-pi-plans command", () => {
|
|
|
117
132
|
|
|
118
133
|
assert.ok(fs.existsSync(configPath(dir)));
|
|
119
134
|
const config = readJson(configPath(dir));
|
|
120
|
-
assert.equal(config.language.tag, "
|
|
121
|
-
assert.equal(config.
|
|
122
|
-
assert.equal(config.artifact_root, "./.git/pi_plans/plans");
|
|
123
|
-
assert.equal(config.artifact_root_source, "user");
|
|
135
|
+
assert.equal(config.language.tag, "zh-Hans", "labels[0] on the language menu");
|
|
136
|
+
assert.equal(config.artifact_root, "./.git/pi-plans/plans");
|
|
124
137
|
assert.equal(config.refs_root, ".git/pi-plans/refs");
|
|
125
|
-
assert.equal(config.
|
|
126
|
-
assert.
|
|
127
|
-
assert.equal(config
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
assert.equal(
|
|
131
|
-
|
|
132
|
-
assert.
|
|
133
|
-
|
|
138
|
+
assert.equal(config.graph_enabled, true, "labels[0] on the graph menu enables");
|
|
139
|
+
assert.equal("reviewer" in config, false, "the reviewer role never lands in the workspace config");
|
|
140
|
+
assert.equal("criticizer" in config, false);
|
|
141
|
+
// The mode switch went to the GLOBAL config.
|
|
142
|
+
const globalRole = readGlobal().reviewer;
|
|
143
|
+
assert.equal(globalRole.mode, "current-session");
|
|
144
|
+
// Q-3: no reviewer-model prompt in current-session mode.
|
|
145
|
+
assert.equal(recorded.selects.some((entry) => entry.question === "Reviewer model?"), false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("shows a keep/change entry menu when the reviewer is unconfirmed", async () => {
|
|
149
|
+
const dir = workdir("entry-menu");
|
|
150
|
+
const { ctx, recorded } = makeContext(dir, {
|
|
151
|
+
select: (question, labels) => {
|
|
152
|
+
if (question === "Reviewer mode?") return labels[0];
|
|
153
|
+
if (question === "Reviewer model?") return labels[0];
|
|
154
|
+
return workspaceDefaults(question, labels);
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await configPiPlansCommand("", ctx);
|
|
159
|
+
|
|
160
|
+
const entry = recorded.selects.find((entry) => entry.question === "Reviewer model?");
|
|
161
|
+
assert.ok(entry);
|
|
162
|
+
assert.deepEqual(entry?.labels, [
|
|
163
|
+
"1. Keep current (unconfirmed; first refine will ask)",
|
|
164
|
+
"2. Choose model & thinking level…",
|
|
165
|
+
]);
|
|
166
|
+
// Keeping the unconfirmed role writes nothing to the global config.
|
|
167
|
+
assert.equal(fs.existsSync(path.join(globalDir, "config.json")), false);
|
|
168
|
+
assert.equal(fs.existsSync(configPath(dir)), true);
|
|
134
169
|
});
|
|
135
170
|
|
|
136
|
-
it("
|
|
137
|
-
const dir = workdir("
|
|
171
|
+
it("change flow via menus persists model + level to the global config", async () => {
|
|
172
|
+
const dir = workdir("change-menus");
|
|
138
173
|
const { ctx, recorded } = makeContext(dir, {
|
|
139
174
|
model: { provider: "live", id: "pro" },
|
|
140
175
|
scopedModels: [{ model: { provider: "scoped", id: "model-a" } }],
|
|
@@ -143,51 +178,56 @@ describe("config-pi-plans command", () => {
|
|
|
143
178
|
{ provider: "registry", id: "model-b" },
|
|
144
179
|
],
|
|
145
180
|
select: (question, labels) => {
|
|
146
|
-
if (question === "Language?") return labels[0];
|
|
147
|
-
if (question === "Artifact root?") return labels[0];
|
|
148
|
-
if (question === "Refs root (plan-with-refs downloads)?") return labels[0];
|
|
149
|
-
if (question === "Code graph?") return labels[0];
|
|
150
181
|
if (question === "Reviewer mode?") return labels[0];
|
|
151
|
-
if (question === "Reviewer model?")
|
|
152
|
-
|
|
153
|
-
|
|
182
|
+
if (question === "Reviewer model?") {
|
|
183
|
+
const modelMenus = recorded.selects.filter((e) => e.question === "Reviewer model?");
|
|
184
|
+
if (modelMenus.length === 1) return labels.find((label) => label.includes("Choose model & thinking level"));
|
|
185
|
+
return labels.find((label) => label.includes("Use registry/model-b"));
|
|
186
|
+
}
|
|
187
|
+
if (question.startsWith("Reviewer thinking level?")) return labels[0];
|
|
188
|
+
return workspaceDefaults(question, labels);
|
|
154
189
|
},
|
|
155
190
|
});
|
|
156
191
|
|
|
157
192
|
await configPiPlansCommand("", ctx);
|
|
158
193
|
|
|
159
|
-
const
|
|
160
|
-
assert.
|
|
161
|
-
assert.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
"5. Other...",
|
|
167
|
-
]);
|
|
168
|
-
assert.equal(fs.existsSync(configPath(dir)), false);
|
|
169
|
-
assert.equal(recorded.notifies.at(-1)?.severity, "warning");
|
|
194
|
+
const globalRole = readGlobal().reviewer;
|
|
195
|
+
assert.equal(globalRole.mode, "delegated-subagent");
|
|
196
|
+
assert.equal(globalRole.model_selector, "registry/model-b");
|
|
197
|
+
assert.equal(globalRole.thinking_level, null, "first row = default → null");
|
|
198
|
+
assert.ok(globalRole.confirmed_at);
|
|
199
|
+
// The workspace config stays reviewer-free.
|
|
200
|
+
assert.equal("reviewer" in readJson(configPath(dir)), false);
|
|
170
201
|
});
|
|
171
202
|
|
|
172
|
-
it("
|
|
203
|
+
it("an invalid manual selector keeps the current role and still writes workspace settings (F-011)", async () => {
|
|
173
204
|
const dir = workdir("invalid-model");
|
|
174
205
|
const { ctx, recorded } = makeContext(dir, {
|
|
175
206
|
select: (question, labels) => {
|
|
176
|
-
if (question === "Language?") return labels[0];
|
|
177
|
-
if (question === "Artifact root?") return labels[0];
|
|
178
|
-
if (question === "Refs root (plan-with-refs downloads)?") return labels[0];
|
|
179
|
-
if (question === "Code graph?") return labels[0];
|
|
180
207
|
if (question === "Reviewer mode?") return labels[0];
|
|
181
|
-
if (question === "Reviewer model?")
|
|
182
|
-
|
|
208
|
+
if (question === "Reviewer model?") {
|
|
209
|
+
const modelMenus = recorded.selects.filter((e) => e.question === "Reviewer model?");
|
|
210
|
+
if (modelMenus.length === 1) return labels.find((label) => label.includes("Choose model & thinking level"));
|
|
211
|
+
return labels.find((label) => label.includes("Other..."));
|
|
212
|
+
}
|
|
213
|
+
return workspaceDefaults(question, labels);
|
|
183
214
|
},
|
|
184
215
|
input: () => "bad selector",
|
|
185
216
|
});
|
|
186
217
|
|
|
187
218
|
await configPiPlansCommand("", ctx);
|
|
188
219
|
|
|
189
|
-
assert.equal(fs.existsSync(
|
|
190
|
-
assert.equal(
|
|
220
|
+
assert.equal(fs.existsSync(path.join(globalDir, "config.json")), false, "nothing persisted for the reviewer");
|
|
221
|
+
assert.equal(fs.existsSync(configPath(dir)), true, "workspace settings still written");
|
|
222
|
+
assert.equal(
|
|
223
|
+
recorded.notifies.some((entry) => entry.message.includes("Model selector must be an exact provider/model string.")),
|
|
224
|
+
true,
|
|
225
|
+
);
|
|
226
|
+
assert.equal(
|
|
227
|
+
recorded.notifies.some((entry) => entry.message.includes("Reviewer change failed")),
|
|
228
|
+
true,
|
|
229
|
+
"the summary reports the failed reviewer change",
|
|
230
|
+
);
|
|
191
231
|
});
|
|
192
232
|
|
|
193
233
|
it("does not write when the wizard is cancelled", async () => {
|
|
@@ -204,49 +244,15 @@ describe("config-pi-plans command", () => {
|
|
|
204
244
|
assert.equal(fs.existsSync(configPath(dir)), false);
|
|
205
245
|
});
|
|
206
246
|
|
|
207
|
-
it("renders a two-option inherit menu when no model sources exist", async () => {
|
|
208
|
-
const dir = workdir("empty-model-menu");
|
|
209
|
-
const { ctx, recorded } = makeContext(dir, {
|
|
210
|
-
model: null,
|
|
211
|
-
scopedModels: [],
|
|
212
|
-
availableModels: [],
|
|
213
|
-
select: (question, labels) => {
|
|
214
|
-
if (question === "Language?") return labels[0];
|
|
215
|
-
if (question === "Artifact root?") return labels[0];
|
|
216
|
-
if (question === "Refs root (plan-with-refs downloads)?") return labels[0];
|
|
217
|
-
if (question === "Code graph?") return labels[0];
|
|
218
|
-
if (question === "Reviewer mode?") return labels[0];
|
|
219
|
-
if (question === "Reviewer model?") return labels[0];
|
|
220
|
-
return undefined;
|
|
221
|
-
},
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
await configPiPlansCommand("", ctx);
|
|
225
|
-
|
|
226
|
-
const reviewerModelPrompt = recorded.selects.find((entry) => entry.question === "Reviewer model?");
|
|
227
|
-
assert.ok(reviewerModelPrompt);
|
|
228
|
-
assert.deepEqual(reviewerModelPrompt?.labels, [
|
|
229
|
-
"1. Keep current default (inherit)",
|
|
230
|
-
"2. Other...",
|
|
231
|
-
]);
|
|
232
|
-
assert.equal(fs.existsSync(configPath(dir)), false);
|
|
233
|
-
});
|
|
234
|
-
|
|
235
247
|
it("keeps the active run snapshot unchanged", async () => {
|
|
236
248
|
const dir = workdir("active-run");
|
|
237
249
|
const { run } = startRun(dir, { topic: "config", skill: "plan-small", requestText: "x" });
|
|
238
250
|
const before = readJson(runPath(dir, run.run_id));
|
|
239
251
|
const { ctx } = makeContext(dir, {
|
|
240
252
|
select: (question, labels) => {
|
|
241
|
-
if (question === "Language?") return labels.find((label) => label.includes("en"));
|
|
242
|
-
if (question === "Artifact root?") return labels.find((label) => label.includes("./.git/pi_plans/plans"));
|
|
243
|
-
if (question === "Refs root (plan-with-refs downloads)?") return labels.find((label) => label.includes(".git/pi-plans/refs"));
|
|
244
|
-
if (question === "Code graph?") return labels.find((label) => label.includes("Enable code graph"));
|
|
245
253
|
if (question === "Reviewer mode?") return labels.find((label) => label.includes("Keep delegated-subagent"));
|
|
246
254
|
if (question === "Reviewer model?") return labels[0];
|
|
247
|
-
|
|
248
|
-
if (question === "Criticizer model?") return labels[0];
|
|
249
|
-
return undefined;
|
|
255
|
+
return workspaceDefaults(question, labels);
|
|
250
256
|
},
|
|
251
257
|
});
|
|
252
258
|
|
|
@@ -255,10 +261,7 @@ describe("config-pi-plans command", () => {
|
|
|
255
261
|
const after = readJson(runPath(dir, run.run_id));
|
|
256
262
|
assert.deepEqual(after, before);
|
|
257
263
|
const config = readJson(configPath(dir));
|
|
258
|
-
assert.equal(config
|
|
259
|
-
assert.equal(config.artifact_root, "./.git/pi_plans/plans");
|
|
260
|
-
assert.equal(config.reviewer.model_selector, null);
|
|
261
|
-
assert.ok(config.reviewer.confirmed_at);
|
|
264
|
+
assert.equal("reviewer" in config, false);
|
|
262
265
|
});
|
|
263
266
|
|
|
264
267
|
it("with an active run, a successful save never notifies an error (impl review F-001)", async () => {
|
|
@@ -266,14 +269,14 @@ describe("config-pi-plans command", () => {
|
|
|
266
269
|
startRun(dir, { topic: "config-guard", skill: "plan-small", requestText: "x" });
|
|
267
270
|
const { ctx, recorded } = makeContext(dir, {
|
|
268
271
|
select: (question, labels) => {
|
|
269
|
-
if (question === "Language?") return labels.find((label) => label.includes("
|
|
272
|
+
if (question === "Language?") return labels.find((label) => label.includes("zh-Hans")) ?? labels[0];
|
|
270
273
|
return labels[0];
|
|
271
274
|
},
|
|
272
275
|
});
|
|
273
276
|
|
|
274
277
|
await configPiPlansCommand("", ctx);
|
|
275
278
|
|
|
276
|
-
assert.equal(
|
|
279
|
+
assert.equal(fs.existsSync(configPath(dir)), true, "the wizard must keep saving");
|
|
277
280
|
assert.equal(
|
|
278
281
|
recorded.notifies.find((entry) => entry.severity === "error"),
|
|
279
282
|
undefined,
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/** Tests for the task dashboard (v0.6.1): compact widget, expanded tree,
|
|
2
|
+
* markers, width adaptation, and the shared summary line.
|
|
3
|
+
*
|
|
4
|
+
* Width invariant: the widget host measures every rendered line with pi-tui's
|
|
5
|
+
* `visibleWidth` and throws an uncaughtException — killing the whole TUI
|
|
6
|
+
* session — when a line is wider than the terminal (real crashes:
|
|
7
|
+
* "Rendered line 157 exceeds terminal width (231 > 230)" and
|
|
8
|
+
* "(114 > 113)"). These tests therefore measure with the *host's* function,
|
|
9
|
+
* not a local reimplementation, so a local/host divergence is caught here
|
|
10
|
+
* rather than in a user's terminal.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as assert from "node:assert/strict";
|
|
14
|
+
import { describe, it } from "node:test";
|
|
15
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
16
|
+
import { parsePlanTasks } from "../src/plan.ts";
|
|
17
|
+
import { buildTaskView } from "../src/tasks.ts";
|
|
18
|
+
import { visibleWidth as localVisibleWidth } from "../src/refine-ui-helpers.ts";
|
|
19
|
+
import {
|
|
20
|
+
deriveDashboardModel,
|
|
21
|
+
formatDashboardSummaryLine,
|
|
22
|
+
openTaskSummary,
|
|
23
|
+
renderDashboardLines,
|
|
24
|
+
renderDashboardTreeLines,
|
|
25
|
+
taskMarker,
|
|
26
|
+
} from "../src/dashboard.ts";
|
|
27
|
+
|
|
28
|
+
const PLAN = `## Tasks
|
|
29
|
+
|
|
30
|
+
- Task-1: 解析器 — files: src/plan.ts; wave: 1
|
|
31
|
+
- Task-2: 工具 — files: src/task-tool.ts; wave: 1
|
|
32
|
+
- Task-3: 核心 — deps: Task-1, Task-2; files: src/exec.ts; wave: 2
|
|
33
|
+
- Task-3.1: 注入
|
|
34
|
+
- Task-3.2: 状态机
|
|
35
|
+
|
|
36
|
+
### Execution Waves
|
|
37
|
+
|
|
38
|
+
- wave 1: Task-1, Task-2 — 并行
|
|
39
|
+
- wave 2: Task-3 — 串行
|
|
40
|
+
|
|
41
|
+
## Verification Checks
|
|
42
|
+
|
|
43
|
+
- [ ] \`VC-001\` covers \`Task-1\`; pass condition: 解析单测
|
|
44
|
+
- [ ] \`VC-002\` covers \`Task-3\`; pass condition: 核心测试
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
function model(progress?: Record<string, { status: "complete" | "skipped" }>) {
|
|
48
|
+
const tasks = buildTaskView(parsePlanTasks(PLAN), progress);
|
|
49
|
+
const checklist = [
|
|
50
|
+
{ id: "VC-001", text: "`VC-001` covers `Task-1`; pass condition: x", done: progress?.["Task-1"]?.status === "complete" },
|
|
51
|
+
{ id: "VC-002", text: "`VC-002` covers `Task-3`; pass condition: y", done: false },
|
|
52
|
+
];
|
|
53
|
+
return deriveDashboardModel("demo-run", tasks, checklist, { startedAt: new Date().toISOString() });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("dashboard model", () => {
|
|
57
|
+
it("summary line carries task and VC progress", () => {
|
|
58
|
+
const line = formatDashboardSummaryLine(model({ "Task-1": { status: "complete" } }));
|
|
59
|
+
assert.match(line, /tasks 1\/5/);
|
|
60
|
+
assert.match(line, /VC 1\/2/);
|
|
61
|
+
assert.match(line, /wave 1/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("markers: ✓ complete, ~ skipped, ▸ current, · pending", () => {
|
|
65
|
+
const m = model({ "Task-1": { status: "complete" }, "Task-2": { status: "skipped" } });
|
|
66
|
+
const flat = m.tasks.flatMap(function walk(t: { id: string; children: unknown[] }) { return [t, ...t.children]; } as never) as never[];
|
|
67
|
+
const byId = new Map(flat.map((t: { id: string }) => [t.id, t]));
|
|
68
|
+
assert.equal(taskMarker(byId.get("Task-1")!, null), "✓");
|
|
69
|
+
assert.equal(taskMarker(byId.get("Task-2")!, null), "~");
|
|
70
|
+
// Task-3 is the first open task in wave order → current.
|
|
71
|
+
assert.equal(taskMarker(byId.get("Task-3")!, "Task-3"), "▸");
|
|
72
|
+
assert.equal(taskMarker(byId.get("Task-3.1")!, "Task-3"), "·");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("open task summary lists the current wave and later waves", () => {
|
|
76
|
+
const tasks = buildTaskView(parsePlanTasks(PLAN), { "Task-1": { status: "complete" } });
|
|
77
|
+
const summary = openTaskSummary(tasks);
|
|
78
|
+
assert.match(summary, /current wave 1: Task-2/);
|
|
79
|
+
assert.match(summary, /later waves: Task-3/);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("compact widget rendering", () => {
|
|
84
|
+
it("renders the current task and files at wide width", () => {
|
|
85
|
+
const lines = renderDashboardLines(model(), 100);
|
|
86
|
+
assert.ok(lines.some((line) => line.includes("Task-1")));
|
|
87
|
+
assert.ok(lines.some((line) => line.includes("src/plan.ts")));
|
|
88
|
+
assert.ok(lines.some((line) => line.includes("Ctrl+Shift+T")));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("narrow width drops file lists and stays inside the width", () => {
|
|
92
|
+
const lines = renderDashboardLines(model(), 40);
|
|
93
|
+
assert.ok(!lines.some((line) => line.includes("src/plan.ts")));
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
assert.ok(visibleWidth(line) <= 40, `line too wide (${visibleWidth(line)}): ${line}`);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("shows the pause state when paused", () => {
|
|
100
|
+
const m = model();
|
|
101
|
+
m.paused = true;
|
|
102
|
+
m.pausedReason = "no task-status change in 3 rounds";
|
|
103
|
+
const lines = renderDashboardLines(m, 80);
|
|
104
|
+
assert.ok(lines.some((line) => line.includes("⏸")));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("shows audit failures when present", () => {
|
|
108
|
+
const m = model();
|
|
109
|
+
m.auditRounds = 1;
|
|
110
|
+
m.auditFailed = ["VC-002"];
|
|
111
|
+
const lines = renderDashboardLines(m, 100);
|
|
112
|
+
assert.ok(lines.some((line) => line.includes("VC-002")));
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("expanded tree rendering", () => {
|
|
117
|
+
it("renders the nested tree with markers, deps, and files at wide width", () => {
|
|
118
|
+
const m = model({ "Task-1": { status: "complete" }, "Task-2": { status: "complete" } });
|
|
119
|
+
const lines = renderDashboardTreeLines(m, 120);
|
|
120
|
+
assert.ok(lines.some((line) => /✓ Task-1/.test(line)));
|
|
121
|
+
assert.ok(lines.some((line) => line.trim().startsWith("· Task-3.1")));
|
|
122
|
+
assert.ok(lines.some((line) => line.includes("(deps: Task-1, Task-2)")));
|
|
123
|
+
assert.ok(lines.some((line) => line.includes("Verification checks:")));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("narrow width clips titles and drops file/dep decorations", () => {
|
|
127
|
+
const lines = renderDashboardTreeLines(model(), 60);
|
|
128
|
+
assert.ok(!lines.some((line) => line.includes("(deps:")));
|
|
129
|
+
assert.ok(!lines.some((line) => line.includes("[")));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("lists the audit state when a round has run", () => {
|
|
133
|
+
const m = model();
|
|
134
|
+
m.auditRounds = 2;
|
|
135
|
+
m.auditFailed = ["VC-001"];
|
|
136
|
+
const lines = renderDashboardTreeLines(m, 100);
|
|
137
|
+
assert.ok(lines.some((line) => line.includes("Completion audit: round 2")));
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/** Regression cover for the TUI crash: "Rendered line 157 exceeds terminal
|
|
142
|
+
* width (231 > 230)" (2026-09-30, v0.99.1) and the same +1 overflow at 113
|
|
143
|
+
* columns. Root cause: the box rows were built as
|
|
144
|
+
* `content.padEnd(width).slice(0, width) + border` — one column over on every
|
|
145
|
+
* row — and the width math counted UTF-16 units, so CJK titles overflowed by
|
|
146
|
+
* far more than one. */
|
|
147
|
+
describe("width invariant (TUI crash regression)", () => {
|
|
148
|
+
const WIDTHS = [1, 2, 3, 5, 8, 20, 40, 63, 64, 79, 80, 95, 96, 113, 114, 120, 200, 229, 230, 231, 300, 400];
|
|
149
|
+
|
|
150
|
+
/** Long, CJK-heavy plan: the content that triggered the crash. */
|
|
151
|
+
const STRESS_PLAN = `## Tasks
|
|
152
|
+
|
|
153
|
+
- Task-1: 移除 typebox 运行时重复声明并改为 peerDependencies 通配声明 — files: package.json, package-lock.json, src/exec.ts, tests/exec.test.ts, tests/impl-review.test.ts; wave: 1
|
|
154
|
+
- Task-2: 适配器单测覆盖 check 双分支、schema 透传与 compiled() 非空鸭子形状断言 — files: tests/compiled-schema.test.ts, src/code-graph/schema.ts; wave: 1
|
|
155
|
+
- Task-3: 全量验证与冒烟启动 — deps: Task-1, Task-2; files: scripts/validate.ts; wave: 2
|
|
156
|
+
- Task-3.1: 注入
|
|
157
|
+
|
|
158
|
+
### Execution Waves
|
|
159
|
+
|
|
160
|
+
- wave 1: Task-1, Task-2 — 并行
|
|
161
|
+
- wave 2: Task-3 — 串行
|
|
162
|
+
|
|
163
|
+
## Verification Checks
|
|
164
|
+
|
|
165
|
+
- [ ] \`VC-1\` covers \`Task-1\`; pass condition: package.json 无 typebox 残留
|
|
166
|
+
- [ ] \`VC-2\` covers \`Task-2\`; pass condition: 适配器单测全绿
|
|
167
|
+
`;
|
|
168
|
+
|
|
169
|
+
function stressModel() {
|
|
170
|
+
const tasks = buildTaskView(parsePlanTasks(STRESS_PLAN), { "Task-1": { status: "complete" } });
|
|
171
|
+
const checklist = [
|
|
172
|
+
{ id: "VC-1", text: "`VC-1` covers `Task-1`; pass condition: package.json 无 `@sinclair/typebox` 残留", done: true },
|
|
173
|
+
{ id: "VC-2", text: "`VC-2` covers `Task-2`; pass condition: 适配器单测全绿且断言 compiled() 非空", done: false },
|
|
174
|
+
];
|
|
175
|
+
return deriveDashboardModel("20260930T051445Z-fix-binary-ref-download-with-a-very-long-topic-slug", tasks, checklist, {
|
|
176
|
+
auditRounds: 1,
|
|
177
|
+
auditFailed: ["VC-2"],
|
|
178
|
+
pausedReason: "连续 3 轮无任务状态变化自动暂停 — 完成审计已耗尽预算",
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const states = (): [string, ReturnType<typeof stressModel>][] => {
|
|
183
|
+
const base = stressModel();
|
|
184
|
+
return [
|
|
185
|
+
["running", base],
|
|
186
|
+
["paused", { ...base, paused: true, pausedReason: base.pausedReason as string }],
|
|
187
|
+
["all-terminal", { ...base, tasks: base.tasks.map((t) => ({ ...t, status: "complete" as const })) }],
|
|
188
|
+
["audit-failed", { ...base, auditRounds: 3, auditFailed: ["VC-1", "VC-2"] }],
|
|
189
|
+
];
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
for (const [label, render] of [
|
|
193
|
+
["compact", renderDashboardLines],
|
|
194
|
+
["tree", renderDashboardTreeLines],
|
|
195
|
+
] as const) {
|
|
196
|
+
it(`${label} view never exceeds the terminal width at any width`, () => {
|
|
197
|
+
for (const [state, m] of states()) {
|
|
198
|
+
for (const width of WIDTHS) {
|
|
199
|
+
for (const line of render(m, width)) {
|
|
200
|
+
assert.ok(
|
|
201
|
+
visibleWidth(line) <= width,
|
|
202
|
+
`${label}/${state} @${width}: line is ${visibleWidth(line)} columns: ${line}`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
it("compact box rows are exactly the terminal width (the +1 border overflow)", () => {
|
|
211
|
+
for (const [state, m] of states()) {
|
|
212
|
+
for (const width of [40, 64, 113, 230]) {
|
|
213
|
+
for (const line of renderDashboardLines(m, width)) {
|
|
214
|
+
assert.equal(visibleWidth(line), width, `${state} @${width}: ${line}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("keeps the progress bar and the right border at wide widths", () => {
|
|
221
|
+
const m = stressModel();
|
|
222
|
+
const [top, progress] = renderDashboardLines(m, 230);
|
|
223
|
+
assert.ok(top.startsWith("┌─ "), "top row opens with the box corner");
|
|
224
|
+
assert.ok(top.endsWith("┐"), "top row closes with the right border");
|
|
225
|
+
assert.ok(progress.includes("█"), "progress bar survives a long topic");
|
|
226
|
+
assert.ok(!top.includes("█"), "the bar moved to the progress row, not the title row");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("title row is π-plans: <plan>, brand accented and the rest muted", () => {
|
|
230
|
+
const m = stressModel();
|
|
231
|
+
const fg = (color: string, text: string) => `<${color}>${text}</${color}>`;
|
|
232
|
+
const [title] = renderDashboardLines(m, 100, { fg });
|
|
233
|
+
// Brand in the accent colour, plan name in muted — as sibling spans, so a
|
|
234
|
+
// closing reset cannot strip the colour off everything after it.
|
|
235
|
+
assert.ok(title.startsWith("<muted>┌─ </muted><accent>π-plans</accent>"), `brand span: ${title}`);
|
|
236
|
+
assert.ok(title.includes("<muted>: 20260930T051445Z-"), `plan name muted: ${title}`);
|
|
237
|
+
assert.ok(!/<muted>[^<]*<\/muted><accent>[^<]*<\/accent><muted>[^<]*π-plans/.test(title), "brand is not nested inside a muted span");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("progress row spells out the counts and carries elapsed time then the bar", () => {
|
|
241
|
+
const m = stressModel();
|
|
242
|
+
const [, progress] = renderDashboardLines(m, 100);
|
|
243
|
+
assert.match(progress, /│ Tasks \d+\/\d+ · Verification checks \d+\/\d+ /);
|
|
244
|
+
// Elapsed time precedes the bar, both on the progress row.
|
|
245
|
+
assert.match(progress, /\d{2}:\d{2}:\d{2} [█░]+/);
|
|
246
|
+
assert.ok(!/VC \d+\//.test(progress), "the VC abbreviation is gone");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("drops the bar, then elapsed time, when the row cannot fit them", () => {
|
|
250
|
+
const m = stressModel();
|
|
251
|
+
for (const width of [12, 20, 24, 30, 40, 64]) {
|
|
252
|
+
const [, progress] = renderDashboardLines(m, width);
|
|
253
|
+
assert.ok(visibleWidth(progress) <= width, `progress row fits at ${width}`);
|
|
254
|
+
}
|
|
255
|
+
// Where the full counts fit they always survive; the abbreviation the old
|
|
256
|
+
// layout used is never substituted back in.
|
|
257
|
+
assert.match(renderDashboardLines(m, 40)[1], /Tasks \d+\/\d+ · Verification checks \d+\/\d+/);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("local width helper agrees with the host on every glyph the dashboard emits", () => {
|
|
261
|
+
// The dashboard sizes rows with the local helper but the host measures
|
|
262
|
+
// them. A disagreement in the crash-causing direction (local < host)
|
|
263
|
+
// silently reintroduces the overflow, so pin the glyph set.
|
|
264
|
+
for (const glyph of ["┌", "┐", "└", "┘", "│", "─", "⏸", "▸", "·", "✗", "✓", "☑", "☐", "█", "░", "—", "…", "~"]) {
|
|
265
|
+
assert.equal(localVisibleWidth(glyph), visibleWidth(glyph), `glyph width mismatch for ${JSON.stringify(glyph)}`);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
});
|