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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/references/pi-planning-workflow.md +13 -6
- package/references/state-and-config.md +1 -1
- package/src/auditor.ts +12 -5
- package/src/dashboard.ts +60 -9
- package/src/exec.ts +596 -43
- package/src/resume-command.ts +20 -6
- package/src/review-budget.ts +290 -0
- package/src/tasks.ts +53 -0
- package/src/ui-language.ts +38 -0
- package/src/workflow-state.ts +138 -3
- package/tests/dashboard.test.ts +98 -0
- package/tests/exec-review-loop.test.ts +632 -4
- package/tests/exec.test.ts +32 -5
- package/tests/fixtures/lattice-code-blocked/plan-v2-trimmed.md +57 -0
- package/tests/fixtures/lattice-code-blocked/state.json +158 -0
- package/tests/resume.test.ts +45 -1
- package/tests/review-budget.test.ts +201 -0
- package/tests/tasks.test.ts +45 -0
- package/tests/workflow-state.test.ts +187 -0
- package/tools/execute-plan.ts +12 -5
package/tests/tasks.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/tools/execute-plan.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
167
|
-
:
|
|
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:
|
|
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);
|