pi-plans 0.1.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.
@@ -0,0 +1,249 @@
1
+ /** Tests for the execution loop: marker tracking, session restore, completion. */
2
+
3
+ import * as assert from "node:assert/strict";
4
+ import * as fs from "node:fs";
5
+ import * as os from "node:os";
6
+ import * as path from "node:path";
7
+ import { after, before, describe, it } from "node:test";
8
+ import {
9
+ applyDoneMarkers,
10
+ completeExecution,
11
+ consumePendingPanelSync,
12
+ executionContextMessage,
13
+ getExecution,
14
+ isExecutionComplete,
15
+ restoreFromSession,
16
+ startExecution,
17
+ stopExecution,
18
+ toggleExecutionPanelView,
19
+ } from "../src/exec.ts";
20
+ import type { CheckItem } from "../src/plan.ts";
21
+
22
+ interface Recorded {
23
+ entries: { type: string; customType?: string; data?: unknown }[];
24
+ messages: { customType: string; content: string }[];
25
+ status: string | undefined;
26
+ widget?: { key: string; options?: unknown; factory: any };
27
+ widgetCalls: number;
28
+ }
29
+
30
+ interface Harness {
31
+ pi: any;
32
+ ctx: any;
33
+ recorded: Recorded;
34
+ }
35
+
36
+ function makeHarness(workdir: string): Harness {
37
+ const recorded: Recorded = { entries: [], messages: [], status: undefined, widgetCalls: 0 };
38
+ const pi = {
39
+ appendEntry: (customType: string, data: unknown) => {
40
+ recorded.entries.push({ type: "custom", customType, data });
41
+ },
42
+ sendMessage: (message: { customType: string; content: string }) => {
43
+ recorded.messages.push(message);
44
+ },
45
+ };
46
+ const ui = {
47
+ setStatus: (_key: string, value: string | undefined) => {
48
+ recorded.status = value;
49
+ },
50
+ setWidget: (key: string, factory: any, options?: unknown) => {
51
+ recorded.widgetCalls += 1;
52
+ if (factory === undefined) {
53
+ recorded.widget = undefined;
54
+ return;
55
+ }
56
+ recorded.widget = { key, options, factory };
57
+ },
58
+ theme: {
59
+ fg: (_color: string, text: string) => text,
60
+ strikethrough: (text: string) => `~~${text}~~`,
61
+ },
62
+ };
63
+ const ctx = {
64
+ cwd: workdir,
65
+ ui,
66
+ isIdle: () => true,
67
+ };
68
+ return { pi, ctx, recorded };
69
+ }
70
+
71
+ function items(...ids: string[]): CheckItem[] {
72
+ return ids.map((id) => ({ id, text: `\`${id}\` demo item`, done: false }));
73
+ }
74
+
75
+ describe("execution loop", () => {
76
+ let tmpRoot: string;
77
+ let counter = 0;
78
+
79
+ before(() => {
80
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-exec-"));
81
+ });
82
+
83
+ after(() => {
84
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
85
+ });
86
+
87
+ function freshWorkdir(): string {
88
+ counter += 1;
89
+ const workdir = path.join(tmpRoot, `repo-${counter}`);
90
+ fs.mkdirSync(workdir, { recursive: true });
91
+ return workdir;
92
+ }
93
+
94
+ it("tracks done markers and completes", () => {
95
+ const workdir = freshWorkdir();
96
+ const { pi, ctx, recorded } = makeHarness(workdir);
97
+ startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001", "VC-002"));
98
+ assert.ok(recorded.widget);
99
+ assert.equal(recorded.widget?.key, "pi-plans-execution");
100
+ assert.deepEqual(recorded.widget?.options, { placement: "belowEditor" });
101
+ const widget = recorded.widget?.factory(
102
+ {} as any,
103
+ { fg: (_color: string, text: string) => text, strikethrough: (text: string) => `~~${text}~~` },
104
+ );
105
+ assert.ok(widget);
106
+ const rendered = widget.render(80);
107
+ assert.match(rendered[0] ?? "", /alt\+o/);
108
+ assert.match(rendered[0] ?? "", /📋 plans 0\/2/);
109
+
110
+ toggleExecutionPanelView(pi, ctx);
111
+ assert.ok(recorded.widget);
112
+ const expandedWidget = recorded.widget?.factory(
113
+ {} as any,
114
+ { fg: (_color: string, text: string) => text, strikethrough: (text: string) => `~~${text}~~` },
115
+ );
116
+ assert.ok(expandedWidget);
117
+ const expandedLines = expandedWidget.render(80);
118
+ assert.match(expandedLines.join("\n"), /☐/);
119
+
120
+ assert.ok(getExecution());
121
+ assert.match(executionContextMessage()!, /PI-PLANS EXECUTION/);
122
+ assert.match(executionContextMessage()!, /VC-001/);
123
+
124
+ assert.deepEqual(applyDoneMarkers("progress… [DONE:VC-001] done"), ["VC-001"]);
125
+ assert.equal(isExecutionComplete(), false);
126
+ assert.deepEqual(applyDoneMarkers("final: [DONE:VC-002]"), ["VC-002"]);
127
+ assert.equal(isExecutionComplete(), true);
128
+
129
+ completeExecution(pi, ctx);
130
+ assert.equal(getExecution(), null);
131
+ assert.ok(recorded.messages.some((message) => message.customType === "pi-plans-complete"));
132
+ });
133
+
134
+ it("restores progress from session entries and rescans messages", () => {
135
+ const workdir = freshWorkdir();
136
+ const { pi, ctx, recorded } = makeHarness(workdir);
137
+ const snapshot = {
138
+ planPath: path.join(workdir, "PLAN_v1.md"),
139
+ items: items("VC-001", "VC-002"),
140
+ startedAt: "2026-08-25T00:00:00Z",
141
+ panel: {
142
+ expanded: true,
143
+ baseline: { added: 0, removed: 0, files: 0 },
144
+ lastSnapshot: { added: 1, removed: 0, files: 1 },
145
+ touchedPaths: ["src/exec.ts"],
146
+ itemSummaries: {
147
+ "VC-001": {
148
+ summary: { added: 1, removed: 0, files: 1, paths: ["src/exec.ts"] },
149
+ },
150
+ },
151
+ },
152
+ };
153
+ fs.writeFileSync(snapshot.planPath, "# plan");
154
+ const entries = [
155
+ { type: "custom", customType: "pi-plans-exec", data: snapshot },
156
+ {
157
+ type: "message",
158
+ message: { role: "assistant", content: [{ type: "text", text: "did [DONE:VC-001]" }] },
159
+ },
160
+ ];
161
+ restoreFromSession(pi, ctx, entries as any);
162
+ const execution = getExecution();
163
+ assert.ok(execution);
164
+ assert.ok(recorded.widget);
165
+ const widget = recorded.widget?.factory(
166
+ {} as any,
167
+ { fg: (_color: string, text: string) => text, strikethrough: (text: string) => `~~${text}~~` },
168
+ );
169
+ assert.ok(widget);
170
+ const rendered = widget.render(80);
171
+ assert.match(rendered.join("\n"), /☑/);
172
+ assert.match(rendered.join("\n"), /\+1/);
173
+
174
+ const clearedEntries = [...entries, { type: "custom", customType: "pi-plans-exec-cleared", data: {} }];
175
+ restoreFromSession(pi, ctx, clearedEntries as any);
176
+ assert.equal(getExecution(), null);
177
+ });
178
+
179
+ it("ignores restore when the plan file vanished", () => {
180
+ const workdir = freshWorkdir();
181
+ const { pi, ctx } = makeHarness(workdir);
182
+ const entries = [
183
+ {
184
+ type: "custom",
185
+ customType: "pi-plans-exec",
186
+ data: {
187
+ planPath: path.join(workdir, "missing-plan.md"),
188
+ items: items("VC-001"),
189
+ startedAt: "2026-08-25T00:00:00Z",
190
+ },
191
+ },
192
+ ];
193
+ restoreFromSession(pi, ctx, entries as any);
194
+ assert.equal(getExecution(), null);
195
+ });
196
+
197
+ it("stop clears execution", () => {
198
+ const workdir = freshWorkdir();
199
+ const { pi, ctx } = makeHarness(workdir);
200
+ startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
201
+ stopExecution(pi, ctx, "test");
202
+ assert.equal(getExecution(), null);
203
+ });
204
+
205
+ it("keeps the status bar count-free while executing and points at the panel", () => {
206
+ const workdir = freshWorkdir();
207
+ const { pi, ctx, recorded } = makeHarness(workdir);
208
+ startExecution(pi, ctx, path.join(workdir, "PLAN_v2.md"), items("VC-001", "VC-002"));
209
+
210
+ // The below-editor panel owns progress; the status item must be cleared,
211
+ // never carrying a duplicate “📋 plans x/y” count (regression: double display).
212
+ assert.equal(recorded.status, undefined);
213
+
214
+ const start = recorded.messages.find((message) => message.customType === "pi-plans-exec-start");
215
+ assert.ok(start);
216
+ assert.match(start.content, /Progress appears below the editor/);
217
+ assert.doesNotMatch(start.content, /footer/);
218
+
219
+ applyDoneMarkers("[DONE:VC-001]");
220
+ completeExecution(pi, ctx);
221
+ assert.equal(getExecution(), null);
222
+ });
223
+
224
+ it("defers persistence and widget churn when toggling mid-turn", () => {
225
+ const workdir = freshWorkdir();
226
+ const { pi, ctx, recorded } = makeHarness(workdir);
227
+ startExecution(pi, ctx, path.join(workdir, "PLAN_v3.md"), items("VC-001", "VC-002"));
228
+
229
+ const entriesBefore = recorded.entries.length;
230
+ const factoriesBefore = recorded.widgetCalls;
231
+
232
+ // Mid-turn: flip must be pure memory — no session writes, no re-register.
233
+ (ctx as any).isIdle = () => false;
234
+ assert.equal(toggleExecutionPanelView(pi, ctx), true);
235
+ assert.equal(recorded.entries.length, entriesBefore, "busy toggle appended a session entry");
236
+ assert.equal(recorded.widgetCalls, factoriesBefore, "busy toggle re-registered the widget");
237
+ assert.equal(consumePendingPanelSync(), true, "expected a pending panel sync marker");
238
+ assert.equal(consumePendingPanelSync(), false, "marker should be consumed exactly once");
239
+
240
+ // Back to idle: the next toggle persists and syncs, but still reuses the
241
+ // already-registered factory (cache-drop instead of teardown).
242
+ ctx.isIdle = () => true;
243
+ assert.equal(toggleExecutionPanelView(pi, ctx), false);
244
+ assert.ok(recorded.entries.length > entriesBefore, "idle toggle did not persist");
245
+ assert.equal(recorded.widgetCalls, factoriesBefore, "idle toggle replaced the widget factory");
246
+
247
+ stopExecution(pi, ctx, "test-done");
248
+ });
249
+ });
@@ -0,0 +1,198 @@
1
+ import * as assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import * as fs from "node:fs";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import { after, before, describe, it } from "node:test";
7
+ import {
8
+ captureRepoSnapshot,
9
+ completeCompletedItems,
10
+ createExecutionPanelState,
11
+ refreshExecutionPanel,
12
+ restorePanelState,
13
+ subtractSnapshots,
14
+ toggleExpanded,
15
+ truncateAnsi,
16
+ visibleWidth,
17
+ } from "../src/execution-panel.ts";
18
+
19
+ function initGitRepo(dir: string): void {
20
+ fs.mkdirSync(dir, { recursive: true });
21
+ const git = (args: string[]): void => {
22
+ const result = spawnSync("git", args, { cwd: dir, stdio: "ignore" });
23
+ if (result.status !== 0) {
24
+ throw new Error(`git ${args.join(" ")} failed`);
25
+ }
26
+ };
27
+ git(["init"]);
28
+ git(["config", "user.name", "Pi Plans Test"]);
29
+ git(["config", "user.email", "test@example.com"]);
30
+ fs.writeFileSync(path.join(dir, "tracked.txt"), "line 1\n");
31
+ git(["add", "tracked.txt"]);
32
+ git(["commit", "-m", "baseline"]);
33
+ }
34
+
35
+ describe("execution panel helpers", () => {
36
+ let tmpRoot: string;
37
+ let repo: string;
38
+
39
+ before(() => {
40
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-panel-"));
41
+ repo = path.join(tmpRoot, "repo");
42
+ initGitRepo(repo);
43
+ });
44
+
45
+ after(() => {
46
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
47
+ });
48
+
49
+ it("captures diff snapshots and restores panel state", () => {
50
+ const baseline = captureRepoSnapshot(repo);
51
+ fs.appendFileSync(path.join(repo, "tracked.txt"), "line 2\n");
52
+ fs.writeFileSync(path.join(repo, "new.txt"), "new file\n");
53
+ const current = captureRepoSnapshot(repo);
54
+ const delta = subtractSnapshots(baseline, current);
55
+ assert.ok(delta.added >= 1);
56
+ assert.ok(delta.files >= 1);
57
+
58
+ const panel = createExecutionPanelState();
59
+ panel.expanded = true;
60
+ panel.baseline = baseline;
61
+ panel.lastSnapshot = current;
62
+ panel.touchedPaths = ["tracked.txt", "new.txt"];
63
+ panel.itemSummaries = {
64
+ "VC-001": {
65
+ summary: {
66
+ added: 1,
67
+ removed: 0,
68
+ files: 2,
69
+ paths: ["tracked.txt", "new.txt"],
70
+ },
71
+ },
72
+ };
73
+ const restored = restorePanelState(panel);
74
+ assert.ok(restored);
75
+ assert.equal(restored?.expanded, true);
76
+ assert.equal(restored?.baseline?.added, baseline.added);
77
+ assert.equal(restored?.itemSummaries["VC-001"]?.summary?.files, 2);
78
+ assert.deepEqual(restored?.touchedPaths, ["tracked.txt", "new.txt"]);
79
+ });
80
+
81
+ it("records completed-item summaries with touched paths", () => {
82
+ fs.writeFileSync(path.join(repo, "tracked.txt"), "line 1\n");
83
+ fs.rmSync(path.join(repo, "new.txt"), { force: true });
84
+ const baseline = captureRepoSnapshot(repo);
85
+ fs.appendFileSync(path.join(repo, "tracked.txt"), "line 2\n");
86
+ fs.writeFileSync(path.join(repo, "new.txt"), "new file\n");
87
+
88
+ const execution = {
89
+ planPath: path.join(repo, "PLAN_v1.md"),
90
+ items: [{ id: "VC-001", text: "`VC-001` demo item", done: true }],
91
+ panel: createExecutionPanelState(),
92
+ };
93
+ execution.panel.baseline = baseline;
94
+ execution.panel.lastSnapshot = baseline;
95
+ execution.panel.touchedPaths = ["tracked.txt", "new.txt"];
96
+
97
+ const summary = completeCompletedItems(execution as any, repo, ["VC-001"]);
98
+ assert.ok(summary);
99
+ assert.ok((summary?.added ?? 0) >= 1);
100
+ assert.ok((summary?.files ?? 0) >= 1);
101
+ assert.deepEqual(execution.panel.itemSummaries["VC-001"]?.summary?.paths, ["tracked.txt", "new.txt"]);
102
+ });
103
+
104
+ it("toggles expanded state", () => {
105
+ const execution = { planPath: path.join(repo, "PLAN_v1.md"), items: [] as any[], panel: createExecutionPanelState() };
106
+ assert.equal(toggleExpanded(execution), true);
107
+ assert.equal(toggleExpanded(execution), false);
108
+ });
109
+
110
+ it("reuses one registered widget factory across refreshes", () => {
111
+ const execution: any = {
112
+ planPath: "/tmp/PLAN_v1.md",
113
+ items: [{ id: "VC-001", text: "item", done: false }],
114
+ panel: createExecutionPanelState(),
115
+ };
116
+ const factories: unknown[] = [];
117
+ const theme = { fg: (_color: string, text: string) => `\u001b[38;5;2m${text}\u001b[39m`, strikethrough: (text: string) => text };
118
+ const ctx = { ui: { setWidget: (_key: string, factory: any) => void factories.push(factory) } } as any;
119
+
120
+ refreshExecutionPanel(ctx, execution);
121
+ refreshExecutionPanel(ctx, execution); // second pass must only invalidate
122
+ assert.equal(factories.length, 1, "factory re-registered on refresh");
123
+
124
+ const widget = (factories[0] as any)({}, theme);
125
+ assert.equal(widget.render(80).length, 1, "collapsed render should be a single line");
126
+
127
+ toggleExpanded(execution);
128
+ refreshExecutionPanel(ctx, execution);
129
+ assert.equal(factories.length, 1);
130
+ assert.ok(widget.render(80).length > 1, "invalidate did not pick up expanded state");
131
+
132
+ // Clearing releases the slot so a future run registers afresh.
133
+ refreshExecutionPanel(ctx, null);
134
+ refreshExecutionPanel(ctx, execution);
135
+ assert.equal(factories.length, 3); // #2 was the explicit clear (undefined)
136
+ });
137
+ });
138
+
139
+ describe("execution panel width safety", () => {
140
+ const styledTheme = {
141
+ fg: (_color: string, text: string) => `\u001b[38;5;2m${text}\u001b[39m`,
142
+ strikethrough: (text: string) => text,
143
+ };
144
+
145
+ it("measures CJK code points as double width and zero-width runs as none", () => {
146
+ assert.equal(visibleWidth("新的"), 4);
147
+ assert.equal(visibleWidth("菜单配置"), 8);
148
+ assert.equal(visibleWidth("\uFF46\uFF55\uFF4C\uFF4C"), 8); // fullwidth “full”
149
+ assert.equal(visibleWidth("e\u0301"), 1); // combining acute
150
+ assert.equal(visibleWidth("\u2764\uFE0F"), 1); // variation selector
151
+ assert.equal(visibleWidth("a\u001b[31mbc\u001b[0m"), 3); // ANSI ignored
152
+ });
153
+
154
+ it("truncates styled CJK lines within the requested width", () => {
155
+ const cjk = "新的 Nemotron AIME 配置和批量入口能验证 seed vector、model pin、arm set 和 500 budget;" +
156
+ "evidence: pr… 后续还有很长的中文描述需要被安全截断。".repeat(6);
157
+ for (const width of [20, 40, 138]) {
158
+ const line = truncateAnsi(styledTheme.fg("accent", cjk), width);
159
+ assert.ok(
160
+ visibleWidth(line) <= width,
161
+ `width ${width}: rendered ${visibleWidth(line)} columns`,
162
+ );
163
+ }
164
+ });
165
+
166
+ it("renders every expanded checklist line inside the terminal width", () => {
167
+ const execution: any = {
168
+ planPath: "/tmp/PLAN_v1.md",
169
+ items: [
170
+ {
171
+ id: "VC-001",
172
+ // Mirrors the crash-log line that rendered at 155 columns in a 138-column terminal.
173
+ text: "`VC-001` covers `I-001` 和 `I-003`;pass condition: 新的 Nemotron AIME 配置和批量入口能验证 seed vector、model pin、arm set 和 500 budget;evidence: pr…",
174
+ done: false,
175
+ },
176
+ { id: "VC-002", text: "`VC-002` plain ascii item that is also far too long to fit one line ".repeat(4), done: false },
177
+ ],
178
+ panel: createExecutionPanelState(),
179
+ };
180
+ toggleExpanded(execution);
181
+
182
+ let factory: ((tui: unknown, theme: unknown) => any) | undefined;
183
+ refreshExecutionPanel({ ui: { setWidget: (_key: string, f: any) => void (factory = f) } } as any, execution);
184
+ const widget = factory!({}, styledTheme);
185
+
186
+ let sawEllipsis = false;
187
+ for (const width of [20, 40, 80, 138]) {
188
+ for (const line of widget.render(width)) {
189
+ if (line.endsWith("…") || line.includes("…\u001b[0m")) sawEllipsis = true;
190
+ assert.ok(
191
+ visibleWidth(line) <= width,
192
+ `width ${width}: rendered ${visibleWidth(line)} columns: ${JSON.stringify(line.slice(0, 60))}`,
193
+ );
194
+ }
195
+ }
196
+ assert.ok(sawEllipsis, "expected at least one truncated line with an ellipsis");
197
+ });
198
+ });
@@ -0,0 +1,70 @@
1
+ /** Tests for the planning write guard. */
2
+
3
+ import * as assert from "node:assert/strict";
4
+ import * as fs from "node:fs";
5
+ import * as os from "node:os";
6
+ import * as path from "node:path";
7
+ import { after, before, describe, it } from "node:test";
8
+ import { planningWriteBlockReason } from "../src/guard.ts";
9
+ import { initState, setRunStatus, startRun } from "../src/state.ts";
10
+
11
+ let tmpRoot: string;
12
+
13
+ before(() => {
14
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-guard-"));
15
+ });
16
+
17
+ after(() => {
18
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
19
+ });
20
+
21
+ describe("planning write guard", () => {
22
+ it("blocks source writes during planning, allows artifacts, lifts after handoff", () => {
23
+ const workdir = path.join(tmpRoot, "repo");
24
+ fs.mkdirSync(workdir);
25
+ initState(workdir);
26
+ const { run } = startRun(workdir, {
27
+ topic: "guard check",
28
+ skill: "plan-small",
29
+ requestText: "x",
30
+ });
31
+
32
+ const block = planningWriteBlockReason({ workdir, toolName: "write", rawPath: "src/main.ts" });
33
+ assert.ok(block);
34
+ assert.ok(block.includes("read-only outside planning artifacts"));
35
+
36
+ // Artifact writes are allowed (absolute artifact_dir and relative path into it).
37
+ assert.equal(
38
+ planningWriteBlockReason({ workdir, toolName: "write", rawPath: path.join(run.artifact_dir, "PLAN_v1.md") }),
39
+ null,
40
+ );
41
+ const relArtifact = path.join(path.relative(workdir, run.artifact_dir), "DECISIONS.md");
42
+ assert.equal(
43
+ planningWriteBlockReason({ workdir, toolName: "edit", rawPath: relArtifact }),
44
+ null,
45
+ );
46
+ // Another run's artifact directory is still blocked (per-run boundary).
47
+ assert.ok(planningWriteBlockReason({ workdir, toolName: "write", rawPath: "./docs/pi-plans/other-run/PLAN_v1.md" }));
48
+
49
+ // State dir writes are allowed; @-prefixed paths are normalized.
50
+ assert.equal(
51
+ planningWriteBlockReason({ workdir, toolName: "write", rawPath: ".git/pi_plans/tmp/note" }),
52
+ null,
53
+ );
54
+ assert.ok(planningWriteBlockReason({ workdir, toolName: "write", rawPath: "@/src/main.ts" }));
55
+
56
+ // Read tools are never guarded.
57
+ assert.equal(planningWriteBlockReason({ workdir, toolName: "read", rawPath: "src/main.ts" }), null);
58
+
59
+ // Once the run leaves planning/accepted, the guard lifts.
60
+ setRunStatus(workdir, run.run_id, "executing");
61
+ assert.equal(planningWriteBlockReason({ workdir, toolName: "write", rawPath: "src/main.ts" }), null);
62
+ });
63
+
64
+ it("is inactive without an active run", () => {
65
+ const workdir = path.join(tmpRoot, "no-run");
66
+ fs.mkdirSync(workdir);
67
+ initState(workdir);
68
+ assert.equal(planningWriteBlockReason({ workdir, toolName: "write", rawPath: "src/main.ts" }), null);
69
+ });
70
+ });
@@ -0,0 +1,105 @@
1
+ /** Tests for plan artifact parsing (verifier checklist + done markers). */
2
+
3
+ import * as assert from "node:assert/strict";
4
+ import * as fs from "node:fs";
5
+ import * as os from "node:os";
6
+ import * as path from "node:path";
7
+ import { after, before, describe, it } from "node:test";
8
+ import { latestPlanVersion, nextPlanVersionPath, parseChecklist, scanDoneMarkers } from "../src/plan.ts";
9
+
10
+ const PLAN = `# PLAN_v1 - demo
11
+
12
+ ## Goals
13
+
14
+ - \`G-001\`: Ship it.
15
+
16
+ ## Verifier Checklist
17
+
18
+ - [ ] \`VC-001\` covers \`I-001\`; pass condition: tests green; evidence: pytest output; metric: 100% pass.
19
+ - [x] \`VC-002\` covers \`I-002\`; pass condition: no lint errors; evidence: run ruff; metric: zero findings.
20
+ - not a checklist line
21
+ - [ ] \`VC-003\` covers \`I-003\`; pass condition: manual check; metric: not quantified.
22
+
23
+ ## Risks And Mitigations
24
+
25
+ - \`Risk-001\`: something.
26
+ `;
27
+
28
+ describe("plan parsing", () => {
29
+ it("parses checklist items with ids and done state", () => {
30
+ const items = parseChecklist(PLAN);
31
+ assert.equal(items.length, 3);
32
+ assert.equal(items[0]?.id, "VC-001");
33
+ assert.equal(items[0]?.done, false);
34
+ assert.equal(items[1]?.id, "VC-002");
35
+ assert.equal(items[1]?.done, true);
36
+ assert.equal(items[2]?.id, "VC-003");
37
+ });
38
+
39
+ it("returns empty without a checklist section", () => {
40
+ assert.equal(parseChecklist("# no checklist here\n\n- [ ] `VC-001` orphan\n").length, 0);
41
+ });
42
+
43
+ it("scans done markers", () => {
44
+ assert.deepEqual(scanDoneMarkers("done [DONE:VC-001] and [DONE:VC-003], plus [DONE:VC-001] again"), [
45
+ "VC-001",
46
+ "VC-003",
47
+ "VC-001",
48
+ ]);
49
+ assert.deepEqual(scanDoneMarkers("nothing here"), []);
50
+ });
51
+ });
52
+
53
+ describe("latestPlanVersion", () => {
54
+ let dir: string;
55
+
56
+ before(() => {
57
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-plan-"));
58
+ fs.writeFileSync(path.join(dir, "PLAN_v1.md"), "v1");
59
+ fs.writeFileSync(path.join(dir, "PLAN_v10.md"), "v10");
60
+ fs.writeFileSync(path.join(dir, "PLAN_v2.md"), "v2");
61
+ fs.writeFileSync(path.join(dir, "notes.md"), "notes");
62
+ });
63
+
64
+ after(() => {
65
+ fs.rmSync(dir, { recursive: true, force: true });
66
+ });
67
+
68
+ it("picks the highest version numerically", () => {
69
+ const latest = latestPlanVersion(dir);
70
+ assert.equal(latest?.version, 10);
71
+ assert.equal(latest?.path, path.join(dir, "PLAN_v10.md"));
72
+ });
73
+
74
+ it("returns null for missing dirs", () => {
75
+ assert.equal(latestPlanVersion(path.join(dir, "nope")), null);
76
+ });
77
+ });
78
+
79
+ describe("nextPlanVersionPath", () => {
80
+ let dir: string;
81
+
82
+ before(() => {
83
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-next-"));
84
+ });
85
+
86
+ after(() => {
87
+ fs.rmSync(dir, { recursive: true, force: true });
88
+ });
89
+
90
+ it("increments from the highest existing version", () => {
91
+ fs.writeFileSync(path.join(dir, "PLAN_v1.md"), "v1");
92
+ fs.writeFileSync(path.join(dir, "PLAN_v4.md"), "v4");
93
+ const next = nextPlanVersionPath(dir);
94
+ assert.equal(next.version, 5);
95
+ assert.equal(next.path, path.join(dir, "PLAN_v5.md"));
96
+ });
97
+
98
+ it("starts at v1 for empty dirs", () => {
99
+ const empty = path.join(dir, "empty");
100
+ fs.mkdirSync(empty, { recursive: true });
101
+ const next = nextPlanVersionPath(empty);
102
+ assert.equal(next.version, 1);
103
+ assert.equal(next.path, path.join(empty, "PLAN_v1.md"));
104
+ });
105
+ });
@@ -0,0 +1,49 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { buildCriticizerTask, buildReviewerTask, reviewerLanes } from "../src/refine-prompts.ts";
4
+
5
+ describe("reviewerLanes", () => {
6
+ it("uses stable lane ids for the big-plan fanout", () => {
7
+ assert.deepEqual(reviewerLanes(3).map((lane) => lane.id), ["correctness", "ordering", "verification"]);
8
+ });
9
+
10
+ it("falls back to a general lane for one-off review passes", () => {
11
+ assert.deepEqual(reviewerLanes(1), [{ id: "general", lens: null }]);
12
+ });
13
+ });
14
+
15
+ describe("buildReviewerTask", () => {
16
+ it("includes a compact read-only contract and lane emphasis", () => {
17
+ const text = buildReviewerTask({
18
+ planText: "# plan",
19
+ planPath: "/tmp/PLAN_v1.md",
20
+ lens: "verification rigor",
21
+ focus: "check the checklist",
22
+ context: "repo evidence",
23
+ });
24
+
25
+ assert.match(text, /Goal: review the plan against the repository\./);
26
+ assert.match(text, /Target: \/tmp\/PLAN_v1\.md/);
27
+ assert.match(text, /Authority boundary: read-only analysis only\./);
28
+ assert.match(text, /Review lens: verification rigor\./);
29
+ assert.match(text, /Specific concerns from the main agent: check the checklist/);
30
+ assert.match(text, /Context: repo evidence/);
31
+ assert.match(text, /Surface at most five high-priority findings/);
32
+ });
33
+ });
34
+
35
+ describe("buildCriticizerTask", () => {
36
+ it("asks for short adversarial questions only", () => {
37
+ const text = buildCriticizerTask({
38
+ planText: "# plan",
39
+ planPath: "/tmp/PLAN_v1.md",
40
+ focus: "challenge the deployment step",
41
+ });
42
+
43
+ assert.match(text, /Goal: stress-test the plan's assumptions\./);
44
+ assert.match(text, /Authority boundary: read-only analysis only\./);
45
+ assert.match(text, /Specific concerns from the main agent: challenge the deployment step/);
46
+ assert.match(text, /at most five adaptive questions/);
47
+ assert.match(text, /never rewrite the plan/);
48
+ });
49
+ });