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.
@@ -7,7 +7,9 @@ import { describe, it } from "node:test";
7
7
  import type { CheckItem } from "../src/plan.ts";
8
8
  import {
9
9
  auditRollbackSet,
10
+ blockedReviewTasks,
10
11
  buildTaskView,
12
+ rollbackCoverageIds,
11
13
  invalidateChecksForRolledBackTasks,
12
14
  taskProgressMap,
13
15
  type TaskProgressMap,
@@ -113,6 +115,49 @@ describe("progress persistence", () => {
113
115
  });
114
116
  });
115
117
 
118
+ describe("blocked review tasks", () => {
119
+ it("reports every non-terminal member of the authoritative rollback set in tree order", () => {
120
+ // Task-3 is complete but its child is open, so the parent is not
121
+ // terminal either; both must surface as blockers.
122
+ const tasks = view({ "Task-1": { status: "complete" }, "Task-3": { status: "complete" } });
123
+ assert.deepEqual(
124
+ blockedReviewTasks(tasks, ["Task-1", "Task-2", "Task-3", "Task-3.1"]),
125
+ ["Task-2", "Task-3", "Task-3.1"],
126
+ );
127
+ });
128
+
129
+ it("reports a task that was already pending, unlike the flip-reporting rollback helper", () => {
130
+ // The rollback helpers report only nodes they flipped; a task that was
131
+ // pending before the round must still count as blocking work.
132
+ const tasks = view({ "Task-1": { status: "pending" } });
133
+ assert.deepEqual(auditRollbackSet(tasks, checks(), "VC-001"), []);
134
+ assert.deepEqual(blockedReviewTasks(tasks, ["Task-1"]), ["Task-1"]);
135
+ });
136
+
137
+ it("ignores terminal tasks and ids outside the rollback set", () => {
138
+ const tasks = view(ALL_DONE);
139
+ assert.deepEqual(blockedReviewTasks(tasks, ["Task-1", "Task-3"]), []);
140
+ assert.deepEqual(blockedReviewTasks(tasks, []), []);
141
+ });
142
+
143
+ it("computes a failed round's coverage (pre-feature backfill) without mutating the tree", () => {
144
+ // VC-002 covers Task-2 and Task-3; the cascade pulls Task-3.1 in.
145
+ const tasks = view(ALL_DONE);
146
+ const before = JSON.stringify(tasks);
147
+ assert.deepEqual(rollbackCoverageIds(tasks, checks(), ["VC-002"]), ["Task-2", "Task-3", "Task-3.1"]);
148
+ assert.deepEqual(rollbackCoverageIds(tasks, checks(), ["VC-003"]), ["Task-3.1"]);
149
+ assert.deepEqual(rollbackCoverageIds(tasks, checks(), ["VC-999"]), []);
150
+ assert.equal(JSON.stringify(tasks), before, "coverage computation never reopens anything");
151
+ });
152
+
153
+ it("never mutates status, evidence, or skipReason", () => {
154
+ const tasks = view({ "Task-2": { status: "pending", evidence: "previous attempt" }, "Task-3": { status: "skipped", skipReason: "covered by Task-2" } });
155
+ const before = JSON.stringify(tasks);
156
+ blockedReviewTasks(tasks, ["Task-1", "Task-2", "Task-3", "Task-3.1"]);
157
+ assert.equal(JSON.stringify(tasks), before);
158
+ });
159
+ });
160
+
116
161
  describe("done-flag invalidation", () => {
117
162
  it("clears done on checks whose covered tasks were reopened", () => {
118
163
  // VC-002 covers Task-2 and Task-3; its rollback reopens those, so a
@@ -523,3 +523,190 @@ describe("blocking budget persistence", () => {
523
523
  assert.throws(() => mutateCheckpoint(workdir, runId, () => cp), /findings\.0: unexpected key/);
524
524
  });
525
525
  });
526
+
527
+ describe("blocked-point persistence (v0.9.2)", () => {
528
+ function executingCheckpoint(workdir: string, runId: string) {
529
+ createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
530
+ const planPath = path.join(workdir, "PLAN_v1.md");
531
+ fs.writeFileSync(planPath, "# PLAN_v1 - blocked demo\n", "utf8");
532
+ const plan = planIdentityOf(planPath, 1);
533
+ let cp = applyPlanWritten(baseCheckpoint(workdir, runId), plan);
534
+ cp = { ...cp, nextAction: "accept-execute" };
535
+ cp = applyExecutionApproved(cp, { plan, worktree: cp.worktreeRoot, headAtApproval: null, approvedAt: cp.updatedAt });
536
+ return cp;
537
+ }
538
+
539
+ const BLOCKED = {
540
+ rolledBack: ["Task-2", "Task-2.1", "Task-7"],
541
+ tasks: ["Task-2"],
542
+ round: 1,
543
+ escalatedRounds: 0,
544
+ since: "2026-10-02T10:32:00Z",
545
+ };
546
+
547
+ it("round-trips the blocker and its escalation counter", () => {
548
+ const { workdir, runId } = setupRun("blocked-roundtrip");
549
+ const cp = applyExecutionProgress(executingCheckpoint(workdir, runId), { blocked: BLOCKED });
550
+ mutateCheckpoint(workdir, runId, () => cp);
551
+ const loaded = loadCheckpoint(workdir, runId);
552
+ assert.ok(loaded.status === "ok");
553
+ assert.deepEqual(loaded.checkpoint.execution?.blocked, BLOCKED);
554
+ });
555
+
556
+ it("null clears the blocker (delete-on-null, like pausedReason)", () => {
557
+ const { workdir, runId } = setupRun("blocked-null");
558
+ let cp = applyExecutionProgress(executingCheckpoint(workdir, runId), { blocked: BLOCKED });
559
+ cp = applyExecutionProgress(cp, { blocked: null });
560
+ assert.equal(cp.execution?.blocked, undefined);
561
+ mutateCheckpoint(workdir, runId, () => cp);
562
+ const loaded = loadCheckpoint(workdir, runId);
563
+ assert.ok(loaded.status === "ok");
564
+ assert.equal(loaded.checkpoint.execution?.blocked, undefined);
565
+ });
566
+
567
+ it("loads a checkpoint written before the field existed", () => {
568
+ const { workdir, runId } = setupRun("blocked-legacy");
569
+ const cp = executingCheckpoint(workdir, runId);
570
+ mutateCheckpoint(workdir, runId, () => cp);
571
+ const loaded = loadCheckpoint(workdir, runId);
572
+ assert.ok(loaded.status === "ok", "legacy checkpoint still loads");
573
+ assert.equal(loaded.checkpoint.execution?.blocked, undefined);
574
+ });
575
+
576
+ it("rejects unknown keys inside the blocker record", () => {
577
+ const { workdir, runId } = setupRun("blocked-bad-record");
578
+ const cp = applyExecutionProgress(executingCheckpoint(workdir, runId), {
579
+ blocked: { ...BLOCKED, bogus: true } as never,
580
+ });
581
+ assert.throws(() => mutateCheckpoint(workdir, runId, () => cp), /blocked: unexpected key/);
582
+ });
583
+
584
+ it("a stop revokes the blocker (stale rollback authorization)", () => {
585
+ const { workdir, runId } = setupRun("blocked-stopped");
586
+ const cp = applyExecutionProgress(executingCheckpoint(workdir, runId), { blocked: BLOCKED });
587
+ const stopped = applyExecutionStopped(cp, "user stop");
588
+ assert.equal(stopped.execution?.blocked, undefined);
589
+ assert.equal(stopped.execution?.pausedReason, "user stop");
590
+ });
591
+
592
+ it("completion clears the blocker", () => {
593
+ const { workdir, runId } = setupRun("blocked-completed");
594
+ const cp = applyExecutionProgress(executingCheckpoint(workdir, runId), { blocked: BLOCKED });
595
+ assert.equal(applyExecutionCompleted(cp).execution?.blocked, undefined);
596
+ });
597
+ });
598
+
599
+ describe("review-budget persistence (v0.9.3)", () => {
600
+ function executingCheckpoint(workdir: string, runId: string) {
601
+ createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
602
+ const planPath = path.join(workdir, "PLAN_v1.md");
603
+ fs.writeFileSync(planPath, "# PLAN_v1 - budget demo\n", "utf8");
604
+ const plan = planIdentityOf(planPath, 1);
605
+ let cp = applyPlanWritten(baseCheckpoint(workdir, runId), plan);
606
+ cp = { ...cp, nextAction: "accept-execute" };
607
+ cp = applyExecutionApproved(cp, { plan, worktree: cp.worktreeRoot, headAtApproval: null, approvedAt: cp.updatedAt });
608
+ return cp;
609
+ }
610
+
611
+ const BUDGET = {
612
+ reviewBudget: "unlimited" as const,
613
+ reviewBudgetDefaulted: true,
614
+ reviewRoundsTotal: 7,
615
+ reviewCapExtension: 50,
616
+ reviewNoProgress: { key: "VC-002|VC-001|F-001", streak: 2 },
617
+ };
618
+
619
+ it("round-trips the budget, its counters, and the no-progress state", () => {
620
+ const { workdir, runId } = setupRun("budget-roundtrip");
621
+ const cp = applyExecutionProgress(executingCheckpoint(workdir, runId), BUDGET);
622
+ mutateCheckpoint(workdir, runId, () => cp);
623
+ const loaded = loadCheckpoint(workdir, runId);
624
+ assert.ok(loaded.status === "ok");
625
+ assert.equal(loaded.checkpoint.execution?.reviewBudget, "unlimited");
626
+ assert.equal(loaded.checkpoint.execution?.reviewBudgetDefaulted, true);
627
+ assert.equal(loaded.checkpoint.execution?.reviewRoundsTotal, 7);
628
+ assert.equal(loaded.checkpoint.execution?.reviewCapExtension, 50);
629
+ assert.deepEqual(loaded.checkpoint.execution?.reviewNoProgress, { key: "VC-002|VC-001|F-001", streak: 2 });
630
+ });
631
+
632
+ it("accepts a numeric budget and rejects malformed values with the key name", () => {
633
+ const { workdir, runId } = setupRun("budget-validation");
634
+ const base = executingCheckpoint(workdir, runId);
635
+ assert.equal(applyExecutionProgress(base, { reviewBudget: 1 }).execution?.reviewBudget, 1);
636
+ // The write path stores what it is given (like every other progress
637
+ // field); the READ path validates and names the offending key.
638
+ for (const bad of [0, -1, 2.5, true, { rounds: 3 }]) {
639
+ const cp = applyExecutionProgress(base, { reviewBudget: bad as never });
640
+ assert.throws(
641
+ () => mutateCheckpoint(workdir, runId, () => cp),
642
+ /reviewBudget/,
643
+ `reviewBudget ${JSON.stringify(bad)} rejected`,
644
+ );
645
+ }
646
+ assert.throws(() => mutateCheckpoint(workdir, runId, () => applyExecutionProgress(base, { reviewBudget: "infinite" as never })), /reviewBudget/);
647
+ assert.throws(() => mutateCheckpoint(workdir, runId, () => applyExecutionProgress(base, { reviewRoundsTotal: -1 })), /reviewRoundsTotal/);
648
+ assert.throws(() => mutateCheckpoint(workdir, runId, () => applyExecutionProgress(base, { reviewCapExtension: -3 })), /reviewCapExtension/);
649
+ assert.throws(
650
+ () => mutateCheckpoint(workdir, runId, () => applyExecutionProgress(base, { reviewNoProgress: { key: "x" } as never })),
651
+ /reviewNoProgress/,
652
+ );
653
+ assert.throws(
654
+ () => mutateCheckpoint(workdir, runId, () => applyExecutionProgress(base, { reviewNoProgress: { key: "x", streak: -1 } })),
655
+ /reviewNoProgress.streak/,
656
+ );
657
+ });
658
+
659
+ it("null clears the budget and the valve state (delete-on-null)", () => {
660
+ const { workdir, runId } = setupRun("budget-null");
661
+ let cp = applyExecutionProgress(executingCheckpoint(workdir, runId), BUDGET);
662
+ cp = applyExecutionProgress(cp, { reviewBudget: null, reviewBudgetDefaulted: null, reviewNoProgress: null });
663
+ assert.equal(cp.execution?.reviewBudget, undefined);
664
+ assert.equal(cp.execution?.reviewBudgetDefaulted, undefined);
665
+ assert.equal(cp.execution?.reviewNoProgress, undefined);
666
+ mutateCheckpoint(workdir, runId, () => cp);
667
+ const loaded = loadCheckpoint(workdir, runId);
668
+ assert.ok(loaded.status === "ok");
669
+ assert.equal(loaded.checkpoint.execution?.reviewBudget, undefined);
670
+ assert.equal(loaded.checkpoint.execution?.reviewNoProgress, undefined);
671
+ });
672
+
673
+ it("loads a checkpoint written before the fields existed (absent, not corrupt)", () => {
674
+ const { workdir, runId } = setupRun("budget-legacy");
675
+ const cp = applyExecutionProgress(executingCheckpoint(workdir, runId), { reviewBudget: null });
676
+ mutateCheckpoint(workdir, runId, () => cp);
677
+ const raw = JSON.parse(fs.readFileSync(checkpointFilePath(workdir, runId), "utf8"));
678
+ assert.equal(raw.execution.reviewBudget, undefined);
679
+ const loaded = loadCheckpoint(workdir, runId);
680
+ assert.ok(loaded.status === "ok");
681
+ assert.equal(loaded.checkpoint.execution?.reviewBudget, undefined);
682
+ // The live state resolves a legacy shape to the 5-round bound; that
683
+ // resolution is exercised in tests/review-budget.test.ts.
684
+ });
685
+
686
+ it("a stop and a completion both drop the budget and its valve state", () => {
687
+ const { workdir, runId } = setupRun("budget-cleanup");
688
+ const withBudget = applyExecutionProgress(executingCheckpoint(workdir, runId), BUDGET);
689
+ const stopped = applyExecutionStopped(withBudget, "user stop");
690
+ assert.equal(stopped.execution?.reviewBudget, undefined);
691
+ assert.equal(stopped.execution?.reviewRoundsTotal, undefined);
692
+ assert.equal(stopped.execution?.reviewCapExtension, undefined);
693
+ assert.equal(stopped.execution?.reviewNoProgress, undefined);
694
+ const completed = applyExecutionCompleted(withBudget);
695
+ assert.equal(completed.execution?.reviewBudget, undefined);
696
+ assert.equal(completed.execution?.reviewRoundsTotal, undefined);
697
+ assert.equal(completed.execution?.reviewNoProgress, undefined);
698
+ });
699
+
700
+ it("a migration preserves the budget but resets the invalidated execution state", () => {
701
+ const { workdir, runId } = setupRun("budget-migration");
702
+ const migrated = applyMigration(applyExecutionProgress(executingCheckpoint(workdir, runId), BUDGET), {
703
+ workdir,
704
+ worktreeRoot: workdir,
705
+ commonDir: path.join(workdir, ".git"),
706
+ });
707
+ assert.equal(migrated.execution?.reviewBudget, "unlimited");
708
+ assert.equal(migrated.execution?.reviewRoundsTotal, 7);
709
+ assert.deepEqual(migrated.execution?.tasks, {}, "task progress still does not migrate");
710
+ assert.equal(migrated.execution?.audit?.rounds, 0, "audit rounds still restart at 0");
711
+ });
712
+ });
@@ -157,19 +157,26 @@ export async function executeCommand(ctx: ExtensionContext, planPathArg?: string
157
157
  const activeExecution = getExecution();
158
158
  const planPath = planPathArg ? path.resolve(ctx.cwd, planPathArg.replace(/^@/, "")) : activeExecution?.planPath;
159
159
  if (activeExecution && planPath && path.resolve(activeExecution.planPath) === path.resolve(planPath)) {
160
- const resumed = resumeActiveExecution(ctx);
160
+ // v0.9.3: the resume is async because a review-pause grant re-opens the
161
+ // budget picker; the message below is produced AFTER that panel resolves.
162
+ const resumed = await resumeActiveExecution(ctx);
161
163
  // v0.8 phase-aware response: a verifying run continues its review loop
162
- // (this tool is also the ONLY budget-granting surface at a cap pause).
164
+ // (this tool is also the ONLY budget-granting surface at a review pause).
163
165
  const statusText = activeExecution.review?.inFlight
164
166
  ? "Execution review in progress (status verifying); the reviewer round runs in the overlay."
165
167
  : (getExecution()?.stall.paused ?? false)
166
- ? "Execution review paused at the round cap — this confirmation granted a fresh five-round budget; the review resumes now."
167
- : "Execution resumed; task progress preserved.";
168
+ ? "Execution review paused — run /plans-execute and pick a round budget to continue."
169
+ : resumed.grantedBudget !== undefined
170
+ ? `Review budget granted (${resumed.grantedBudget === "unlimited" ? "unlimited" : `${resumed.grantedBudget} rounds`}); the review resumes now.`
171
+ : "Execution resumed; task progress preserved.";
172
+ const declined = resumed.budgetDeclined === true;
168
173
  return {
169
174
  status: "executing",
170
175
  planPath,
171
176
  itemCount: activeExecution.items.length,
172
- message: resumed ? statusText : "This plan is already executing.",
177
+ message: declined
178
+ ? "Review budget unchanged — the run stays paused. Call /plans-execute again and pick a round count to continue."
179
+ : resumed.resumed ? statusText : "This plan is already executing.",
173
180
  };
174
181
  }
175
182
  return executeHandoff(ctx, planPathArg);