pi-plans 0.8.1 → 0.8.2

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.
@@ -12,17 +12,24 @@ import { after, before, describe, it } from "node:test";
12
12
  import {
13
13
  __awaitReviewRoundForTests,
14
14
  __setAuditRunnerForTests,
15
+ EXECUTION_BLOCKED_CUSTOM_TYPE,
16
+ EXECUTION_CONTINUE_CUSTOM_TYPE,
17
+ executionContextMessage,
15
18
  getExecution,
16
19
  persistTaskProgress,
20
+ registerExecutionTurnHandlers,
17
21
  restoreFromSession,
18
22
  startExecution,
19
23
  stopExecution,
20
24
  } from "../src/exec.ts";
25
+ import { parseChecklist, parsePlanTasks } from "../src/plan.ts";
21
26
  import { setMessagingApi } from "../src/messaging.ts";
22
27
  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";
28
+ import { spawnSync } from "node:child_process";
29
+ import { getRun, initState, setRunStatus, startRun } from "../src/state.ts";
30
+ import { createCheckpoint, loadCheckpoint, mutateCheckpoint, applyExecutionApproved, applyExecutionProgress, applyPlanWritten, planIdentityOf, resolveHeadAt } from "../src/workflow-state.ts";
31
+
32
+ import { loadExecutionFromCheckpoint } from "../src/exec.ts";
26
33
 
27
34
  const PLAN = `# PLAN_v1 - review-loop fixture
28
35
 
@@ -147,6 +154,10 @@ describe("execution-review loop (v0.8)", () => {
147
154
  __setAuditRunnerForTests(ctl.runner);
148
155
  const ctxTui = makeCtx(workdir, "tui");
149
156
  await restoreFromSession(ctxTui, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
157
+ // v0.9.3: a TUI host gets the budget panel before round 1; this minimal
158
+ // host resolves it without a choice, so one hop passes before the round
159
+ // spawns. The detach contract itself is unchanged.
160
+ await tick();
150
161
  // The restore returned while the round is STILL running: in-flight marker
151
162
  // present, run status moved to verifying, no outcome committed yet.
152
163
  assert.ok(getExecution()!.review.inFlight, "the round is in flight after the settle returned");
@@ -278,7 +289,12 @@ describe("execution-review loop (v0.8)", () => {
278
289
  __setAuditRunnerForTests(ctl.runner);
279
290
  const ctx = makeCtx(workdir, "tui");
280
291
  await restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
292
+ // v0.9.3: the first restore resolves the budget (one hop) before its
293
+ // round spawns; wait for it so the second restore really replaces a
294
+ // LIVE round.
295
+ await tick();
281
296
  const firstAttempt = getExecution()!.review.attempts;
297
+ assert.equal(firstAttempt, 1, "the first round is in flight before the second restore");
282
298
  await restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
283
299
  const after = getExecution()!;
284
300
  assert.ok(after.review.inFlight, "the second resume started its own round");
@@ -471,6 +487,9 @@ describe("findings-driven fix loop (v0.9)", () => {
471
487
  it("findings persist across the session snapshot and survive the fresh-budget renewal", async () => {
472
488
  const { workdir, planPath } = freshWorkdir();
473
489
  const ctx = await startTerminal(planPath, workdir);
490
+ // v0.9.3: this test drives rounds 1..5 on purpose — pin the 5-round
491
+ // budget (the no-UI default is now 3).
492
+ getExecution()!.reviewBudget = 5;
474
493
  const ctl = controlledRunner();
475
494
  __setAuditRunnerForTests(ctl.runner);
476
495
  const restoring = restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
@@ -513,7 +532,11 @@ describe("findings-driven fix loop (v0.9)", () => {
513
532
  // Cancelled rounds burn no budget and loop nowhere — safe to leave the
514
533
  // runner in this mode while checking the renewal semantics.
515
534
  __setAuditRunnerForTests(async () => ({ cancelled: true }) as never);
516
- assert.ok(resumeActiveExecution(ctx), "renewal lifts the pause");
535
+ // v0.9.3: the resume is async and reports the outcome; this ctx has no
536
+ // panel, so the headless path re-grants the same budget (5).
537
+ const renewed = await resumeActiveExecution(ctx);
538
+ assert.ok(renewed.resumed, "renewal lifts the pause");
539
+ assert.equal(renewed.grantedBudget, 5, "the headless grant keeps the current budget");
517
540
  assert.equal(getExecution()!.audit.rounds, 0, "fresh budget");
518
541
  assert.deepEqual(getExecution()!.audit.findings.map((f: { id: string }) => f.id), ["F-001"], "stable ids carry into the fresh budget");
519
542
  ctl.drainAll();
@@ -722,3 +745,608 @@ describe("executor injection with findings (v0.9)", () => {
722
745
  ctl.drainAll();
723
746
  });
724
747
  });
748
+
749
+ /**
750
+ * v0.9.2 blocked-point escalation (RCA: a missed parent re-close left the tree
751
+ * non-terminal, the review could not start, and three silent no-op wakes
752
+ * paused the run with a watchdog metric instead of the blocker).
753
+ */
754
+ const PARENT_PLAN = `# PLAN_v1 - blocked fixture
755
+
756
+ ## Tasks
757
+
758
+ - Task-1: engine — files: lib/engine.js; wave: 1
759
+ - Task-2: host — files: lib/host.js; wave: 1
760
+ - Task-2.1: daemon wiring — files: lib/host.js
761
+
762
+ ## Verification Checks
763
+
764
+ - [ ] \`VC-001\` covers \`Task-1\`; pass condition: engine works; evidence: tests; metric: green.
765
+ - [ ] \`VC-002\` covers \`Task-2\`; pass condition: host works; evidence: tests; metric: green.
766
+ `;
767
+
768
+ describe("blocked-point escalation (v0.9.2)", () => {
769
+ let root = "";
770
+ let counter = 0;
771
+
772
+ before(() => {
773
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-blocked-"));
774
+ });
775
+
776
+ after(() => {
777
+ fs.rmSync(root, { recursive: true, force: true });
778
+ __setAuditRunnerForTests(null);
779
+ });
780
+
781
+ function freshParentWorkdir(): { workdir: string; planPath: string; runId: string } {
782
+ counter += 1;
783
+ const workdir = path.join(root, `blocked-${counter}`);
784
+ fs.mkdirSync(path.join(workdir, "lib"), { recursive: true });
785
+ fs.writeFileSync(path.join(workdir, "lib", "engine.js"), "export const engine = 1;\n", "utf8");
786
+ fs.writeFileSync(path.join(workdir, "lib", "host.js"), "export const host = 1;\n", "utf8");
787
+ initState(workdir);
788
+ const { run } = startRun(workdir, { topic: `blocked${counter}`, skill: "plan-small", requestText: "demo" });
789
+ createCheckpoint(workdir, { runId: run.run_id, originWorkdir: workdir, workdir });
790
+ const planPath = path.join(run.artifact_dir, "PLAN_v1.md");
791
+ fs.mkdirSync(run.artifact_dir, { recursive: true });
792
+ fs.writeFileSync(planPath, PARENT_PLAN, "utf8");
793
+ mutateCheckpoint(workdir, run.run_id, (cp) =>
794
+ applyExecutionApproved(
795
+ applyPlanWritten({ ...cp, nextAction: "accept-execute" }, planIdentityOf(planPath, 1)),
796
+ { plan: planIdentityOf(planPath, 1), worktree: workdir, headAtApproval: null, approvedAt: cp.updatedAt },
797
+ ),
798
+ );
799
+ return { workdir, planPath, runId: run.run_id };
800
+ }
801
+
802
+ /** ctx + event driver (pattern: tests/exec.test.ts wireEvents, 677-690). */
803
+ function makeHarness(workdir: string) {
804
+ const entries: Array<{ customType: string; data?: unknown; content?: string; display?: boolean }> = [];
805
+ const ctx = {
806
+ cwd: workdir,
807
+ sessionManager: {},
808
+ hasUI: true,
809
+ mode: "tui",
810
+ entries,
811
+ ui: {
812
+ notify: () => {},
813
+ setStatus: () => {},
814
+ setWidget: () => {},
815
+ theme: { fg: (_c: string, t: string) => t, bold: (t: string) => t },
816
+ },
817
+ isIdle: () => true,
818
+ hasPendingMessages: () => false,
819
+ } as never;
820
+ setMessagingApi({
821
+ appendEntry: (customType: string, data: unknown) => entries.push({ customType, data }),
822
+ sendMessage: (message: { customType: string; content: string; display?: boolean }) => entries.push({ customType: message.customType, content: message.content, display: message.display }),
823
+ } as never);
824
+ const handlers = new Map<string, Array<(event: unknown, c: never) => unknown>>();
825
+ const ext = {
826
+ on: (name: string, fn: (event: unknown, c: never) => unknown) => {
827
+ const list = handlers.get(name) ?? [];
828
+ list.push(fn);
829
+ handlers.set(name, list);
830
+ },
831
+ } as never;
832
+ registerExecutionTurnHandlers(ext);
833
+ return {
834
+ ctx,
835
+ entries,
836
+ async fire(name: string, event: unknown = {}) {
837
+ for (const fn of handlers.get(name) ?? []) await fn(event, ctx);
838
+ },
839
+ /** One idle executor round that never touched a task or tool. */
840
+ async idleRound() {
841
+ await this.fire("agent_start");
842
+ await this.fire("turn_end", { message: { role: "assistant", stopReason: "stop", usage: { input: 1, output: 1 } } });
843
+ await this.fire("agent_settled");
844
+ },
845
+ };
846
+ }
847
+
848
+ it("names the missed parent, escalates before pausing, and clears on the parent re-close", async () => {
849
+ const { workdir, planPath, runId } = freshParentWorkdir();
850
+ const harness = makeHarness(workdir);
851
+ const ctx = harness.ctx;
852
+ await startExecution(ctx, { planPath, planTasks: parsePlanTasks(PARENT_PLAN), items: parseChecklist(PARENT_PLAN) });
853
+ // First pass: everything closed (children before the parent).
854
+ for (const id of ["Task-2.1", "Task-2", "Task-1"]) {
855
+ applyTaskUpdate(getExecution()!.tasks, id, "complete", `${id} evidence`);
856
+ persistTaskProgress(ctx);
857
+ }
858
+ // Round 1 fails VC-002 -> Task-2 + Task-2.1 roll back.
859
+ const ctl = controlledRunner();
860
+ __setAuditRunnerForTests(ctl.runner);
861
+ const restoring = restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
862
+ await tick();
863
+ ctl.resolveRound({ round: 1, passed: ["VC-001"], failed: ["VC-002"], undeterminable: [], report: "VC-002 fails" });
864
+ await restoring;
865
+ await __awaitReviewRoundForTests();
866
+ const ex = getExecution()!;
867
+ assert.equal(ex.audit.rounds, 1);
868
+ assert.deepEqual(ex.blocked?.rolledBack.sort(), ["Task-2", "Task-2.1"], "the authoritative rollback set is captured at commit time");
869
+ assert.deepEqual(ex.blocked?.tasks.sort(), ["Task-2", "Task-2.1"]);
870
+ assert.deepEqual(loadCheckpoint(workdir, runId).checkpoint.execution?.blocked?.tasks.sort(), ["Task-2", "Task-2.1"], "the blocker is persisted");
871
+ // The executor re-closes the CHILD only — the lattice-code shape.
872
+ applyTaskUpdate(ex.tasks, "Task-2.1", "complete", "wiring fixed");
873
+ persistTaskProgress(ctx);
874
+ assert.deepEqual(getExecution()!.blocked?.tasks, ["Task-2"], "closing the child does not close the parent");
875
+ assert.deepEqual(loadCheckpoint(workdir, runId).checkpoint.execution?.blocked?.tasks, ["Task-2"]);
876
+
877
+ // Blocked wake 1: escalated wording + one visible system line.
878
+ await harness.idleRound();
879
+ assert.equal(getExecution()!.blocked?.escalatedRounds, 1);
880
+ const wake1 = harness.entries.filter((e) => e.customType === EXECUTION_CONTINUE_CUSTOM_TYPE).at(-1)?.content ?? "";
881
+ assert.match(wake1, /Review: NO round is running — the task tree is not terminal, so the review cannot start/);
882
+ assert.match(wake1, /BLOCKED — 1 task\(s\) reopened by round 1 are still open:/);
883
+ assert.match(wake1, /- Task-2 \(reopened by round 1\)/);
884
+ assert.match(wake1, /Closing a child does NOT close its parent/);
885
+ assert.match(wake1, /blocked wake 1\/3/);
886
+ const visible1 = harness.entries.filter((e) => e.customType === EXECUTION_BLOCKED_CUSTOM_TYPE);
887
+ assert.equal(visible1.length, 1, "one visible escalation line");
888
+ assert.equal(visible1[0]!.display, true);
889
+ assert.match(visible1[0]!.content ?? "", /execution blocked \(wake 1\/3\)/);
890
+
891
+ // Blocked wake 2: still no change -> escalate, still no pause.
892
+ await harness.idleRound();
893
+ assert.equal(getExecution()!.blocked?.escalatedRounds, 2);
894
+ assert.equal(getExecution()!.stall.paused, false, "escalation precedes the pause");
895
+ assert.equal(harness.entries.filter((e) => e.customType === EXECUTION_BLOCKED_CUSTOM_TYPE).length, 2);
896
+ const wake2 = harness.entries.filter((e) => e.customType === EXECUTION_CONTINUE_CUSTOM_TYPE).at(-1)?.content ?? "";
897
+ assert.match(wake2, /blocked wake 2\/3/);
898
+
899
+ // Blocked wake 3: the watchdog pauses with the BLOCKER as the reason.
900
+ await harness.idleRound();
901
+ assert.equal(getExecution()!.stall.paused, true, "the third blocked wake pauses");
902
+ const reason = getExecution()!.stall.pausedReason ?? "";
903
+ assert.match(reason, /^blocked: review round 2 cannot start/);
904
+ assert.match(reason, /Task-2/);
905
+ assert.match(reason, /reopened by round 1/);
906
+ assert.equal(loadCheckpoint(workdir, runId).checkpoint.execution?.pausedReason, reason, "the pause reason is persisted");
907
+ // Levels 1 and 2 emit the visible line; the cap levels pauses instead of
908
+ // waking, and the pause carries the blocker in the pause row/notify.
909
+ assert.equal(harness.entries.filter((e) => e.customType === EXECUTION_BLOCKED_CUSTOM_TYPE).length, 2);
910
+ // The cap-pause prefix matching must not see a review-cap pause here.
911
+ await harness.fire("input", { source: "interactive" });
912
+ assert.equal(getExecution()!.stall.paused, false, "ordinary input resumes a blocked pause");
913
+ assert.equal(getExecution()!.blocked?.escalatedRounds, 0, "a resume grants a fresh ladder");
914
+
915
+ // Re-closing the parent clears the blocker and the review starts by itself.
916
+ applyTaskUpdate(getExecution()!.tasks, "Task-2", "complete", "host fixed");
917
+ persistTaskProgress(ctx);
918
+ assert.equal(getExecution()!.blocked, null);
919
+ assert.equal(loadCheckpoint(workdir, runId).checkpoint.execution?.blocked, undefined, "the record is dropped, not left stale");
920
+ // The very next settled round launches round 2 by itself (turn_end owns
921
+ // the terminal-but-unaudited state — no wake, no user input).
922
+ const settled = harness.fire("turn_end", { message: { role: "assistant", stopReason: "stop", usage: { input: 1, output: 1 } } });
923
+ await tick();
924
+ assert.equal(ctl.calls(), 2, "round 2 spawned from the settle, not from a wake");
925
+ ctl.resolveRound({ round: 2, passed: ["VC-001", "VC-002"], failed: [], undeterminable: [], report: "all pass" });
926
+ await settled;
927
+ await __awaitReviewRoundForTests();
928
+ assert.equal(loadCheckpoint(workdir, runId).checkpoint.phase, "completed", "the review ran once the tree became terminal");
929
+ __setAuditRunnerForTests(null);
930
+ ctl.drainAll();
931
+ });
932
+ it("real progress resets the ladder instead of hardening the wording (round-1 F-001)", async () => {
933
+ const { workdir, planPath, runId } = freshParentWorkdir();
934
+ const harness = makeHarness(workdir);
935
+ const ctx = harness.ctx;
936
+ await startExecution(ctx, { planPath, planTasks: parsePlanTasks(PARENT_PLAN), items: parseChecklist(PARENT_PLAN) });
937
+ for (const id of ["Task-2.1", "Task-2", "Task-1"]) {
938
+ applyTaskUpdate(getExecution()!.tasks, id, "complete", `${id} evidence`);
939
+ persistTaskProgress(ctx);
940
+ }
941
+ const ctl = controlledRunner();
942
+ __setAuditRunnerForTests(ctl.runner);
943
+ const restoring = restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
944
+ await tick();
945
+ ctl.resolveRound({ round: 1, passed: ["VC-001"], failed: ["VC-002"], undeterminable: [], report: "VC-002 fails" });
946
+ await restoring;
947
+ await __awaitReviewRoundForTests();
948
+ // Both reopened tasks stay open: one no-progress blocked wake.
949
+ await harness.idleRound();
950
+ assert.equal(getExecution()!.blocked?.escalatedRounds, 1, "the first blocked wake counts as no progress");
951
+ assert.deepEqual(getExecution()!.blocked?.tasks.sort(), ["Task-2", "Task-2.1"]);
952
+ // The executor closes ONE of the two blockers -> real progress.
953
+ applyTaskUpdate(getExecution()!.tasks, "Task-2.1", "complete", "child fixed");
954
+ persistTaskProgress(ctx);
955
+ assert.deepEqual(getExecution()!.blocked?.tasks, ["Task-2"], "persistTaskProgress re-syncs the STORED list (the reason it cannot be the ladder baseline)");
956
+ await harness.idleRound();
957
+ assert.equal(getExecution()!.blocked?.escalatedRounds, 0, "a shrunken blocker set resets the ladder");
958
+ const wake = harness.entries.filter((e) => e.customType === EXECUTION_CONTINUE_CUSTOM_TYPE).at(-1)?.content ?? "";
959
+ assert.doesNotMatch(wake, /blocked wake/, "the reset wake carries no escalation sentence");
960
+ assert.equal(harness.entries.filter((e) => e.customType === EXECUTION_BLOCKED_CUSTOM_TYPE).length, 1, "no new visible line for a reset");
961
+ // The fresh ladder then counts again: 1, 2, pause at 3.
962
+ await harness.idleRound();
963
+ assert.equal(getExecution()!.blocked?.escalatedRounds, 1);
964
+ await harness.idleRound();
965
+ assert.equal(getExecution()!.blocked?.escalatedRounds, 2);
966
+ await harness.idleRound();
967
+ assert.equal(getExecution()!.stall.paused, true, "three consecutive no-progress wakes still pause");
968
+ assert.match(getExecution()!.stall.pausedReason ?? "", /blocked: review round 2 cannot start/);
969
+ assert.match(loadCheckpoint(workdir, runId).checkpoint.execution?.pausedReason ?? "", /Task-2/);
970
+ await stopExecution(ctx, "teardown");
971
+ ctl.drainAll();
972
+ });
973
+
974
+ it("the executor rules forbid waiting and require re-closing parents", async () => {
975
+ const { workdir, planPath } = freshParentWorkdir();
976
+ const harness = makeHarness(workdir);
977
+ await startExecution(harness.ctx, { planPath, planTasks: parsePlanTasks(PARENT_PLAN), items: parseChecklist(PARENT_PLAN) });
978
+ const msg = executionContextMessage(harness.ctx) ?? "";
979
+ assert.match(msg, /PARENTS INCLUDED/);
980
+ assert.match(msg, /Closing a task's children does NOT close the task/);
981
+ assert.match(msg, /NEVER wait for the review/);
982
+ await stopExecution(harness.ctx, "teardown");
983
+ });
984
+ });
985
+
986
+ /**
987
+ * Pre-feature checkpoint backfill (v0.9.2): the lattice-code run was paused by
988
+ * the old build, so its checkpoint has no `execution.blocked` record. The
989
+ * blocker is reconstructed from round 1's `audit.lastResult` + the plan's
990
+ * coverage — without re-applying the rollback (that would revert work the
991
+ * executor has since re-closed).
992
+ */
993
+ describe("pre-feature checkpoint backfill (v0.9.2)", () => {
994
+ it("reconstructs the real lattice-code blocker (Task-2, round 1) and persists it", async () => {
995
+ const fixtureDir = path.join(import.meta.dirname, "fixtures", "lattice-code-blocked");
996
+ const planText = fs.readFileSync(path.join(fixtureDir, "plan-v2-trimmed.md"), "utf8");
997
+ const state = JSON.parse(fs.readFileSync(path.join(fixtureDir, "state.json"), "utf8")) as {
998
+ tasks: Record<string, { status: string; evidence?: string; skipReason?: string }>;
999
+ audit: { rounds: number; lastResult: string; undeterminable: string[] };
1000
+ };
1001
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-backfill-"));
1002
+ spawnSync("git", ["init"], { cwd: workdir });
1003
+ spawnSync("git", ["config", "user.email", "t@example.com"], { cwd: workdir });
1004
+ spawnSync("git", ["config", "user.name", "T"], { cwd: workdir });
1005
+ fs.writeFileSync(path.join(workdir, "seed.txt"), "seed", "utf8");
1006
+ spawnSync("git", ["add", "-A"], { cwd: workdir });
1007
+ spawnSync("git", ["commit", "-m", "seed"], { cwd: workdir });
1008
+ initState(workdir);
1009
+ const { run } = startRun(workdir, { topic: "lattice-backfill", skill: "plan-small", requestText: "demo" });
1010
+ createCheckpoint(workdir, { runId: run.run_id, originWorkdir: workdir, workdir });
1011
+ const planPath = path.join(run.artifact_dir, "PLAN_v2.md");
1012
+ fs.mkdirSync(run.artifact_dir, { recursive: true });
1013
+ fs.writeFileSync(planPath, planText, "utf8");
1014
+ mutateCheckpoint(workdir, run.run_id, (cp) => {
1015
+ const plan = planIdentityOf(planPath, 2);
1016
+ let next = applyPlanWritten({ ...cp, nextAction: "accept-execute" }, plan);
1017
+ next = applyExecutionApproved(next, {
1018
+ plan,
1019
+ worktree: workdir,
1020
+ headAtApproval: resolveHeadAt(workdir),
1021
+ approvedAt: next.updatedAt,
1022
+ });
1023
+ // The REAL slices; deliberately no `blocked` key (pre-feature checkpoint).
1024
+ return applyExecutionProgress(next, {
1025
+ tasks: state.tasks,
1026
+ audit: { rounds: state.audit.rounds, lastResult: state.audit.lastResult, undeterminable: state.audit.undeterminable },
1027
+ });
1028
+ });
1029
+ setRunStatus(workdir, run.run_id, "executing");
1030
+ const ctx = makeCtx(workdir, "tui");
1031
+ const load = loadExecutionFromCheckpoint(ctx, run.run_id);
1032
+ assert.equal(load.status, "loaded");
1033
+ assert.deepEqual(load.blocked?.tasks, ["Task-2"], "the real blocker is reconstructed");
1034
+ assert.equal(load.blocked?.round, 1);
1035
+ assert.ok(load.blocked?.rolledBack.includes("Task-7"), "coverage cascade covers the other tasks of the failed checks");
1036
+ assert.ok(load.blocked?.rolledBack.includes("Task-2.1"), "children cascade");
1037
+ const persisted = loadCheckpoint(workdir, run.run_id).checkpoint.execution?.blocked;
1038
+ assert.deepEqual(persisted?.tasks, ["Task-2"], "the reconstructed blocker is persisted");
1039
+ // The tree was NOT re-mutated: the reopened-then-re-closed tasks stay closed.
1040
+ const after = loadCheckpoint(workdir, run.run_id).checkpoint.execution?.tasks ?? {};
1041
+ assert.equal(after["Task-2"]?.status, "pending");
1042
+ assert.equal(after["Task-2.1"]?.status, "complete");
1043
+ assert.equal(after["Task-7"]?.status, "complete");
1044
+ assert.equal(getExecution()!.blocked?.tasks[0], "Task-2");
1045
+ // The /reload recovery path: a session snapshot written by a pre-feature
1046
+ // build carries the LIVE failed set (`audit.failed`) and no blocker
1047
+ // record — exactly the shape the stuck lattice-code session has.
1048
+ const live = getExecution()!;
1049
+ const snapshot = { ...live, audit: { ...live.audit, failed: state.audit.lastResult.split(",") } } as Record<string, unknown>;
1050
+ delete snapshot.blocked;
1051
+ await restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: snapshot }]);
1052
+ assert.deepEqual(getExecution()!.blocked?.tasks, ["Task-2"], "a pre-feature session snapshot is backfilled too");
1053
+ assert.equal(getExecution()!.blocked?.round, 1);
1054
+ await stopExecution(makeCtx(workdir), "teardown");
1055
+ });
1056
+ });
1057
+
1058
+ describe("execution-review budget (v0.9.3)", () => {
1059
+ const highFinding = (id: string, taskIds: string[] = ["Task-1"]) => ({
1060
+ id,
1061
+ severity: "high" as const,
1062
+ taskIds,
1063
+ note: `${id} note`,
1064
+ evidence: "lib/engine.js",
1065
+ raw: `- \` ${id}\``,
1066
+ });
1067
+
1068
+ /** A ctx whose budget menu answers with `choice` (a label prefix), or a
1069
+ * cancelled menu when null. UI.custom is either absent (menu path) or a
1070
+ * no-choice overlay (falls through to the menu). */
1071
+ function ctxWithBudgetMenu(workdir: string, choice: string | null, mode: "print" | "tui" = "tui", onAsk?: () => void) {
1072
+ const base = makeCtx(workdir, mode);
1073
+ const ui = (base as { ui: Record<string, unknown> }).ui;
1074
+ return {
1075
+ ...base,
1076
+ ui: {
1077
+ ...ui,
1078
+ select: async (_title: string, options: string[]): Promise<string | undefined> => {
1079
+ onAsk?.();
1080
+ if (choice === null) return undefined;
1081
+ return options.find((option) => option.startsWith(choice)) ?? options[0];
1082
+ },
1083
+ },
1084
+ } as typeof base;
1085
+ }
1086
+
1087
+ it("asks for the budget once, right before round 1, and persists the pick", async () => {
1088
+ const { workdir, planPath, runId } = freshWorkdir();
1089
+ await startTerminal(planPath, workdir);
1090
+ const ctl = controlledRunner();
1091
+ __setAuditRunnerForTests(ctl.runner);
1092
+ let asks = 0;
1093
+ const ctx = ctxWithBudgetMenu(workdir, "1 round", "tui", () => {
1094
+ asks += 1;
1095
+ });
1096
+ const restoring = restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
1097
+ await tick();
1098
+ const ex = getExecution()!;
1099
+ assert.equal(ex.reviewBudget, 1, "the picked budget is live");
1100
+ assert.equal(ex.reviewBudgetDefaulted, false, "a user pick is not marked default");
1101
+ assert.ok(ex.review.inFlight, "the round spawns right after the pick");
1102
+ assert.equal(asks, 1, "the panel was asked exactly once");
1103
+ const cp = loadCheckpoint(workdir, runId);
1104
+ assert.ok(cp.status === "ok");
1105
+ assert.equal(cp.checkpoint.execution?.reviewBudget, 1, "the budget is persisted");
1106
+ ctl.resolveRound({ round: 1, passed: ["VC-001", "VC-002"], failed: [], undeterminable: [], report: "all pass" });
1107
+ await restoring;
1108
+ await __awaitReviewRoundForTests();
1109
+ assert.equal(asks, 1, "no second ask for the same run");
1110
+ assert.equal(getExecution(), null, "the passing round completed the run");
1111
+ ctl.drainAll();
1112
+ __setAuditRunnerForTests(null);
1113
+ });
1114
+
1115
+ it("falls back to the default 3 with a visible note when no panel exists", async () => {
1116
+ const { workdir, planPath } = freshWorkdir();
1117
+ const ctx = await startTerminal(planPath, workdir);
1118
+ const ctl = controlledRunner();
1119
+ __setAuditRunnerForTests(ctl.runner);
1120
+ const restoring = restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
1121
+ await tick();
1122
+ const ex = getExecution()!;
1123
+ assert.equal(ex.reviewBudget, 3, "the no-UI default applies");
1124
+ assert.equal(ex.reviewBudgetDefaulted, true, "and it is marked as the fallback");
1125
+ const notes = ctx.entries.filter((e) => e.customType === "pi-plans-review-budget-default");
1126
+ assert.equal(notes.length, 1, "one visible note, never silent");
1127
+ assert.match(String(notes[0].content), /default/);
1128
+ ctl.resolveRound({ round: 1, passed: ["VC-001", "VC-002"], failed: [], undeterminable: [], report: "all pass" });
1129
+ await restoring;
1130
+ await __awaitReviewRoundForTests();
1131
+ ctl.drainAll();
1132
+ __setAuditRunnerForTests(null);
1133
+ });
1134
+
1135
+ it("completes at an exhausted budget with an unresolved high — no rollback, no append, no wake", async () => {
1136
+ const { workdir, planPath, runId } = freshWorkdir();
1137
+ const ctx = await startTerminal(planPath, workdir);
1138
+ // One round, then the budget is spent.
1139
+ getExecution()!.reviewBudget = 1;
1140
+ const ctl = controlledRunner();
1141
+ __setAuditRunnerForTests(ctl.runner);
1142
+ const restoring = restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
1143
+ await tick();
1144
+ ctl.resolveRound({ round: 1, passed: ["VC-001", "VC-002"], failed: [], undeterminable: [], report: "pass with a high", findings: [highFinding("F-001")] } as never);
1145
+ await restoring;
1146
+ await __awaitReviewRoundForTests();
1147
+ assert.equal(getExecution(), null, "the run completes at the exhausted budget");
1148
+ const cp = loadCheckpoint(workdir, runId);
1149
+ assert.ok(cp.status === "ok");
1150
+ assert.equal(cp.checkpoint.phase, "completed");
1151
+ assert.equal(ctx.entries.filter((e) => e.customType === "pi-plans-audit-failed").length, 0, "no fix wake on the tolerated round");
1152
+ assert.equal(/Task-4/.test(fs.readFileSync(planPath, "utf8")), false, "no plan task was appended");
1153
+ const done = ctx.entries.filter((e) => e.customType === "pi-plans-complete");
1154
+ assert.equal(done.length, 1);
1155
+ assert.match(String(done[0].content), /review budget exhausted/, "the completion discloses the exhausted budget");
1156
+ assert.match(String(done[0].content), /F-001/, "and names the tolerated high finding");
1157
+ ctl.drainAll();
1158
+ __setAuditRunnerForTests(null);
1159
+ });
1160
+
1161
+ it("still wakes and repairs a high finding when the budget is NOT exhausted", async () => {
1162
+ const { workdir, planPath } = freshWorkdir();
1163
+ const ctx = await startTerminal(planPath, workdir);
1164
+ getExecution()!.reviewBudget = 3;
1165
+ const ctl = controlledRunner();
1166
+ __setAuditRunnerForTests(ctl.runner);
1167
+ const restoring = restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
1168
+ await tick();
1169
+ ctl.resolveRound({ round: 1, passed: ["VC-001", "VC-002"], failed: [], undeterminable: [], report: "pass with a high", findings: [highFinding("F-001")] } as never);
1170
+ await restoring;
1171
+ await __awaitReviewRoundForTests();
1172
+ const ex = getExecution();
1173
+ assert.notEqual(ex, null, "the run does not complete with a live high inside the budget");
1174
+ assert.equal(ex!.tasks.find((t) => t.id === "Task-1")?.status, "pending", "the mapped task rolled back");
1175
+ const wakes = ctx.entries.filter((e) => e.customType === "pi-plans-audit-failed");
1176
+ assert.equal(wakes.length, 1, "exactly one repair wake");
1177
+ ctl.drainAll();
1178
+ await stopExecution(ctx, "teardown");
1179
+ __setAuditRunnerForTests(null);
1180
+ });
1181
+
1182
+ it("unlimited: three identical no-report rounds trip the no-progress valve", async () => {
1183
+ const { workdir, planPath } = freshWorkdir();
1184
+ const ctx = await startTerminal(planPath, workdir);
1185
+ getExecution()!.reviewBudget = "unlimited";
1186
+ // A spawn failure carries no report: the signature must still advance
1187
+ // (round-1 F-002) — otherwise the valve could never catch a dead runner.
1188
+ __setAuditRunnerForTests(async () => null);
1189
+ await restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
1190
+ const ex = getExecution()!;
1191
+ assert.equal(ex.audit.rounds, 3, "three rounds committed before the valve");
1192
+ assert.equal(ex.reviewRoundsTotal, 3, "the cumulative counter agrees");
1193
+ assert.equal(ex.stall.paused, true, "the valve pauses the run");
1194
+ assert.match(ex.stall.pausedReason ?? "", /^execution review stalled/);
1195
+ assert.match(ex.stall.pausedReason ?? "", /3 consecutive rounds/);
1196
+ assert.equal(ex.reviewNoProgress?.streak, 3);
1197
+ assert.ok(ctx.entries.some((e) => e.customType === "pi-plans-review-paused"), "the pause is in-band");
1198
+ await stopExecution(ctx, "teardown");
1199
+ __setAuditRunnerForTests(null);
1200
+ });
1201
+
1202
+ it("unlimited: the run-cumulative hard cap pauses at 50 rounds", async () => {
1203
+ const { workdir, planPath } = freshWorkdir();
1204
+ const ctx = await startTerminal(planPath, workdir);
1205
+ getExecution()!.reviewBudget = "unlimited";
1206
+ getExecution()!.reviewRoundsTotal = 49;
1207
+ __setAuditRunnerForTests(async () => null);
1208
+ await restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
1209
+ const ex = getExecution()!;
1210
+ assert.equal(ex.reviewRoundsTotal, 50, "the cap counts the whole run");
1211
+ assert.equal(ex.stall.paused, true);
1212
+ assert.match(ex.stall.pausedReason ?? "", /execution review exhausted 50 rounds/);
1213
+ assert.match(ex.stall.pausedReason ?? "", /unlimited budget safety cap/);
1214
+ await stopExecution(ctx, "teardown");
1215
+ __setAuditRunnerForTests(null);
1216
+ });
1217
+
1218
+ it("the at-pause grant re-asks the budget; Esc keeps the pause, an unlimited pick adds 50", async () => {
1219
+ const { workdir, planPath } = freshWorkdir();
1220
+ const ctx = await startTerminal(planPath, workdir);
1221
+ getExecution()!.reviewBudget = "unlimited";
1222
+ getExecution()!.reviewRoundsTotal = 49;
1223
+ __setAuditRunnerForTests(async () => null);
1224
+ await restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
1225
+ assert.equal(getExecution()!.stall.paused, true, "paused at the hard cap");
1226
+ const { resumeActiveExecution } = await import("../src/exec.ts");
1227
+ // Esc (cancelled menu) keeps the pause — never a silent grant.
1228
+ const declined = await resumeActiveExecution(ctxWithBudgetMenu(workdir, null));
1229
+ assert.equal(declined.resumed, false);
1230
+ assert.equal(declined.budgetDeclined, true);
1231
+ assert.equal(getExecution()!.stall.paused, true, "the pause stands after Esc");
1232
+ // Picking unlimited lifts the cap by exactly one window and resets the
1233
+ // per-grant counters — the cumulative total never resets.
1234
+ const ctl = controlledRunner();
1235
+ __setAuditRunnerForTests(ctl.runner);
1236
+ const grantingCtx = ctxWithBudgetMenu(workdir, "unlimited");
1237
+ const granted = await resumeActiveExecution(grantingCtx);
1238
+ assert.equal(granted.resumed, true);
1239
+ assert.equal(granted.grantedBudget, "unlimited");
1240
+ const ex = getExecution()!;
1241
+ assert.equal(ex.reviewCapExtension, 50, "the unlimited grant extends the hard cap");
1242
+ assert.equal(ex.audit.rounds, 0, "the per-grant round counter resets");
1243
+ assert.equal(ex.reviewRoundsTotal, 50, "the run-cumulative counter does not");
1244
+ assert.equal(ex.reviewNoProgress, undefined, "the valve window restarts");
1245
+ assert.equal(ex.stall.paused, false, "the run resumed");
1246
+ assert.ok(
1247
+ grantingCtx.entries.some((e) => e.customType === "pi-plans-review-budget-granted" && /hard cap now 100 rounds/.test(String(e.content))),
1248
+ "the grant names the lifted cap",
1249
+ );
1250
+ ctl.drainAll();
1251
+ await stopExecution(ctx, "teardown");
1252
+ __setAuditRunnerForTests(null);
1253
+ });
1254
+
1255
+ it("a headless session keeps the default budget and can still be granted at a pause (round-1 F-001)", async () => {
1256
+ const { workdir, planPath, runId } = freshWorkdir();
1257
+ const ctx = await startTerminal(planPath, workdir);
1258
+ // The SDK's ui.select is REQUIRED, so a real headless context carries a
1259
+ // function that cannot ask. This host throws if the code ever calls it.
1260
+ const headlessCtx = {
1261
+ ...ctx,
1262
+ hasUI: false,
1263
+ ui: {
1264
+ ...(ctx as { ui: Record<string, unknown> }).ui,
1265
+ select: async (): Promise<string | undefined> => {
1266
+ throw new Error("headless select must never be called");
1267
+ },
1268
+ },
1269
+ } as typeof ctx;
1270
+ const ctl = controlledRunner();
1271
+ __setAuditRunnerForTests(ctl.runner);
1272
+ const restoring = restoreFromSession(headlessCtx, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
1273
+ await tick();
1274
+ const ex = getExecution()!;
1275
+ assert.equal(ex.reviewBudget, 3, "a headless session gets the default budget");
1276
+ assert.equal(ex.reviewBudgetDefaulted, true, "and it is marked as the fallback");
1277
+ assert.ok(ex.review.inFlight, "the round spawned without asking");
1278
+ const cp = loadCheckpoint(workdir, runId);
1279
+ assert.ok(cp.status === "ok");
1280
+ assert.equal(cp.checkpoint.execution?.reviewBudget, 3, "the default is persisted");
1281
+ // A cancelled round ends the chain without burning budget or
1282
+ // self-scheduling; the next restore then sees an exhausted budget.
1283
+ ctl.resolveRound({ cancelled: true } as never);
1284
+ await restoring;
1285
+ await __awaitReviewRoundForTests();
1286
+ const live = getExecution()!;
1287
+ live.audit.rounds = 3;
1288
+ live.reviewRoundsTotal = 3;
1289
+ live.audit.failed = ["VC-001"];
1290
+ await restoreFromSession(headlessCtx, [{ type: "custom", customType: "pi-plans-exec", data: live }]);
1291
+ assert.equal(getExecution()!.stall.paused, true, "an owed review at an exhausted budget pauses");
1292
+ const { resumeActiveExecution } = await import("../src/exec.ts");
1293
+ const granted = await resumeActiveExecution(headlessCtx);
1294
+ assert.equal(granted.resumed, true, "a headless grant is not declined");
1295
+ assert.equal(granted.budgetDeclined, undefined, "no panel decline path in a headless session");
1296
+ assert.equal(granted.grantedBudget, 3, "the headless grant keeps the current budget");
1297
+ assert.equal(getExecution()!.stall.paused, false, "the run resumes instead of staying paused forever");
1298
+ assert.equal(getExecution()!.audit.rounds, 0, "and the per-grant counter reset");
1299
+ ctl.resolveRound({ cancelled: true } as never);
1300
+ await __awaitReviewRoundForTests();
1301
+ await stopExecution(headlessCtx, "teardown");
1302
+ __setAuditRunnerForTests(null);
1303
+ });
1304
+
1305
+ it("an auto-approve session never asks for the budget (recorded plan decision)", async () => {
1306
+ const { workdir, planPath } = freshWorkdir();
1307
+ const ctx = await startTerminal(planPath, workdir);
1308
+ // A TUI-capable host: without the auto-approve gate this would ask.
1309
+ const answering = ctxWithBudgetMenu(workdir, "1 round", "tui", () => {
1310
+ throw new Error("auto-approve must not ask for the budget");
1311
+ });
1312
+ const previous = process.env.PI_PLANS_AUTO_APPROVE;
1313
+ process.env.PI_PLANS_AUTO_APPROVE = "1";
1314
+ try {
1315
+ const ctl = controlledRunner();
1316
+ __setAuditRunnerForTests(ctl.runner);
1317
+ const restoring = restoreFromSession(answering, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
1318
+ await tick();
1319
+ assert.equal(getExecution()!.reviewBudget, 3, "auto-approve takes the default budget");
1320
+ assert.equal(getExecution()!.reviewBudgetDefaulted, true);
1321
+ ctl.resolveRound({ round: 1, passed: ["VC-001", "VC-002"], failed: [], undeterminable: [], report: "all pass" });
1322
+ await restoring;
1323
+ await __awaitReviewRoundForTests();
1324
+ ctl.drainAll();
1325
+ } finally {
1326
+ if (previous === undefined) delete process.env.PI_PLANS_AUTO_APPROVE;
1327
+ else process.env.PI_PLANS_AUTO_APPROVE = previous;
1328
+ }
1329
+ __setAuditRunnerForTests(null);
1330
+ await stopExecution(ctx, "teardown");
1331
+ });
1332
+
1333
+ it("the budget counters and the valve state survive a session restore", async () => {
1334
+ const { workdir, planPath } = freshWorkdir();
1335
+ const ctx = await startTerminal(planPath, workdir);
1336
+ const live = getExecution()!;
1337
+ live.reviewBudget = "unlimited";
1338
+ live.reviewRoundsTotal = 7;
1339
+ live.reviewCapExtension = 50;
1340
+ live.reviewNoProgress = { key: "VC-002|VC-001|", streak: 2 };
1341
+ // A cancelled round neither burns the budget nor mutates the counters.
1342
+ __setAuditRunnerForTests(async () => ({ cancelled: true }) as never);
1343
+ await restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: live }]);
1344
+ const ex = getExecution()!;
1345
+ assert.equal(ex.reviewBudget, "unlimited", "the budget survives");
1346
+ assert.equal(ex.reviewRoundsTotal, 7, "the cumulative counter survives (no free window)");
1347
+ assert.equal(ex.reviewCapExtension, 50, "the granted extension survives");
1348
+ assert.deepEqual(ex.reviewNoProgress, { key: "VC-002|VC-001|", streak: 2 }, "the valve streak survives");
1349
+ await stopExecution(ctx, "teardown");
1350
+ __setAuditRunnerForTests(null);
1351
+ });
1352
+ });