pi-plans 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/AGENTS.md +58 -0
  2. package/CONTRIBUTING.md +8 -15
  3. package/README.md +39 -37
  4. package/agents/execution-reviewer.md +40 -0
  5. package/agents/reviewer.md +12 -3
  6. package/index.ts +55 -58
  7. package/package.json +2 -1
  8. package/references/pi-planning-workflow.md +50 -60
  9. package/references/plan-artifact-template.md +81 -60
  10. package/references/state-and-config.md +60 -44
  11. package/scripts/bench/pi-adapter/pi_plans_bench.py +33 -22
  12. package/scripts/bench/pi-adapter/rpc_driver.mjs +4 -4
  13. package/scripts/run-tests.ts +12 -1
  14. package/scripts/validate.ts +39 -11
  15. package/skills/debug-and-plan/SKILL.md +3 -3
  16. package/skills/plan-big/SKILL.md +3 -3
  17. package/skills/plan-normal/SKILL.md +3 -3
  18. package/skills/plan-small/SKILL.md +4 -4
  19. package/skills/plan-with-refs/SKILL.md +6 -6
  20. package/skills/planning/SKILL.md +1 -1
  21. package/src/ask-form.ts +4 -4
  22. package/src/auditor.ts +227 -0
  23. package/src/auto-approve.ts +1 -1
  24. package/src/autocomplete.ts +19 -17
  25. package/src/code-graph/commands.ts +8 -3
  26. package/src/code-graph/community.ts +1 -1
  27. package/src/code-graph/paths.ts +1 -1
  28. package/src/code-graph/watch.ts +2 -2
  29. package/src/compaction.ts +3 -3
  30. package/src/config-command.ts +146 -73
  31. package/src/dashboard.ts +303 -0
  32. package/src/exec.ts +1185 -924
  33. package/src/global-state.ts +304 -0
  34. package/src/guard.ts +18 -19
  35. package/src/messaging.ts +44 -0
  36. package/src/plan.ts +421 -112
  37. package/src/query-hook.ts +4 -4
  38. package/src/refine-prompts.ts +12 -70
  39. package/src/refine-ui-helpers.ts +24 -5
  40. package/src/refine-ui-state.ts +1 -1
  41. package/src/refine-ui.ts +19 -3
  42. package/src/resume-command.ts +45 -129
  43. package/src/resume.ts +5 -1
  44. package/src/role-panels.ts +542 -0
  45. package/src/run-context.ts +3 -10
  46. package/src/staleness.ts +53 -0
  47. package/src/state.ts +273 -72
  48. package/src/subagent.ts +19 -29
  49. package/src/task-tool.ts +100 -0
  50. package/src/tasks.ts +223 -0
  51. package/src/thinking-levels.ts +67 -0
  52. package/src/ui-language.ts +7 -54
  53. package/src/workflow-state.ts +76 -58
  54. package/tests/analyze-refs.test.ts +35 -18
  55. package/tests/ask-choice-schema.test.ts +0 -12
  56. package/tests/ask-choice.test.ts +2 -49
  57. package/tests/ask-form-tool.test.ts +4 -5
  58. package/tests/ask-form.test.ts +2 -2
  59. package/tests/auditor.test.ts +210 -0
  60. package/tests/auto-approve.test.ts +7 -10
  61. package/tests/autocomplete.test.ts +8 -11
  62. package/tests/code-graph-apply-action.test.ts +2 -2
  63. package/tests/code-graph-commands.test.ts +2 -2
  64. package/tests/code-graph-index.test.ts +2 -2
  65. package/tests/code-graph-loop.e2e.test.ts +1 -1
  66. package/tests/code-graph-mutations.test.ts +1 -1
  67. package/tests/code-graph-rollback.test.ts +1 -1
  68. package/tests/code-graph-v05.test.ts +2 -2
  69. package/tests/compaction.test.ts +1 -1
  70. package/tests/config-command.test.ts +103 -100
  71. package/tests/dashboard.test.ts +402 -0
  72. package/tests/exec-lifecycle.test.ts +181 -115
  73. package/tests/exec-panel-lifecycle.test.ts +106 -251
  74. package/tests/exec-review-loop.test.ts +331 -0
  75. package/tests/exec.test.ts +771 -1706
  76. package/tests/execute-plan.test.ts +44 -19
  77. package/tests/extension-load.test.ts +48 -0
  78. package/tests/global-state.test.ts +371 -0
  79. package/tests/graph-aware-file-tools.test.ts +5 -5
  80. package/tests/guard.test.ts +1 -1
  81. package/tests/multi-run.test.ts +3 -103
  82. package/tests/plan.test.ts +139 -62
  83. package/tests/plans.test.ts +7 -79
  84. package/tests/refine-prompts.test.ts +20 -71
  85. package/tests/refine-resume.test.ts +27 -22
  86. package/tests/refine-ui.test.ts +6 -15
  87. package/tests/resume-lifecycle.test.ts +41 -22
  88. package/tests/resume.test.ts +39 -81
  89. package/tests/role-panels.test.ts +391 -0
  90. package/tests/run-context.test.ts +1 -1
  91. package/tests/run-ownership.test.ts +1 -1
  92. package/tests/stale-ctx.test.ts +218 -0
  93. package/tests/staleness.test.ts +76 -0
  94. package/tests/state.test.ts +155 -32
  95. package/tests/subagent-thinking.test.ts +65 -0
  96. package/tests/subagent-usage.test.ts +1 -1
  97. package/tests/task-tool.test.ts +61 -0
  98. package/tests/tasks.test.ts +142 -0
  99. package/tests/thinking-levels.test.ts +77 -0
  100. package/tests/ui-language.test.ts +2 -17
  101. package/tests/workflow-state.test.ts +73 -90
  102. package/tools/analyze-refs.ts +67 -32
  103. package/tools/ask-choice.ts +7 -53
  104. package/tools/code-graph.ts +2 -2
  105. package/tools/execute-plan.ts +55 -99
  106. package/tools/graph-aware-file-tools.ts +4 -10
  107. package/tools/plans.ts +41 -67
  108. package/tools/refine.ts +101 -164
  109. package/agents/criticizer.md +0 -18
  110. package/agents/executor.md +0 -26
  111. package/scripts/bench/pi-adapter/__pycache__/pi_plans_bench.cpython-312.pyc +0 -0
  112. package/src/panel.ts +0 -473
  113. package/src/termination-prompt.ts +0 -73
  114. package/tests/goal-wait.test.ts +0 -269
  115. package/tests/panel-i-zero.test.ts +0 -420
  116. package/tests/panel.test.ts +0 -355
@@ -0,0 +1,331 @@
1
+ /**
2
+ * Execution-review loop tests (v0.8): detached rounds, abort/identity
3
+ * lifecycle, the fingerprint guard, budget accounting (commit-only), round
4
+ * report persistence, the cap pause, and resume self-heal.
5
+ */
6
+
7
+ import * as assert from "node:assert/strict";
8
+ import * as fs from "node:fs";
9
+ import * as os from "node:os";
10
+ import * as path from "node:path";
11
+ import { after, before, describe, it } from "node:test";
12
+ import {
13
+ __awaitReviewRoundForTests,
14
+ __setAuditRunnerForTests,
15
+ getExecution,
16
+ persistTaskProgress,
17
+ restoreFromSession,
18
+ startExecution,
19
+ stopExecution,
20
+ } from "../src/exec.ts";
21
+ import { setMessagingApi } from "../src/messaging.ts";
22
+ import { applyTaskUpdate } from "../src/task-tool.ts";
23
+ import { REVIEW_MAX_ROUNDS } from "../src/auditor.ts";
24
+ import { getRun, initState, startRun } from "../src/state.ts";
25
+ import { createCheckpoint, loadCheckpoint, mutateCheckpoint, applyExecutionApproved, applyExecutionProgress, applyPlanWritten, planIdentityOf } from "../src/workflow-state.ts";
26
+
27
+ const PLAN = `# PLAN_v1 - review-loop fixture
28
+
29
+ ## Tasks
30
+
31
+ - Task-1: engine — files: lib/engine.js; wave: 1
32
+ - Task-2: report — files: lib/report.js; wave: 1
33
+
34
+ ## Verification Checks
35
+
36
+ - [ ] \`VC-001\` covers \`Task-1\`; pass condition: engine works; evidence: tests; metric: green.
37
+ - [ ] \`VC-002\` covers \`Task-2\`; pass condition: report written; evidence: file; metric: green.
38
+ `;
39
+
40
+ let root = "";
41
+ let counter = 0;
42
+
43
+ before(() => {
44
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-review-loop-"));
45
+ });
46
+
47
+ after(() => {
48
+ fs.rmSync(root, { recursive: true, force: true });
49
+ __setAuditRunnerForTests(null);
50
+ });
51
+
52
+ function freshWorkdir(): { workdir: string; planPath: string; runId: string } {
53
+ counter += 1;
54
+ const workdir = path.join(root, `repo-${counter}`);
55
+ fs.mkdirSync(workdir, { recursive: true });
56
+ fs.mkdirSync(path.join(workdir, "lib"), { recursive: true });
57
+ // Real covered files so the round fingerprint tracks their mtimes.
58
+ fs.writeFileSync(path.join(workdir, "lib", "engine.js"), "export const engine = 1;\n", "utf8");
59
+ fs.writeFileSync(path.join(workdir, "lib", "report.js"), "export const report = 1;\n", "utf8");
60
+ initState(workdir);
61
+ const { run } = startRun(workdir, { topic: `r${counter}`, skill: "plan-small", requestText: "demo" });
62
+ createCheckpoint(workdir, { runId: run.run_id, originWorkdir: workdir, workdir });
63
+ const planPath = path.join(run.artifact_dir, "PLAN_v1.md");
64
+ fs.mkdirSync(run.artifact_dir, { recursive: true });
65
+ fs.writeFileSync(planPath, PLAN, "utf8");
66
+ mutateCheckpoint(workdir, run.run_id, (cp) =>
67
+ applyExecutionApproved(
68
+ applyPlanWritten({ ...cp, nextAction: "accept-execute" }, planIdentityOf(planPath, 1)),
69
+ { plan: planIdentityOf(planPath, 1), worktree: workdir, headAtApproval: null, approvedAt: cp.updatedAt },
70
+ ),
71
+ );
72
+ return { workdir, planPath, runId: run.run_id };
73
+ }
74
+
75
+ function makeCtx(workdir: string, mode: "print" | "tui" = "print", customOpens?: { count: number }) {
76
+ const entries: Array<{ customType: string; data?: unknown; content?: string }> = [];
77
+ const ctx = {
78
+ cwd: workdir,
79
+ sessionManager: {},
80
+ hasUI: true,
81
+ mode,
82
+ entries,
83
+ ui: {
84
+ notify: () => {},
85
+ setStatus: () => {},
86
+ setWidget: () => {},
87
+ theme: { fg: (_c: string, t: string) => t, bold: (t: string) => t },
88
+ // Minimal overlay host: counts ui.custom opens (one per controller).
89
+ custom: () => {
90
+ if (customOpens) customOpens.count += 1;
91
+ return Promise.resolve();
92
+ },
93
+ },
94
+ isIdle: () => true,
95
+ hasPendingMessages: () => false,
96
+ } as never;
97
+ setMessagingApi({
98
+ appendEntry: (customType: string, data: unknown) => entries.push({ customType, data }),
99
+ sendMessage: (message: { customType: string; content: string }) => entries.push({ customType: message.customType, content: message.content }),
100
+ } as never);
101
+ return ctx;
102
+ }
103
+
104
+ async function startTerminal(planPath: string, workdir: string) {
105
+ const ctx = makeCtx(workdir);
106
+ await startExecution(ctx, { planPath, planTasks: (await import("../src/plan.ts")).parsePlanTasks(PLAN), items: (await import("../src/plan.ts")).parseChecklist(PLAN) });
107
+ for (const id of ["Task-1", "Task-2"]) {
108
+ applyTaskUpdate(getExecution()!.tasks, id, "complete", `${id} evidence`);
109
+ persistTaskProgress(ctx);
110
+ }
111
+ return ctx;
112
+ }
113
+
114
+ /** A runner whose rounds the test resolves by hand. */
115
+ function controlledRunner() {
116
+ const pending: Array<(value: { round: number; passed: string[]; failed: string[]; undeterminable: string[]; report: string } | null) => void> = [];
117
+ let calls = 0;
118
+ const runner = async () => {
119
+ calls += 1;
120
+ return new Promise<{ round: number; passed: string[]; failed: string[]; undeterminable: string[]; report: string } | null>((resolve) => pending.push(resolve));
121
+ };
122
+ const resolveRound = (value: Parameters<typeof pending[0]>[0]) => {
123
+ const resolve = pending.shift();
124
+ if (resolve) resolve(value);
125
+ };
126
+ /** Resolve every still-pending round (teardown: the identity guard discards them). */
127
+ const drainAll = (value: Parameters<typeof pending[0]>[0] = null) => {
128
+ while (pending.length > 0) pending.shift()!(value);
129
+ };
130
+ return { runner, resolveRound, drainAll, calls: () => calls };
131
+ }
132
+
133
+ /** Let the engine chain advance one step without awaiting its completion. */
134
+ const tick = async (): Promise<void> => {
135
+ await new Promise<void>((resolve) => setImmediate(resolve));
136
+ await new Promise<void>((resolve) => setImmediate(resolve));
137
+ };
138
+
139
+ describe("execution-review loop (v0.8)", () => {
140
+ it("tui/rpc detach: the settle returns before the round resolves, status becomes verifying", async () => {
141
+ const { workdir, planPath, runId } = freshWorkdir();
142
+ await startTerminal(planPath, workdir);
143
+ const ctl = controlledRunner();
144
+ __setAuditRunnerForTests(ctl.runner);
145
+ const ctxTui = makeCtx(workdir, "tui");
146
+ await restoreFromSession(ctxTui, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
147
+ // The restore returned while the round is STILL running: in-flight marker
148
+ // present, run status moved to verifying, no outcome committed yet.
149
+ assert.ok(getExecution()!.review.inFlight, "the round is in flight after the settle returned");
150
+ assert.equal(getExecution()!.audit.rounds, 0, "no budget spent yet");
151
+ assert.equal(getRun(workdir, runId)?.status, "verifying", "run status enters verifying");
152
+ ctl.resolveRound({ round: 1, passed: ["VC-001", "VC-002"], failed: [], undeterminable: [], report: "all pass" });
153
+ await __awaitReviewRoundForTests();
154
+ assert.equal(loadCheckpoint(workdir, runId).checkpoint.phase, "completed", "the round completes the run after resolution");
155
+ __setAuditRunnerForTests(null);
156
+ });
157
+
158
+ it("print/json keep the inline await: the settle returns only after the round commits", async () => {
159
+ const { workdir, planPath, runId } = freshWorkdir();
160
+ await startTerminal(planPath, workdir);
161
+ const ctl = controlledRunner();
162
+ __setAuditRunnerForTests(ctl.runner);
163
+ const ctxPrint = makeCtx(workdir, "print");
164
+ const restoring = restoreFromSession(ctxPrint, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
165
+ await tick(); // the inline round spawns
166
+ ctl.resolveRound({ round: 1, passed: ["VC-001", "VC-002"], failed: [], undeterminable: [], report: "all pass" });
167
+ await restoring;
168
+ // Inline: by the time restoreFromSession returned, the round committed —
169
+ // and a passing round completes (clearing execution state).
170
+ assert.equal(getExecution(), null, "the inline round committed before the settle returned");
171
+ assert.equal(loadCheckpoint(workdir, runId).checkpoint.phase, "completed");
172
+ assert.equal(ctl.calls(), 1);
173
+ __setAuditRunnerForTests(null);
174
+ });
175
+
176
+ it("a cancelled round burns no budget, sends no wake, and pauses nothing", async () => {
177
+ const { workdir, planPath } = freshWorkdir();
178
+ const ctx = await startTerminal(planPath, workdir);
179
+ __setAuditRunnerForTests(async () => ({ cancelled: true }) as never);
180
+ await restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
181
+ const ex = getExecution()!;
182
+ assert.equal(ex.audit.rounds, 0, "cancelled rounds burn no budget");
183
+ assert.equal(ex.stall.paused, false, "cancelled rounds do not pause");
184
+ assert.equal(ctx.entries.filter((e) => String(e.customType).startsWith("pi-plans-audit-")).length, 0, "no wake, no report message");
185
+ await stopExecution(ctx, "teardown");
186
+ });
187
+
188
+ it("fingerprint change discards the attempt, re-runs without budget burn, and both report files survive", async () => {
189
+ const { workdir, planPath, runId } = freshWorkdir();
190
+ const ctx = await startTerminal(planPath, workdir);
191
+ const ctl = controlledRunner();
192
+ __setAuditRunnerForTests(ctl.runner);
193
+ const restoring = restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
194
+ await tick(); // the inline round spawns
195
+ // Mutate a covered file while the round is in flight (real mutation path).
196
+ const engine = path.join(workdir, "lib", "engine.js");
197
+ fs.writeFileSync(engine, "export const engine = 2;\n", "utf8");
198
+ const later = new Date(Date.now() + 10_000);
199
+ fs.utimesSync(engine, later, later);
200
+ ctl.resolveRound({ round: 1, passed: ["VC-001", "VC-002"], failed: [], undeterminable: [], report: "stale verdict" });
201
+ await tick();
202
+ // Attempt 1 discarded (report on disk, marked), attempt 2 committed the pass.
203
+ ctl.resolveRound({ round: 1, passed: ["VC-001", "VC-002"], failed: [], undeterminable: [], report: "fresh verdict" });
204
+ await restoring;
205
+ await __awaitReviewRoundForTests();
206
+ // Attempt 1 discarded (report on disk, marked), attempt 2 committed the pass.
207
+ const ex = getExecution();
208
+ assert.equal(loadCheckpoint(workdir, runId).checkpoint.phase, "completed");
209
+ const runDir = path.join(workdir, ".git", "pi-plans", "runs", runId);
210
+ const attempt1 = path.join(runDir, "execution-review", "round-1-attempt-1.md");
211
+ const attempt2 = path.join(runDir, "execution-review", "round-1-attempt-2.md");
212
+ assert.ok(fs.existsSync(attempt1), "the discarded attempt's report survives");
213
+ assert.match(fs.readFileSync(attempt1, "utf8"), /outcome: discarded/);
214
+ assert.match(fs.readFileSync(attempt1, "utf8"), /discarded: fingerprint changed/);
215
+ assert.ok(fs.existsSync(attempt2), "the re-run's report exists beside it");
216
+ assert.match(fs.readFileSync(attempt2, "utf8"), /outcome: passed/);
217
+ assert.equal(ctl.calls(), 2, "one discard plus one re-run");
218
+ assert.ok(!ex, "execution completed");
219
+ __setAuditRunnerForTests(null);
220
+ });
221
+
222
+ it("two consecutive discards commit as a budget-counting undeterminable round", async () => {
223
+ const { workdir, planPath } = freshWorkdir();
224
+ const ctx = await startTerminal(planPath, workdir);
225
+ const ctl = controlledRunner();
226
+ __setAuditRunnerForTests(ctl.runner);
227
+ const engine = path.join(workdir, "lib", "engine.js");
228
+ const restoring = restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
229
+ await tick();
230
+ const discard = () => {
231
+ fs.writeFileSync(engine, `export const engine = ${Math.random()};\n`, "utf8");
232
+ const later = new Date(Date.now() + 60_000);
233
+ fs.utimesSync(engine, later, later);
234
+ };
235
+ discard();
236
+ ctl.resolveRound({ round: 1, passed: [], failed: [], undeterminable: ["VC-001", "VC-002"], report: "stale 1" });
237
+ await tick();
238
+ assert.equal(getExecution()!.audit.rounds, 0, "the first discard burns nothing");
239
+ discard();
240
+ ctl.resolveRound({ round: 1, passed: [], failed: [], undeterminable: ["VC-001", "VC-002"], report: "stale 2" });
241
+ await tick();
242
+ assert.equal(getExecution()!.audit.rounds, 1, "the second consecutive discard commits as undeterminable (budget spent)");
243
+ assert.deepEqual(getExecution()!.audit.undeterminable, ["VC-001", "VC-002"]);
244
+ await stopExecution(ctx, "teardown");
245
+ ctl.drainAll();
246
+ await restoring;
247
+ await __awaitReviewRoundForTests();
248
+ __setAuditRunnerForTests(null);
249
+ });
250
+
251
+ it("a failed round rolls back, returns the run to executing, and wakes exactly once", async () => {
252
+ const { workdir, planPath, runId } = freshWorkdir();
253
+ const ctx = await startTerminal(planPath, workdir);
254
+ const ctl = controlledRunner();
255
+ __setAuditRunnerForTests(ctl.runner);
256
+ const restoring = restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
257
+ await tick();
258
+ ctl.resolveRound({ round: 1, passed: ["VC-001"], failed: ["VC-002"], undeterminable: [], report: "VC-002 is not satisfied" });
259
+ await restoring;
260
+ await __awaitReviewRoundForTests();
261
+ const ex = getExecution()!;
262
+ assert.equal(getRun(workdir, runId)?.status, "executing", "rollback returns the run to executing for repair");
263
+ assert.equal(ex.tasks.find((t) => t.id === "Task-2")?.status, "pending", "the failed check's task rolled back");
264
+ const wakes = ctx.entries.filter((e) => e.customType === "pi-plans-audit-failed");
265
+ assert.equal(wakes.length, 1, "exactly one wake per committed failed outcome");
266
+ assert.equal(loadCheckpoint(workdir, runId).checkpoint.phase, "executing", "checkpoint phase stays executing across the round");
267
+ await stopExecution(ctx, "teardown");
268
+ __setAuditRunnerForTests(null);
269
+ });
270
+
271
+ it("a second restore aborts the previous in-flight round; never two live rounds on one run", async () => {
272
+ const { workdir, planPath } = freshWorkdir();
273
+ await startTerminal(planPath, workdir);
274
+ const ctl = controlledRunner();
275
+ __setAuditRunnerForTests(ctl.runner);
276
+ const ctx = makeCtx(workdir, "tui");
277
+ await restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
278
+ const firstAttempt = getExecution()!.review.attempts;
279
+ await restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
280
+ const after = getExecution()!;
281
+ assert.ok(after.review.inFlight, "the second resume started its own round");
282
+ assert.equal(after.review.attempts, 1, "the replacement identity starts its own attempt 1 (the old round was aborted)");
283
+ assert.notEqual(after.review.inFlight.controller, undefined, "exactly one live round — its abort lifecycle is owned");
284
+ // The aborted first round resolves late against a replaced identity: it
285
+ // must be discarded silently (no extra wake, no budget charge).
286
+ ctl.resolveRound({ round: 1, passed: ["VC-001", "VC-002"], failed: [], undeterminable: [], report: "late stale round" });
287
+ ctl.resolveRound({ round: 1, passed: ["VC-001", "VC-002"], failed: [], undeterminable: [], report: "fresh round" });
288
+ await __awaitReviewRoundForTests();
289
+ assert.equal(ctx.entries.filter((e) => e.customType === "pi-plans-complete").length, 1, "the run completed exactly once");
290
+ __setAuditRunnerForTests(null);
291
+ });
292
+
293
+ it("every attempt opens a fresh overlay; the reopen shortcut is inert with no in-flight round", async () => {
294
+ const { workdir, planPath } = freshWorkdir();
295
+ await startTerminal(planPath, workdir);
296
+ const ctl = controlledRunner();
297
+ __setAuditRunnerForTests(ctl.runner);
298
+ const opens = { count: 0 };
299
+ const ctx = makeCtx(workdir, "tui", opens);
300
+ await restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
301
+ assert.equal(opens.count, 1, "the round opened its overlay before spawning");
302
+ // ESC closed the (one-shot) controller; the reopen shortcut rebuilds it
303
+ // from engine-held lane state while the round is still in flight.
304
+ const { reopenReviewOverlay } = await import("../src/exec.ts");
305
+ reopenReviewOverlay(ctx);
306
+ assert.equal(opens.count, 2, "reopen builds a fresh controller for the same round");
307
+ ctl.resolveRound({ round: 1, passed: ["VC-001", "VC-002"], failed: [], undeterminable: [], report: "done" });
308
+ await __awaitReviewRoundForTests();
309
+ // No round in flight → the shortcut is inert.
310
+ reopenReviewOverlay(ctx);
311
+ assert.equal(opens.count, 2, "reopen is inert with no in-flight round");
312
+ __setAuditRunnerForTests(null);
313
+ });
314
+
315
+ it("a legacy cap pause (old prefix) survives restore paused at its round count", async () => {
316
+ const { workdir, planPath, runId } = freshWorkdir();
317
+ await startTerminal(planPath, workdir);
318
+ // Simulate a checkpoint paused by a v0.7 build ("completion audit
319
+ // exhausted 3 rounds") — the dual-matched prefix must keep it paused.
320
+ mutateCheckpoint(workdir, runId, (cp) =>
321
+ applyExecutionProgress(cp, { audit: { rounds: 3, lastResult: "VC-001" }, pausedReason: "completion audit exhausted 3 rounds (failed: VC-001)." }),
322
+ );
323
+ const load = await import("../src/exec.ts");
324
+ const result = load.loadExecutionFromCheckpoint(makeCtx(workdir), runId);
325
+ assert.equal(result.status, "loaded", `checkpoint loads (${result.status})`);
326
+ const ex = getExecution()!;
327
+ assert.equal(ex.stall.paused, true, "a legacy cap pause stays paused across restore");
328
+ assert.equal(ex.audit.rounds, 3, "the legacy round count survives — restore grants no budget");
329
+ await stopExecution(makeCtx(workdir), "teardown");
330
+ });
331
+ });