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
package/tests/goal-wait.test.ts
DELETED
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
import * as assert from "node:assert/strict";
|
|
2
|
-
import * as fs from "node:fs";
|
|
3
|
-
import * as os from "node:os";
|
|
4
|
-
import * as path from "node:path";
|
|
5
|
-
import { after, describe, it } from "node:test";
|
|
6
|
-
import {
|
|
7
|
-
GOAL_WAIT_CUSTOM_TYPE, filterGoalWaitMessages, getExecution, noteCompactionStarted,
|
|
8
|
-
registerExecutionTurnHandlers, restoreFromSession, startExecution, stopExecution,
|
|
9
|
-
} from "../src/exec.ts";
|
|
10
|
-
import { executeCommand, executeHandoff, setCurrentApi } from "../tools/execute-plan.ts";
|
|
11
|
-
|
|
12
|
-
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
|
|
13
|
-
after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
14
|
-
let serial = 0;
|
|
15
|
-
|
|
16
|
-
async function setup(mode = "tui") {
|
|
17
|
-
const cwd = path.join(root, String(++serial));
|
|
18
|
-
fs.mkdirSync(cwd);
|
|
19
|
-
const planPath = path.join(cwd, "PLAN_v1.md");
|
|
20
|
-
fs.writeFileSync(planPath, "## Verifier Checklist\n- [ ] `VC-001` first\n- [ ] `VC-002` second\n");
|
|
21
|
-
const handlers = new Map<string, Array<(event: any, ctx: any) => any>>();
|
|
22
|
-
const messages: any[] = [];
|
|
23
|
-
const entries: any[] = [];
|
|
24
|
-
const notices: string[] = [];
|
|
25
|
-
const pending: unknown[] = [];
|
|
26
|
-
let idle = true;
|
|
27
|
-
let status = "";
|
|
28
|
-
const ctx: any = {
|
|
29
|
-
cwd, mode, hasUI: mode === "tui" || mode === "rpc", sessionManager: {},
|
|
30
|
-
isIdle: () => idle, hasPendingMessages: () => pending.length > 0,
|
|
31
|
-
ui: { setStatus: (_key: string, s: string) => { status = s; },
|
|
32
|
-
notify: (s: string) => notices.push(s), theme: { fg: (_c: string, s: string) => s },
|
|
33
|
-
confirm: async () => { throw new Error("same-plan resume must not re-enter handoff"); } },
|
|
34
|
-
};
|
|
35
|
-
const pi: any = {
|
|
36
|
-
on: (name: string, handler: any) => handlers.set(name, [...(handlers.get(name) ?? []), handler]),
|
|
37
|
-
appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data: structuredClone(data) }),
|
|
38
|
-
sendUserMessage: () => { throw new Error("goal-wait must not impersonate a user"); },
|
|
39
|
-
sendMessage: (message: any, options: any) => {
|
|
40
|
-
messages.push({ ...message, options });
|
|
41
|
-
if (message.customType === GOAL_WAIT_CUSTOM_TYPE && options?.triggerTurn) idle = false;
|
|
42
|
-
},
|
|
43
|
-
};
|
|
44
|
-
registerExecutionTurnHandlers(pi);
|
|
45
|
-
setCurrentApi(pi);
|
|
46
|
-
await startExecution(pi, ctx, planPath, [
|
|
47
|
-
{ id: "VC-001", text: "first", done: false }, { id: "VC-002", text: "second", done: false },
|
|
48
|
-
]);
|
|
49
|
-
const emit = async (name: string, event = {}) => {
|
|
50
|
-
for (const handler of handlers.get(name) ?? []) await handler(event, ctx);
|
|
51
|
-
};
|
|
52
|
-
const begin = async () => { idle = false; await emit("agent_start"); };
|
|
53
|
-
const turn = async (text = "working", stopReason = "stop") => emit("turn_end", {
|
|
54
|
-
message: { role: "assistant", stopReason, content: [{ type: "text", text },
|
|
55
|
-
...(stopReason === "toolUse" ? [{ type: "toolCall", id: "call", name: "read", arguments: {} }] : [])],
|
|
56
|
-
usage: { input: 10, output: 5 } }, toolResults: [],
|
|
57
|
-
});
|
|
58
|
-
const settle = async () => { idle = true; await emit("agent_settled"); };
|
|
59
|
-
const run = async (text = "working", stopReason = "stop") => { await begin(); await turn(text, stopReason); await settle(); };
|
|
60
|
-
return { pi, ctx, planPath, messages, entries, notices, pending, emit, begin, turn, settle, run,
|
|
61
|
-
wakes: () => messages.filter(m => m.customType === GOAL_WAIT_CUSTOM_TYPE),
|
|
62
|
-
status: () => status, setIdle: (value: boolean) => { idle = value; } };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
describe("goal-wait settled lifecycle", () => {
|
|
66
|
-
it("never queues on ten tool turns and never wakes after completion", async () => {
|
|
67
|
-
const h = await setup();
|
|
68
|
-
await h.begin();
|
|
69
|
-
for (let i = 0; i < 10; i++) await h.turn("working", "toolUse");
|
|
70
|
-
assert.equal(h.wakes().length, 0);
|
|
71
|
-
assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
|
|
72
|
-
await h.turn("[DONE:VC-001] [DONE:VC-002]");
|
|
73
|
-
await h.settle();
|
|
74
|
-
await h.settle();
|
|
75
|
-
assert.equal(getExecution(), null);
|
|
76
|
-
assert.equal(h.wakes().length, 0);
|
|
77
|
-
assert.equal(h.messages.filter(m => m.customType === "pi-plans-complete").length, 1);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
for (const mode of ["tui", "rpc"]) {
|
|
81
|
-
it(`${mode}: sends one hidden fresh wake and deduplicates settled`, async () => {
|
|
82
|
-
const h = await setup(mode);
|
|
83
|
-
await h.run("[DONE:VC-001]");
|
|
84
|
-
await h.settle();
|
|
85
|
-
const [wake] = h.wakes();
|
|
86
|
-
assert.equal(h.wakes().length, 1);
|
|
87
|
-
assert.equal(wake.display, false);
|
|
88
|
-
assert.equal(wake.options.triggerTurn, true);
|
|
89
|
-
assert.match(wake.content, /1\/2 verifier items done/);
|
|
90
|
-
assert.match(wake.content, /- `VC-002` second/);
|
|
91
|
-
assert.doesNotMatch(wake.content, /- `VC-001` first/);
|
|
92
|
-
assert.deepEqual(filterGoalWaitMessages([wake]), [wake]);
|
|
93
|
-
assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
for (const mode of ["print", "json"]) {
|
|
98
|
-
it(`${mode}: tracks markers without any automatic wake`, async () => {
|
|
99
|
-
const h = await setup(mode);
|
|
100
|
-
await h.run("[DONE:VC-001]");
|
|
101
|
-
assert.equal(getExecution()?.items[0].done, true);
|
|
102
|
-
await h.run("failed", "error");
|
|
103
|
-
await h.run("[DONE:VC-002]");
|
|
104
|
-
assert.equal(h.wakes().length, 0);
|
|
105
|
-
assert.equal(getExecution(), null);
|
|
106
|
-
assert.equal(h.messages.find(m => m.customType === "pi-plans-complete").options.triggerTurn, false);
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
for (const gate of ["busy", "pending", "inFlight", "resumeGuard", "pendingFollowUpPrompt", "lifecycle"]) {
|
|
111
|
-
it(`does not send or count when ${gate} owns continuation`, async () => {
|
|
112
|
-
const h = await setup();
|
|
113
|
-
await h.begin();
|
|
114
|
-
await h.turn();
|
|
115
|
-
h.setIdle(gate !== "busy");
|
|
116
|
-
if (gate === "pending") h.pending.push("user message", { customType: "another-extension" });
|
|
117
|
-
else if (gate === "lifecycle") noteCompactionStarted(h.ctx, undefined);
|
|
118
|
-
else if (gate !== "busy") h.ctx.sessionManager.__executionCompaction = { [gate]: gate === "pendingFollowUpPrompt" ? "follow-up" : true };
|
|
119
|
-
const original = structuredClone(h.pending);
|
|
120
|
-
await h.emit("agent_settled");
|
|
121
|
-
assert.equal(h.wakes().length, 0);
|
|
122
|
-
assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
|
|
123
|
-
assert.deepEqual(h.pending, original);
|
|
124
|
-
if (gate === "busy") {
|
|
125
|
-
await h.settle();
|
|
126
|
-
assert.equal(h.wakes().length, 1, "a busy notification must not consume the real settled cycle");
|
|
127
|
-
}
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
for (const stopReason of ["error", "aborted"]) {
|
|
132
|
-
it(`${stopReason}: pauses once and only genuine input resumes`, async () => {
|
|
133
|
-
const h = await setup();
|
|
134
|
-
await h.run("failed", stopReason);
|
|
135
|
-
assert.equal(getExecution()?.goalWait?.paused, true);
|
|
136
|
-
await h.settle();
|
|
137
|
-
assert.equal(h.notices.length, 1);
|
|
138
|
-
await h.emit("input", { source: "extension" });
|
|
139
|
-
await h.emit("before_agent_start");
|
|
140
|
-
assert.equal(getExecution()?.goalWait?.paused, true);
|
|
141
|
-
assert.equal(h.wakes().length, 0);
|
|
142
|
-
await h.emit("input", { source: "rpc" });
|
|
143
|
-
assert.equal(getExecution()?.goalWait?.paused, false);
|
|
144
|
-
assert.equal(h.wakes().length, 0, "input is already owned by Pi");
|
|
145
|
-
await h.run();
|
|
146
|
-
assert.equal(h.wakes().length, 1);
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
it("leaves retries to Pi and refuses unknown or intentional tool termination", async () => {
|
|
151
|
-
const h = await setup();
|
|
152
|
-
await h.begin();
|
|
153
|
-
await h.turn("retryable failure", "error");
|
|
154
|
-
await h.emit("agent_end");
|
|
155
|
-
assert.equal(h.wakes().length, 0);
|
|
156
|
-
assert.equal(getExecution()?.goalWait?.paused, false);
|
|
157
|
-
await h.turn("recovered");
|
|
158
|
-
await h.settle();
|
|
159
|
-
assert.equal(h.wakes().length, 1);
|
|
160
|
-
for (const reason of ["toolUse", "length", "unknown"]) await h.run("intentional stop", reason);
|
|
161
|
-
assert.equal(h.wakes().length, 1);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
for (const [text, count, field] of [["working", 3, "noProgressRounds"], ["waiting for CI", 6, "waitRounds"]] as const) {
|
|
165
|
-
it(`pauses at ${count} ${field} settled cycles, not intermediate turns`, async () => {
|
|
166
|
-
const h = await setup();
|
|
167
|
-
for (let i = 1; i <= count; i++) {
|
|
168
|
-
await h.begin();
|
|
169
|
-
for (let j = 0; j < 4; j++) await h.turn("tool work", "toolUse");
|
|
170
|
-
assert.equal(getExecution()?.goalWait?.[field], i - 1);
|
|
171
|
-
await h.turn(text);
|
|
172
|
-
await h.settle();
|
|
173
|
-
await h.settle();
|
|
174
|
-
assert.equal(getExecution()?.goalWait?.[field], i);
|
|
175
|
-
}
|
|
176
|
-
assert.equal(h.wakes().length, count - 1);
|
|
177
|
-
assert.equal(getExecution()?.goalWait?.paused, true);
|
|
178
|
-
assert.match(h.status(), /goal-wait paused/);
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
it("real progress resets both nonzero counters through registered handlers", async () => {
|
|
183
|
-
const h = await setup();
|
|
184
|
-
await h.run();
|
|
185
|
-
await h.run("waiting for tests");
|
|
186
|
-
assert.equal(getExecution()?.goalWait?.noProgressRounds, 1);
|
|
187
|
-
assert.equal(getExecution()?.goalWait?.waitRounds, 1);
|
|
188
|
-
await h.run("[DONE:VC-001]");
|
|
189
|
-
assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
|
|
190
|
-
assert.equal(getExecution()?.goalWait?.waitRounds, 0);
|
|
191
|
-
const persisted = h.entries.filter(e => e.customType === "pi-plans-exec").at(-1).data;
|
|
192
|
-
assert.equal(persisted.items[0].done, true);
|
|
193
|
-
assert.equal(persisted.goalWait.noProgressRounds, 0);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it("same-plan explicit handoff resumes without losing verified progress", async () => {
|
|
197
|
-
const h = await setup();
|
|
198
|
-
await h.run("[DONE:VC-001]");
|
|
199
|
-
await h.run("cancelled", "aborted");
|
|
200
|
-
const ex = getExecution()!;
|
|
201
|
-
const before = structuredClone(ex);
|
|
202
|
-
const outcome = await executeCommand(h.ctx, h.planPath);
|
|
203
|
-
assert.equal(outcome.status, "executing");
|
|
204
|
-
assert.equal(getExecution(), ex);
|
|
205
|
-
assert.deepEqual(ex.items, before.items);
|
|
206
|
-
assert.deepEqual(ex.usage, before.usage);
|
|
207
|
-
assert.equal(ex.startedAt, before.startedAt);
|
|
208
|
-
assert.equal(ex.goalWait?.paused, false);
|
|
209
|
-
assert.equal(h.wakes().length, 2);
|
|
210
|
-
await executeCommand(h.ctx, h.planPath);
|
|
211
|
-
assert.equal(h.wakes().length, 2);
|
|
212
|
-
await assert.rejects(executeHandoff(h.ctx, h.planPath), /must not re-enter handoff/);
|
|
213
|
-
const other = path.join(h.ctx.cwd, "PLAN_v2.md");
|
|
214
|
-
fs.copyFileSync(h.planPath, other);
|
|
215
|
-
await assert.rejects(executeCommand(h.ctx, other), /must not re-enter handoff/);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it("a paused command during another run preserves its next settled opportunity", async () => {
|
|
219
|
-
const h = await setup();
|
|
220
|
-
await h.run("interrupted", "aborted");
|
|
221
|
-
await h.begin();
|
|
222
|
-
const outcome = await executeCommand(h.ctx, h.planPath);
|
|
223
|
-
assert.equal(outcome.status, "executing");
|
|
224
|
-
assert.equal(getExecution()?.goalWait?.paused, false);
|
|
225
|
-
assert.equal(h.wakes().length, 0, "busy command must not enqueue a kick");
|
|
226
|
-
await h.turn("still incomplete");
|
|
227
|
-
await h.settle();
|
|
228
|
-
assert.equal(h.wakes().length, 1);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it("restore preserves pause and counters but cannot replay a wake", async () => {
|
|
232
|
-
const h = await setup();
|
|
233
|
-
for (let i = 0; i < 3; i++) await h.run();
|
|
234
|
-
const oldWake = h.wakes().at(-1);
|
|
235
|
-
const before = structuredClone(getExecution()?.goalWait);
|
|
236
|
-
await restoreFromSession(h.pi, h.ctx, h.entries);
|
|
237
|
-
await h.settle();
|
|
238
|
-
assert.deepEqual(getExecution()?.goalWait, before);
|
|
239
|
-
assert.equal(h.wakes().length, 2);
|
|
240
|
-
assert.deepEqual(filterGoalWaitMessages([oldWake]), []);
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
for (const exit of ["stop", "complete", "replacement", "shutdown"]) {
|
|
244
|
-
it(`${exit} invalidates wake identity without filtering user messages`, async () => {
|
|
245
|
-
const h = await setup();
|
|
246
|
-
await h.run();
|
|
247
|
-
const wake = h.wakes()[0];
|
|
248
|
-
if (exit === "stop") await stopExecution(h.pi, h.ctx, "test");
|
|
249
|
-
if (exit === "complete") await h.turn("[DONE:VC-001] [DONE:VC-002]");
|
|
250
|
-
if (exit === "replacement") await startExecution(h.pi, h.ctx, h.planPath, [{ id: "VC-003", text: "new", done: false }]);
|
|
251
|
-
if (exit === "shutdown") await h.emit("session_shutdown");
|
|
252
|
-
await h.settle();
|
|
253
|
-
const user = { role: "user", content: "Goal wait: this is my text" };
|
|
254
|
-
const other = { customType: "another-extension", content: "continue" };
|
|
255
|
-
assert.deepEqual(filterGoalWaitMessages([wake, user, other]), [user, other]);
|
|
256
|
-
assert.equal(h.wakes().length, 1);
|
|
257
|
-
});
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
it("a synchronous dispatch failure pauses once instead of leaving a retry lock", async () => {
|
|
261
|
-
const h = await setup();
|
|
262
|
-
h.pi.sendMessage = () => { throw new Error("dispatch failed"); };
|
|
263
|
-
await h.run();
|
|
264
|
-
await h.settle();
|
|
265
|
-
assert.equal(getExecution()?.goalWait?.paused, true);
|
|
266
|
-
assert.match(h.notices[0], /dispatch failed/);
|
|
267
|
-
assert.equal(h.notices.length, 1);
|
|
268
|
-
});
|
|
269
|
-
});
|
|
@@ -1,420 +0,0 @@
|
|
|
1
|
-
/** 0.5.1 fix suite: fake "I 0/0" panel counts, plan lint notices, form
|
|
2
|
-
* confirmed-state (goal-x aligned ■/□), uniform-gray frame borders. */
|
|
3
|
-
|
|
4
|
-
import * as assert from "node:assert/strict";
|
|
5
|
-
import * as fs from "node:fs";
|
|
6
|
-
import * as os from "node:os";
|
|
7
|
-
import * as path from "node:path";
|
|
8
|
-
import { after, before, describe, it } from "node:test";
|
|
9
|
-
import { lintImplItems, parseImplItems } from "../src/plan.ts";
|
|
10
|
-
import {
|
|
11
|
-
appendRunNotice,
|
|
12
|
-
getRun,
|
|
13
|
-
initState,
|
|
14
|
-
lintPlanIntoNotices,
|
|
15
|
-
startRun,
|
|
16
|
-
} from "../src/state.ts";
|
|
17
|
-
import { recordCheckpointTransition } from "../tools/plans.ts";
|
|
18
|
-
import { createCheckpoint } from "../src/workflow-state.ts";
|
|
19
|
-
import {
|
|
20
|
-
derivePanelModel,
|
|
21
|
-
formatPanelSummaryLine,
|
|
22
|
-
renderPanelLines,
|
|
23
|
-
themePanelLines,
|
|
24
|
-
type PanelModel,
|
|
25
|
-
} from "../src/panel.ts";
|
|
26
|
-
import { getExecution, restoreFromSession, startExecution } from "../src/exec.ts";
|
|
27
|
-
import {
|
|
28
|
-
allAnswered,
|
|
29
|
-
createFormState,
|
|
30
|
-
formAnswers,
|
|
31
|
-
formHandleKey,
|
|
32
|
-
formRender,
|
|
33
|
-
type FormQuestion,
|
|
34
|
-
type FormTheme,
|
|
35
|
-
} from "../src/ask-form.ts";
|
|
36
|
-
|
|
37
|
-
let tmpRoot: string;
|
|
38
|
-
let counter = 0;
|
|
39
|
-
|
|
40
|
-
function freshWorkdir(): string {
|
|
41
|
-
counter += 1;
|
|
42
|
-
const workdir = path.join(tmpRoot, `repo-${counter}`);
|
|
43
|
-
fs.mkdirSync(workdir, { recursive: true });
|
|
44
|
-
return workdir;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function qs(n: number): FormQuestion[] {
|
|
48
|
-
return Array.from({ length: n }, (_, i) => ({
|
|
49
|
-
question: `Question ${i + 1}?`,
|
|
50
|
-
options: [
|
|
51
|
-
{ label: `Opt A${i + 1}`, recommended: true },
|
|
52
|
-
{ label: `Opt B${i + 1}` },
|
|
53
|
-
],
|
|
54
|
-
allowOther: true,
|
|
55
|
-
questionId: `q-${i + 1}`,
|
|
56
|
-
autoComplete: true,
|
|
57
|
-
}));
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const HEADER = "## Implementation Items";
|
|
61
|
-
|
|
62
|
-
before(async () => {
|
|
63
|
-
tmpRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "panel-i-zero-"));
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
after(() => {
|
|
67
|
-
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
// ── VC-001: tolerant parser ─────────────────────────────────────────────────
|
|
71
|
-
|
|
72
|
-
describe("parseImplItems tolerant grammar (VC-001)", () => {
|
|
73
|
-
it("accepts half-width colon, full-width colon and no-colon separators", () => {
|
|
74
|
-
const doc = [
|
|
75
|
-
"# plan",
|
|
76
|
-
HEADER,
|
|
77
|
-
"",
|
|
78
|
-
"- `I-001`: half-width colon body",
|
|
79
|
-
"- `I-002`:full-width colon 正文",
|
|
80
|
-
"- `I-003` plain space separator",
|
|
81
|
-
"",
|
|
82
|
-
].join("\n");
|
|
83
|
-
const items = parseImplItems(doc);
|
|
84
|
-
assert.deepEqual(items.map((i) => i.id), ["I-001", "I-002", "I-003"]);
|
|
85
|
-
assert.equal(items[2]!.text, "plain space separator");
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("ignores indented child bullets, VC lines and malformed ids", () => {
|
|
89
|
-
const doc = [
|
|
90
|
-
"# plan",
|
|
91
|
-
HEADER,
|
|
92
|
-
"",
|
|
93
|
-
"- `I-001`: top level",
|
|
94
|
-
" - `I-002`: indented child must not match",
|
|
95
|
-
"\t- `I-003`: tab-indented child must not match",
|
|
96
|
-
"- [ ] `VC-001` covers `I-001`; verifier line",
|
|
97
|
-
"- `I-x`: malformed id",
|
|
98
|
-
"- `I-004`missing-separator",
|
|
99
|
-
"",
|
|
100
|
-
].join("\n");
|
|
101
|
-
const items = parseImplItems(doc);
|
|
102
|
-
assert.deepEqual(items.map((i) => i.id), ["I-001"]);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it("dedupes repeated ids and returns [] without a section header", () => {
|
|
106
|
-
const dup = [HEADER, "", "- `I-001`: first", "- `I-001`: second"].join("\n");
|
|
107
|
-
assert.deepEqual(parseImplItems(dup).map((i) => i.text), ["first"]);
|
|
108
|
-
assert.deepEqual(parseImplItems("# no section"), []);
|
|
109
|
-
assert.deepEqual(parseImplItems([HEADER, "", "- [ ] `VC-001` only VC lines"].join("\n")), []);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it("lintImplItems warns only when the section exists but parses empty", () => {
|
|
113
|
-
assert.equal(lintImplItems("# no section"), null);
|
|
114
|
-
assert.equal(lintImplItems([HEADER, "", "- `I-001`: ok"].join("\n")), null);
|
|
115
|
-
const warn = lintImplItems([HEADER, "", "- I-001 no backticked id"].join("\n"));
|
|
116
|
-
assert.ok(warn !== null && warn.includes("0 项"));
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
// ── VC-002: durable notices at the lint entry points ───────────────────────
|
|
121
|
-
|
|
122
|
-
describe("run notices persistence (VC-002)", () => {
|
|
123
|
-
function bootRun(name: string): { workdir: string; runId: string } {
|
|
124
|
-
const workdir = freshWorkdir();
|
|
125
|
-
initState(workdir);
|
|
126
|
-
const { run } = startRun(workdir, { skill: "plan-small", topic: `lint-${name}` });
|
|
127
|
-
return { workdir, runId: run.run_id };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
it("appendRunNotice persists to run.json and dedupes by source+text", () => {
|
|
131
|
-
const { workdir, runId } = bootRun("dedupe");
|
|
132
|
-
appendRunNotice(workdir, runId, { kind: "plan-lint", source: "lint-impl-items", text: "w1" });
|
|
133
|
-
appendRunNotice(workdir, runId, { kind: "plan-lint", source: "lint-impl-items", text: "w1" });
|
|
134
|
-
appendRunNotice(workdir, runId, { kind: "plan-lint", source: "lint-impl-items", text: "w2" });
|
|
135
|
-
const run = getRun(workdir, runId)!;
|
|
136
|
-
assert.deepEqual(run.notices?.map((n) => n.text), ["w1", "w2"]);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it("reads legacy run.json files without a notices field", () => {
|
|
140
|
-
const { workdir, runId } = bootRun("legacy");
|
|
141
|
-
const run = getRun(workdir, runId)!;
|
|
142
|
-
assert.equal(run.notices, undefined);
|
|
143
|
-
appendRunNotice(workdir, runId, { kind: "k", source: "s", text: "t" });
|
|
144
|
-
assert.equal(getRun(workdir, runId)!.notices?.length, 1);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it("lintPlanIntoNotices: hit on zero-parse section, no-op on healthy plan", () => {
|
|
148
|
-
const { workdir, runId } = bootRun("helper");
|
|
149
|
-
const badPlan = path.join(workdir, "PLAN_bad.md");
|
|
150
|
-
fs.writeFileSync(badPlan, [HEADER, "", "- I-001 unparseable"].join("\n"));
|
|
151
|
-
assert.ok(lintPlanIntoNotices(workdir, runId, badPlan) !== null);
|
|
152
|
-
assert.ok(getRun(workdir, runId)!.notices?.length === 1);
|
|
153
|
-
const goodPlan = path.join(workdir, "PLAN_good.md");
|
|
154
|
-
fs.writeFileSync(goodPlan, [HEADER, "", "- `I-001`: fine"].join("\n"));
|
|
155
|
-
assert.equal(lintPlanIntoNotices(workdir, runId, goodPlan), null);
|
|
156
|
-
assert.ok(getRun(workdir, runId)!.notices?.length === 1, "no extra notice");
|
|
157
|
-
assert.equal(lintPlanIntoNotices(workdir, runId, path.join(workdir, "missing.md")), null);
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it("recordCheckpointTransition(plan-written) records the lint notice (entry point 1)", () => {
|
|
161
|
-
const { workdir, runId } = bootRun("cp");
|
|
162
|
-
const plan = path.join(workdir, "PLAN_v1.md");
|
|
163
|
-
fs.writeFileSync(plan, [HEADER, "", "- I-001 broken"].join("\n"));
|
|
164
|
-
createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
|
|
165
|
-
recordCheckpointTransition({ sessionManager: {} }, workdir, runId, {
|
|
166
|
-
transition: "plan-written",
|
|
167
|
-
planPath: plan,
|
|
168
|
-
});
|
|
169
|
-
const run = getRun(workdir, runId)!;
|
|
170
|
-
assert.equal(run.notices?.length, 1);
|
|
171
|
-
assert.match(run.notices![0]!.text, /0 项/);
|
|
172
|
-
// Idempotent on repeat.
|
|
173
|
-
recordCheckpointTransition({ sessionManager: {} }, workdir, runId, {
|
|
174
|
-
transition: "plan-written",
|
|
175
|
-
planPath: plan,
|
|
176
|
-
});
|
|
177
|
-
assert.equal(getRun(workdir, runId)!.notices?.length, 1);
|
|
178
|
-
// Healthy plan: no notice fires at this entry point either.
|
|
179
|
-
const healthy = path.join(workdir, "PLAN_v2.md");
|
|
180
|
-
fs.writeFileSync(healthy, [HEADER, "", "- `I-002`: fine"].join("\n"));
|
|
181
|
-
recordCheckpointTransition({ sessionManager: {} }, workdir, runId, {
|
|
182
|
-
transition: "plan-written",
|
|
183
|
-
planPath: healthy,
|
|
184
|
-
});
|
|
185
|
-
assert.equal(getRun(workdir, runId)!.notices?.length, 1, "healthy plan adds no notice");
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it("startExecution (execute handoff, entry point 2) records the lint notice", async () => {
|
|
189
|
-
const { workdir, runId } = bootRun("exec");
|
|
190
|
-
const plan = path.join(workdir, "PLAN_v1.md");
|
|
191
|
-
fs.writeFileSync(plan, [HEADER, "", "- I-001 broken"].join("\n"));
|
|
192
|
-
const pi: any = {
|
|
193
|
-
sendMessage: () => {},
|
|
194
|
-
appendEntry: () => {},
|
|
195
|
-
on: () => {},
|
|
196
|
-
};
|
|
197
|
-
const ui = { setStatus: () => {}, setWidget: () => {}, theme: { fg: (_c: string, t: string) => t } };
|
|
198
|
-
const ctx: any = { cwd: workdir, sessionManager: {}, ui };
|
|
199
|
-
await startExecution(pi, ctx, plan, [{ id: "VC-001", text: "`VC-001` covers `I-001`", done: false }]);
|
|
200
|
-
assert.equal(getRun(workdir, runId)!.notices?.length, 1);
|
|
201
|
-
// F-001 (impl review r1): the live execution state carries the warning
|
|
202
|
-
// too — the panel must not wait for a restore to show the ⚠ line.
|
|
203
|
-
assert.match(getExecution()!.implWarning ?? "", /0 项/);
|
|
204
|
-
// Healthy plan at this entry: no notice, no warning (fresh run: the
|
|
205
|
-
// approval guard fail-closes a second handoff on a different plan).
|
|
206
|
-
const fresh = bootRun("exec2");
|
|
207
|
-
const healthy = path.join(fresh.workdir, "PLAN_v1.md");
|
|
208
|
-
fs.writeFileSync(healthy, [HEADER, "", "- `I-002`: fine"].join("\n"));
|
|
209
|
-
await startExecution(pi, { ...ctx, cwd: fresh.workdir }, healthy, [
|
|
210
|
-
{ id: "VC-001", text: "`VC-001` covers `I-001`", done: false },
|
|
211
|
-
]);
|
|
212
|
-
assert.equal((getRun(fresh.workdir, fresh.runId)!.notices ?? []).length, 0, "healthy plan adds no notice");
|
|
213
|
-
assert.equal(getExecution()!.implWarning ?? null, null);
|
|
214
|
-
});
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
// ── VC-003: panel warning line instead of fake "I 0/0" ──────────────────────
|
|
218
|
-
|
|
219
|
-
describe("panel impl-warning rendering (VC-003)", () => {
|
|
220
|
-
const CJK = /[\u4e00-\u9fff]/;
|
|
221
|
-
|
|
222
|
-
function model(implWarning: string | null, lang?: "zh" | "en"): PanelModel {
|
|
223
|
-
return derivePanelModel(
|
|
224
|
-
{
|
|
225
|
-
items: [{ id: "VC-001", text: "`VC-001` covers `I-001`", done: false }],
|
|
226
|
-
implItems: [],
|
|
227
|
-
implStatus: {},
|
|
228
|
-
implWarning,
|
|
229
|
-
...(lang === undefined ? {} : { uiLanguage: lang }),
|
|
230
|
-
},
|
|
231
|
-
"fake-zero",
|
|
232
|
-
false,
|
|
233
|
-
);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
it("replaces the I-count line with an explicit warning, keeping the 7-line envelope", () => {
|
|
237
|
-
const m = model("warning text", "zh");
|
|
238
|
-
assert.equal(m.implWarning, true);
|
|
239
|
-
const lines = renderPanelLines(m, 80);
|
|
240
|
-
assert.equal(lines.length, 7);
|
|
241
|
-
assert.match(lines[2]!, /⚠ plan 格式:Implementation Items 解析 0 项/);
|
|
242
|
-
assert.ok(!lines[2]!.includes("I items 0/0"), "must not render a fake 0/0 count");
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
it("narrow mode badges the warning instead of counting, summary line too", () => {
|
|
246
|
-
const m = model("warning", "zh");
|
|
247
|
-
const narrow = renderPanelLines(m, 20);
|
|
248
|
-
assert.equal(narrow.length, 3);
|
|
249
|
-
assert.match(narrow[1]!, /⚠ I/);
|
|
250
|
-
assert.ok(!narrow[1]!.includes("I 0/0"));
|
|
251
|
-
assert.match(formatPanelSummaryLine(m), /⚠ Implementation Items 解析 0 项/);
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
it("renders English warning chrome for en and for the omitted default (issue #3)", () => {
|
|
255
|
-
for (const lang of ["en", undefined] as const) {
|
|
256
|
-
const m = model("warning", lang);
|
|
257
|
-
const lines = renderPanelLines(m, 80);
|
|
258
|
-
assert.match(lines[2]!, /⚠ plan format: Implementation Items parsed 0 items/);
|
|
259
|
-
const narrow = renderPanelLines(m, 20);
|
|
260
|
-
// 20 columns truncate the badge right after the warning glyph.
|
|
261
|
-
assert.match(narrow[1]!, /⚠ I/);
|
|
262
|
-
assert.match(formatPanelSummaryLine(m), /⚠ Implementation Items parsed 0 items/);
|
|
263
|
-
for (const line of [...lines, ...narrow, formatPanelSummaryLine(m)]) {
|
|
264
|
-
assert.ok(!CJK.test(line), `CJK leaked (lang=${lang ?? "default"}): ${line}`);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
it("keeps the legacy degradation when there is no lint hit", () => {
|
|
270
|
-
const m = model(null);
|
|
271
|
-
assert.equal(m.implWarning, false);
|
|
272
|
-
const lines = renderPanelLines(m, 80);
|
|
273
|
-
assert.match(lines[2]!, /I items 0\/0/);
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
it("restoreFromSession re-parses a stale empty snapshot implItems list", async () => {
|
|
277
|
-
const workdir = freshWorkdir();
|
|
278
|
-
initState(workdir);
|
|
279
|
-
const plan = path.join(workdir, "PLAN_v9.md");
|
|
280
|
-
// 0.5.0's actual drift shape: no colon after the backticked id.
|
|
281
|
-
fs.writeFileSync(plan, [HEADER, "", "- `I-001` schema fix no colon", "- `I-002` notice wiring"].join("\n"));
|
|
282
|
-
const snapshot = {
|
|
283
|
-
planPath: plan,
|
|
284
|
-
items: [{ id: "VC-001", text: "`VC-001` covers `I-001`", done: false }],
|
|
285
|
-
startedAt: "2026-09-18T00:00:00Z",
|
|
286
|
-
implItems: [],
|
|
287
|
-
implStatus: {},
|
|
288
|
-
};
|
|
289
|
-
const pi: any = { sendMessage: () => {}, appendEntry: () => {} };
|
|
290
|
-
const ui = { setStatus: () => {}, setWidget: () => {}, theme: { fg: (_c: string, t: string) => t } };
|
|
291
|
-
const ctx: any = { cwd: workdir, sessionManager: {}, ui };
|
|
292
|
-
await restoreFromSession(pi, ctx, [
|
|
293
|
-
{ type: "custom", customType: "pi-plans-exec", data: snapshot },
|
|
294
|
-
] as any);
|
|
295
|
-
const exec = getExecution()!;
|
|
296
|
-
assert.equal(exec.implItems?.length, 2);
|
|
297
|
-
assert.equal(exec.implWarning ?? null, null);
|
|
298
|
-
const m = derivePanelModel(exec, "restore", false);
|
|
299
|
-
assert.equal(m.implWarning, false);
|
|
300
|
-
assert.equal(m.totalI, 2);
|
|
301
|
-
});
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
// ── VC-004: confirmed-state lifecycle ───────────────────────────────────────
|
|
305
|
-
|
|
306
|
-
describe("form confirmed lifecycle (VC-004)", () => {
|
|
307
|
-
it("chips flip □→■ per question only after Enter", () => {
|
|
308
|
-
const state = createFormState(qs(2));
|
|
309
|
-
let lines = formRender(state, 80, undefined, {
|
|
310
|
-
fg: (c, t) => `⟪${c}⟩${t}⟪/${c}⟫`,
|
|
311
|
-
bg: (c, t) => `⟦${c}⟧${t}⟦/${c}⟧`,
|
|
312
|
-
bold: (t) => `⟪b⟩${t}⟪/b⟫`,
|
|
313
|
-
});
|
|
314
|
-
assert.ok(lines[1]!.includes("□Q1") && lines[1]!.includes("□Q2"));
|
|
315
|
-
formHandleKey(state, "\r"); // confirm Q1 → tab 1
|
|
316
|
-
lines = formRender({ ...state, tab: 0 }, 80, undefined, {
|
|
317
|
-
fg: (c, t) => `⟪${c}⟩${t}⟪/${c}⟫`,
|
|
318
|
-
bg: (c, t) => `⟦${c}⟧${t}⟦/${c}⟧`,
|
|
319
|
-
bold: (t) => `⟪b⟩${t}⟪/b⟫`,
|
|
320
|
-
});
|
|
321
|
-
assert.ok(lines[1]!.includes("■Q1") && lines[1]!.includes("□Q2"));
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
it("moving the cursor after a confirm un-confirms the tab", () => {
|
|
325
|
-
const state = createFormState(qs(1));
|
|
326
|
-
formHandleKey(state, "\r");
|
|
327
|
-
assert.equal(allAnswered(state), true);
|
|
328
|
-
state.tab = 0;
|
|
329
|
-
formHandleKey(state, "\x1b[B"); // cursor to option 2
|
|
330
|
-
assert.equal(allAnswered(state), false);
|
|
331
|
-
assert.equal(formAnswers(state).length, 0, "unconfirmed selection is not an answer");
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
it("Esc while editing keeps prior confirmed answers intact", () => {
|
|
335
|
-
const state = createFormState(qs(2));
|
|
336
|
-
formHandleKey(state, "\r"); // confirm Q1, now on tab 1
|
|
337
|
-
formHandleKey(state, "\x1b[B"); // cursor 0 -> 1
|
|
338
|
-
formHandleKey(state, "\x1b[B"); // cursor 1 -> custom row
|
|
339
|
-
formHandleKey(state, "\r"); // start editing
|
|
340
|
-
formHandleKey(state, "draft");
|
|
341
|
-
assert.equal(formHandleKey(state, "\x1b"), "abort-editing");
|
|
342
|
-
assert.equal(state.confirmed[0], true);
|
|
343
|
-
assert.equal(state.custom[1], null);
|
|
344
|
-
assert.equal(formAnswers(state).length, 1);
|
|
345
|
-
});
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
// ── VC-006: uniform-gray frame borders ──────────────────────────────────────
|
|
349
|
-
|
|
350
|
-
describe("uniform-gray frame borders (VC-006)", () => {
|
|
351
|
-
const theme = {
|
|
352
|
-
fg: (c: string, t: string) => `<${c}>${t}</${c}>`,
|
|
353
|
-
bg: (c: string, t: string) => `[[${c}]]${t}[[/${c}]]`,
|
|
354
|
-
bold: (t: string) => `<b>${t}</b>`,
|
|
355
|
-
} satisfies FormTheme;
|
|
356
|
-
|
|
357
|
-
it("themePanelLines keeps │ muted on every content line, accents only inside", () => {
|
|
358
|
-
const m = derivePanelModel(
|
|
359
|
-
{
|
|
360
|
-
items: [{ id: "VC-001", text: "`VC-001` covers `I-001`", done: false }],
|
|
361
|
-
implItems: [{ id: "I-001", text: "work" }],
|
|
362
|
-
},
|
|
363
|
-
"gray-frame",
|
|
364
|
-
false,
|
|
365
|
-
);
|
|
366
|
-
const colored = themePanelLines(renderPanelLines(m, 80), m, theme);
|
|
367
|
-
// Lines 1..5 are │…│ content rows.
|
|
368
|
-
for (const line of colored.slice(1, 6)) {
|
|
369
|
-
assert.match(line, /^<muted>│<\/muted>/, `leading │ must be muted: ${line.slice(0, 30)}`);
|
|
370
|
-
assert.match(line, /<muted>│<\/muted>$/, "trailing │ must be muted");
|
|
371
|
-
const inner = line.replace(/^<muted>│<\/muted>/, "").replace(/<muted>│<\/muted>$/, "");
|
|
372
|
-
assert.ok(!inner.includes("│"), "no border glyph inside the colored span");
|
|
373
|
-
}
|
|
374
|
-
// Progress row (index 2) keeps its accent INSIDE the borders.
|
|
375
|
-
assert.match(colored[2]!, /<accent>\s*I items/);
|
|
376
|
-
// Next-action row (index 4) keeps success INSIDE the borders.
|
|
377
|
-
assert.match(colored[4]!, /<success>\s*Next:/);
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
it("panel header stays muted except the pi-plans brand", () => {
|
|
381
|
-
const m = derivePanelModel({ items: [], implItems: [] }, "t", false);
|
|
382
|
-
const [header] = themePanelLines(renderPanelLines(m, 80), m, theme);
|
|
383
|
-
assert.match(header, /<accent>pi-plans<\/accent>/);
|
|
384
|
-
assert.ok(!/<accent>[─╭╮]/.test(header), "frame chrome must not be accented");
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
it("form frame borders render muted and every themed span closes", () => {
|
|
388
|
-
const state = createFormState(qs(2));
|
|
389
|
-
const lines = formRender(state, 80, undefined, theme);
|
|
390
|
-
assert.match(lines[0]!, /^<muted>─+<\/muted>$/);
|
|
391
|
-
assert.match(lines.at(-1)!, /^<muted>─+<\/muted>$/);
|
|
392
|
-
// SGR hygiene: every opening marker has its closer on the same line, so
|
|
393
|
-
// the host dialog's │ cannot inherit a dangling line color.
|
|
394
|
-
const ansi: FormTheme = {
|
|
395
|
-
fg: (_c, t) => `\u001b[3m${t}\u001b[23m`,
|
|
396
|
-
bg: (_c, t) => `\u001b[7m${t}\u001b[27m`,
|
|
397
|
-
bold: (t) => `\u001b[1m${t}\u001b[22m`,
|
|
398
|
-
};
|
|
399
|
-
for (const line of formRender(createFormState(qs(2)), 80, undefined, ansi)) {
|
|
400
|
-
const opens = (line.match(/\u001b\[(3|7|1)m/g) ?? []).length;
|
|
401
|
-
const closes = (line.match(/\u001b\[(23|27|22)m/g) ?? []).length;
|
|
402
|
-
assert.equal(opens, closes, `unclosed SGR span: ${JSON.stringify(line)}`);
|
|
403
|
-
}
|
|
404
|
-
});
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
// ── VC-005: legacy-plan regression ──────────────────────────────────────────
|
|
408
|
-
|
|
409
|
-
describe("legacy plan regression (VC-005)", () => {
|
|
410
|
-
it("parses the shipped 0.4.0 and 0.5.0 plan artifacts", () => {
|
|
411
|
-
const root = path.join(process.cwd(), ".git", "pi_plans", "docs");
|
|
412
|
-
const v040 = path.join(root, "2026-09-17-v0-4-0-forms-and-exec-panel", "PLAN_v1.md");
|
|
413
|
-
const v050 = path.join(root, "2026-09-17-v0-5-0-code-graph-adoption", "PLAN_v2.md");
|
|
414
|
-
if (!fs.existsSync(v040) || !fs.existsSync(v050)) return; // artifacts stripped in CI
|
|
415
|
-
assert.equal(parseImplItems(fs.readFileSync(v040, "utf8")).length, 6);
|
|
416
|
-
const items = parseImplItems(fs.readFileSync(v050, "utf8"));
|
|
417
|
-
assert.equal(items.length, 9);
|
|
418
|
-
assert.equal(lintImplItems(fs.readFileSync(v050, "utf8")), null);
|
|
419
|
-
});
|
|
420
|
-
});
|