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.
@@ -29,6 +29,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
29
29
  import * as path from "node:path";
30
30
  import { StateError, atomicWriteJson, resolveStateRootOrNull, runGit, runDirPath, utcNow } from "./state.ts";
31
31
  import { assertOwnership, heldOwnershipRecord } from "./run-ownership.ts";
32
+ import type { NoProgressState, ReviewBudget } from "./review-budget.ts";
32
33
 
33
34
  // ---------------------------------------------------------------------------
34
35
  // Schema
@@ -133,6 +134,21 @@ export interface ExecutionApproval {
133
134
  approvedAt: string;
134
135
  }
135
136
 
137
+ /** v0.9.2: the newest failed execution-review round's rollback set plus the
138
+ * tasks among it that are still open. Captured when the round commits (the
139
+ * reopen helpers mutate the tree and report only flipped nodes, so a later
140
+ * re-derivation cannot rebuild this set) and persisted so wakes, the pause
141
+ * reason, the dashboard, and resume briefs can name the blocker after a
142
+ * restart. `escalatedRounds` counts consecutive blocked wakes — the escalation
143
+ * ladder must not ride `stallRounds`, which any successful tool call resets. */
144
+ export interface ExecutionBlocked {
145
+ rolledBack: string[];
146
+ tasks: string[];
147
+ round: number;
148
+ escalatedRounds: number;
149
+ since: string;
150
+ }
151
+
136
152
  export interface ExecutionCheckpoint {
137
153
  approval: ExecutionApproval | null;
138
154
  doneVcIds: string[];
@@ -156,6 +172,29 @@ export interface ExecutionCheckpoint {
156
172
  * silently hand a stalled run a fresh budget; optional so checkpoints
157
173
  * written before this field keep loading. */
158
174
  stallRounds?: number;
175
+ /** v0.9.2: outstanding blocker from the newest failed review round (see
176
+ * `ExecutionBlocked`); absent = nothing blocks the review. Optional on
177
+ * read so checkpoints written before it keep loading. */
178
+ blocked?: ExecutionBlocked | null;
179
+ /** v0.9.3: the per-run execution-review budget (committed rounds, or
180
+ * `"unlimited"`). Absent = not decided yet (the picker runs before round
181
+ * 1) OR written by a pre-v0.9.3 build, in which case a checkpoint that
182
+ * already spent rounds keeps the legacy 5-round bound
183
+ * (`resolveStoredBudget` in ./review-budget.ts). */
184
+ reviewBudget?: ReviewBudget;
185
+ /** v0.9.3: the budget above was the no-UI fallback, not a user pick — the
186
+ * dashboard/resume brief annotate it `(default)`. Optional on read. */
187
+ reviewBudgetDefaulted?: boolean;
188
+ /** v0.9.3: committed review rounds across the WHOLE run (never reset by a
189
+ * grant) — bounds the unlimited budget together with `reviewCapExtension`. */
190
+ reviewRoundsTotal?: number;
191
+ /** v0.9.3: explicit `/plans-execute` grants lifted the unlimited hard cap
192
+ * by this many rounds (each grant that lands on `unlimited` adds
193
+ * `UNLIMITED_HARD_CAP`). */
194
+ reviewCapExtension?: number;
195
+ /** v0.9.3: no-progress valve state for the unlimited budget (signature of
196
+ * the last committed round's outcome plus its consecutive streak). */
197
+ reviewNoProgress?: NoProgressState | null;
159
198
  /** v0.9.1 (F-002): set when the execution-review loop mechanically
160
199
  * appended finding tasks to the approved plan. The checkpoint's plan
161
200
  * identity is re-stamped at that moment so /resume-plans accepts the
@@ -297,6 +336,13 @@ function asInt(value: unknown, label: string, min: number): number {
297
336
  return value;
298
337
  }
299
338
 
339
+ /** v0.9.3: a positive integer round count or the literal "unlimited". */
340
+ function asReviewBudget(value: unknown, label: string): ReviewBudget {
341
+ if (value === "unlimited") return "unlimited";
342
+ if (typeof value === "number" && Number.isSafeInteger(value) && value >= 1) return value;
343
+ throw new CheckpointValidationError(`${label}: expected a positive integer or "unlimited"`);
344
+ }
345
+
300
346
  function asEnum<T extends string>(value: unknown, allowed: Set<T>, label: string): T {
301
347
  const text = asString(value, label);
302
348
  if (!allowed.has(text as T)) {
@@ -493,7 +539,7 @@ function asExecution(value: unknown, label: string): ExecutionCheckpoint {
493
539
  const record = asRecord(value, label);
494
540
  rejectExtraKeys(
495
541
  record,
496
- new Set(["approval", "doneVcIds", "implStatus", "currentI", "usage", "pausedReason", "reverifyAll", "originWorktree", "delegate", "tasks", "stallRounds", "planAmended", "audit"]),
542
+ new Set(["approval", "doneVcIds", "implStatus", "currentI", "usage", "pausedReason", "reverifyAll", "originWorktree", "delegate", "tasks", "stallRounds", "blocked", "planAmended", "audit", "reviewBudget", "reviewBudgetDefaulted", "reviewRoundsTotal", "reviewCapExtension", "reviewNoProgress"]),
497
543
  label,
498
544
  );
499
545
  const execution: ExecutionCheckpoint = {
@@ -513,6 +559,17 @@ function asExecution(value: unknown, label: string): ExecutionCheckpoint {
513
559
  };
514
560
  if (record.currentI !== undefined) execution.currentI = asString(record.currentI, `${label}.currentI`);
515
561
  if (record.pausedReason !== undefined) execution.pausedReason = asString(record.pausedReason, `${label}.pausedReason`);
562
+ if (record.blocked !== undefined && record.blocked !== null) {
563
+ const blocked = asRecord(record.blocked, `${label}.blocked`);
564
+ rejectExtraKeys(blocked, new Set(["rolledBack", "tasks", "round", "escalatedRounds", "since"]), `${label}.blocked`);
565
+ execution.blocked = {
566
+ rolledBack: asStringArray(blocked.rolledBack, `${label}.blocked.rolledBack`),
567
+ tasks: asStringArray(blocked.tasks, `${label}.blocked.tasks`),
568
+ round: asInt(blocked.round, `${label}.blocked.round`, 0),
569
+ escalatedRounds: asInt(blocked.escalatedRounds, `${label}.blocked.escalatedRounds`, 0),
570
+ since: asTimestamp(blocked.since, `${label}.blocked.since`),
571
+ };
572
+ }
516
573
  if (record.reverifyAll !== undefined) execution.reverifyAll = asBool(record.reverifyAll, `${label}.reverifyAll`);
517
574
  if (record.originWorktree !== undefined) execution.originWorktree = asString(record.originWorktree, `${label}.originWorktree`);
518
575
  if (record.delegate !== undefined && record.delegate !== null) {
@@ -549,6 +606,30 @@ function asExecution(value: unknown, label: string): ExecutionCheckpoint {
549
606
  round: asInt(rec.round, `${label}.planAmended.round`, 0),
550
607
  };
551
608
  }
609
+ // v0.9.3 review budget. `reviewBudget` accepts a positive integer or the
610
+ // literal "unlimited"; the counters are non-negative integers; the
611
+ // no-progress record is a small fixed shape. All optional on read so
612
+ // pre-feature checkpoints keep loading.
613
+ if (record.reviewBudget !== undefined && record.reviewBudget !== null) {
614
+ execution.reviewBudget = asReviewBudget(record.reviewBudget, `${label}.reviewBudget`);
615
+ }
616
+ if (record.reviewBudgetDefaulted !== undefined) {
617
+ execution.reviewBudgetDefaulted = asBool(record.reviewBudgetDefaulted, `${label}.reviewBudgetDefaulted`);
618
+ }
619
+ if (record.reviewRoundsTotal !== undefined && record.reviewRoundsTotal !== null) {
620
+ execution.reviewRoundsTotal = asInt(record.reviewRoundsTotal, `${label}.reviewRoundsTotal`, 0);
621
+ }
622
+ if (record.reviewCapExtension !== undefined && record.reviewCapExtension !== null) {
623
+ execution.reviewCapExtension = asInt(record.reviewCapExtension, `${label}.reviewCapExtension`, 0);
624
+ }
625
+ if (record.reviewNoProgress !== undefined && record.reviewNoProgress !== null) {
626
+ const np = asRecord(record.reviewNoProgress, `${label}.reviewNoProgress`);
627
+ rejectExtraKeys(np, new Set(["key", "streak"]), `${label}.reviewNoProgress`);
628
+ execution.reviewNoProgress = {
629
+ key: asString(np.key, `${label}.reviewNoProgress.key`),
630
+ streak: asInt(np.streak, `${label}.reviewNoProgress.streak`, 0),
631
+ };
632
+ }
552
633
  if (record.audit !== undefined && record.audit !== null) {
553
634
  const audit = asRecord(record.audit, `${label}.audit`);
554
635
  rejectExtraKeys(audit, new Set(["rounds", "lastResult", "passed", "undeterminable", "findings"]), `${label}.audit`);
@@ -1141,6 +1222,20 @@ export interface ExecutionProgressInput {
1141
1222
  audit?: { rounds: number; lastResult?: string; passed?: boolean; undeterminable?: string[]; findings?: ReviewFindingRecord[] };
1142
1223
  /** v0.7.1: watchdog budget counter, so a restart cannot refresh it. */
1143
1224
  stallRounds?: number;
1225
+ /** v0.9.2: blocker record; null clears it (delete-on-null, like
1226
+ * `pausedReason`/`delegate`). */
1227
+ blocked?: ExecutionBlocked | null;
1228
+ /** v0.9.3: per-run execution-review budget; null clears it (delete-on-null,
1229
+ * used by the stop/complete cleanup paths). */
1230
+ reviewBudget?: ReviewBudget | null;
1231
+ /** v0.9.3: whether `reviewBudget` came from the no-UI fallback. */
1232
+ reviewBudgetDefaulted?: boolean | null;
1233
+ /** v0.9.3: committed rounds across the whole run (monotonic). */
1234
+ reviewRoundsTotal?: number;
1235
+ /** v0.9.3: explicit unlimited-cap extension accumulated by grants. */
1236
+ reviewCapExtension?: number;
1237
+ /** v0.9.3: no-progress valve state; null clears it (delete-on-null). */
1238
+ reviewNoProgress?: NoProgressState | null;
1144
1239
  }
1145
1240
 
1146
1241
  export function applyExecutionProgress(cp: WorkflowCheckpoint, progress: ExecutionProgressInput): WorkflowCheckpoint {
@@ -1161,6 +1256,16 @@ export function applyExecutionProgress(cp: WorkflowCheckpoint, progress: Executi
1161
1256
  else if (progress.delegate !== undefined) execution.delegate = progress.delegate;
1162
1257
  if (progress.tasks !== undefined) execution.tasks = progress.tasks;
1163
1258
  if (progress.stallRounds !== undefined) execution.stallRounds = progress.stallRounds;
1259
+ if (progress.blocked === null) delete execution.blocked;
1260
+ else if (progress.blocked !== undefined) execution.blocked = progress.blocked;
1261
+ if (progress.reviewBudget === null) delete execution.reviewBudget;
1262
+ else if (progress.reviewBudget !== undefined) execution.reviewBudget = progress.reviewBudget;
1263
+ if (progress.reviewBudgetDefaulted === null) delete execution.reviewBudgetDefaulted;
1264
+ else if (progress.reviewBudgetDefaulted !== undefined) execution.reviewBudgetDefaulted = progress.reviewBudgetDefaulted;
1265
+ if (progress.reviewRoundsTotal !== undefined) execution.reviewRoundsTotal = progress.reviewRoundsTotal;
1266
+ if (progress.reviewCapExtension !== undefined) execution.reviewCapExtension = progress.reviewCapExtension;
1267
+ if (progress.reviewNoProgress === null) delete execution.reviewNoProgress;
1268
+ else if (progress.reviewNoProgress !== undefined) execution.reviewNoProgress = progress.reviewNoProgress;
1164
1269
  if (progress.audit !== undefined) execution.audit = progress.audit;
1165
1270
  return { ...cp, execution };
1166
1271
  }
@@ -1192,14 +1297,25 @@ export function applyExecutionCompleted(cp: WorkflowCheckpoint): WorkflowCheckpo
1192
1297
  if (cp.phase !== "executing" || !cp.execution) throw new StateError("requires phase \"executing\"");
1193
1298
  // v0.6.1 (D-018): the post-execution amelioration loop is gone; a
1194
1299
  // completed audit passes the run straight to the terminal phase.
1300
+ // v0.9.3: the review budget (and its valve state) describes a review that
1301
+ // no longer runs — completed runs do not carry it.
1302
+ const {
1303
+ reviewBudget: _reviewBudget,
1304
+ reviewBudgetDefaulted: _reviewBudgetDefaulted,
1305
+ reviewRoundsTotal: _reviewRoundsTotal,
1306
+ reviewCapExtension: _reviewCapExtension,
1307
+ reviewNoProgress: _reviewNoProgress,
1308
+ ...execution
1309
+ } = cp.execution;
1195
1310
  return {
1196
1311
  ...cp,
1197
1312
  phase: "completed",
1198
1313
  nextAction: "none",
1199
1314
  execution: {
1200
- ...cp.execution,
1315
+ ...execution,
1201
1316
  pausedReason: undefined,
1202
1317
  delegate: undefined,
1318
+ blocked: undefined,
1203
1319
  audit: { ...(cp.execution.audit ?? { rounds: 0 }), passed: true },
1204
1320
  },
1205
1321
  };
@@ -1230,6 +1346,14 @@ export function applyMigration(
1230
1346
  // migration (same rule as VC validity).
1231
1347
  tasks: {},
1232
1348
  audit: { rounds: 0 },
1349
+ // v0.9.3 (F-005): the review budget and its counters ARE carried
1350
+ // across a migration — they describe the user's choice and the
1351
+ // run's cumulative spend, not the (invalidated) code state.
1352
+ reviewBudget: cp.execution.reviewBudget,
1353
+ reviewBudgetDefaulted: cp.execution.reviewBudgetDefaulted,
1354
+ reviewRoundsTotal: cp.execution.reviewRoundsTotal,
1355
+ reviewCapExtension: cp.execution.reviewCapExtension,
1356
+ reviewNoProgress: cp.execution.reviewNoProgress ?? undefined,
1233
1357
  }
1234
1358
  : undefined,
1235
1359
  implementationReview: cp.implementationReview
@@ -1253,7 +1377,18 @@ function migrationNextAction(cp: WorkflowCheckpoint): NextAction {
1253
1377
  /** Mark a paused stop without erasing the last phase (D-008). */
1254
1378
  export function applyExecutionStopped(cp: WorkflowCheckpoint, reason: string): WorkflowCheckpoint {
1255
1379
  if (cp.phase !== "executing" || !cp.execution) throw new StateError("requires phase \"executing\"");
1256
- const { delegate: _delegate, ...execution } = cp.execution;
1380
+ // v0.9.3: a stop also drops the review budget and its valve state — the
1381
+ // next execution of this plan picks its own budget.
1382
+ const {
1383
+ delegate: _delegate,
1384
+ blocked: _blocked,
1385
+ reviewBudget: _reviewBudget,
1386
+ reviewBudgetDefaulted: _reviewBudgetDefaulted,
1387
+ reviewRoundsTotal: _reviewRoundsTotal,
1388
+ reviewCapExtension: _reviewCapExtension,
1389
+ reviewNoProgress: _reviewNoProgress,
1390
+ ...execution
1391
+ } = cp.execution;
1257
1392
  // A stop also revokes any outstanding audit-rollback authorization.
1258
1393
  const audit = execution.audit ? { ...execution.audit } : undefined;
1259
1394
  return { ...cp, execution: { ...execution, audit, pausedReason: reason } };
@@ -186,6 +186,9 @@ describe("width invariant (TUI crash regression)", () => {
186
186
  ["paused", { ...base, paused: true, pausedReason: base.pausedReason as string }],
187
187
  ["all-terminal", { ...base, tasks: base.tasks.map((t) => ({ ...t, status: "complete" as const })) }],
188
188
  ["audit-failed", { ...base, auditRounds: 3, auditFailed: ["VC-1", "VC-2"] }],
189
+ // v0.9.2: the blocker row must keep the exact-width invariant too.
190
+ ["blocked", { ...base, blockedTasks: ["Task-1", "Task-3", "Task-3.1"], blockedRound: 1 }],
191
+ ["blocked-paused", { ...base, paused: true, pausedReason: "blocked: review round 2 cannot start — 1 task(s) reopened by round 1 still open (Task-3)", blockedTasks: ["Task-3"], blockedRound: 1 }],
189
192
  ];
190
193
  };
191
194
 
@@ -466,3 +469,98 @@ describe("findings visibility (v0.9)", () => {
466
469
  assert.ok(lines.some((l) => /high findings unresolved: F-001/.test(l)));
467
470
  });
468
471
  });
472
+
473
+ describe("blocked-review visibility (v0.9.2)", () => {
474
+ function withBlocker(extra: { paused?: boolean; pausedReason?: string } = {}) {
475
+ const tasks = buildTaskView(parsePlanTasks(PLAN), {});
476
+ const checklist = [{ id: "VC-001", text: "`VC-001` covers `Task-1`; pass condition: x", done: false }];
477
+ return deriveDashboardModel("demo-run", tasks, checklist, {
478
+ startedAt: new Date().toISOString(),
479
+ auditRounds: 1,
480
+ auditFailed: ["VC-002"],
481
+ blockedTasks: ["Task-3", "Task-3.1"],
482
+ blockedRound: 1,
483
+ ...extra,
484
+ });
485
+ }
486
+
487
+ it("compact panel names the blocker and what to do about it", () => {
488
+ const lines = renderDashboardLines(withBlocker(), 100);
489
+ assert.ok(lines.some((l) => /⊘ blocked: Task-3, Task-3\.1 \(reopened by round 1\) — close with plans_update_task/.test(l)));
490
+ // Narrow widths keep the ids and drop the instruction.
491
+ const narrow = renderDashboardLines(withBlocker(), 40);
492
+ assert.ok(narrow.some((l) => /⊘ blocked: Task-3, Task-3\.1/.test(l)));
493
+ });
494
+
495
+ it("the blocker row survives a paused run (the pause summary may be clipped)", () => {
496
+ const lines = renderDashboardLines(withBlocker({ paused: true, pausedReason: "blocked: review round 2 cannot start — 2 task(s) reopened by round 1 still open" }), 100);
497
+ assert.ok(lines.some((l) => /⏸ blocked: review round 2 cannot start/.test(l)));
498
+ assert.ok(lines.some((l) => /⊘ blocked: Task-3/.test(l)), "blocker row still lists the tasks");
499
+ });
500
+
501
+ it("the summary line carries the blocked token only when blockers exist", () => {
502
+ assert.match(formatDashboardSummaryLine(withBlocker()), / · ⊘ blocked$/);
503
+ const tasks = buildTaskView(parsePlanTasks(PLAN), {});
504
+ const clean = deriveDashboardModel("demo-run", tasks, [], { startedAt: new Date().toISOString(), auditRounds: 1 });
505
+ assert.doesNotMatch(formatDashboardSummaryLine(clean), /blocked/);
506
+ });
507
+ });
508
+
509
+ describe("review-budget visibility (v0.9.3)", () => {
510
+ function withBudget(extra: Record<string, unknown> = {}) {
511
+ const tasks = buildTaskView(parsePlanTasks(PLAN), {});
512
+ const checklist = [{ id: "VC-001", text: "`VC-001` covers `Task-1`; pass condition: x", done: false }];
513
+ return deriveDashboardModel("demo-run", tasks, checklist, {
514
+ startedAt: new Date().toISOString(),
515
+ auditRounds: 2,
516
+ ...extra,
517
+ });
518
+ }
519
+
520
+ it("the summary line renders the chosen budget denominator", () => {
521
+ assert.match(formatDashboardSummaryLine(withBudget({ reviewBudget: 3 })), / · review r2\/3/);
522
+ assert.match(formatDashboardSummaryLine(withBudget({ reviewBudget: "unlimited" })), / · review r2\/∞/);
523
+ // A model without the field (legacy fixtures/checkpoints) reads as 5.
524
+ assert.match(formatDashboardSummaryLine(withBudget()), / · review r2\/5/);
525
+ });
526
+
527
+ it("the compact panel renders the budget denominator too", () => {
528
+ // The review row only renders once every task is terminal.
529
+ const terminal = (extra: Record<string, unknown>) => {
530
+ const tasks = buildTaskView(parsePlanTasks(PLAN), {
531
+ "Task-1": { status: "complete" },
532
+ "Task-2": { status: "complete" },
533
+ "Task-3": { status: "complete" },
534
+ "Task-3.1": { status: "complete" },
535
+ "Task-3.2": { status: "complete" },
536
+ });
537
+ const checklist = [{ id: "VC-001", text: "`VC-001` covers `Task-1`; pass condition: x", done: false }];
538
+ return deriveDashboardModel("demo-run", tasks, checklist, { startedAt: new Date().toISOString(), auditRounds: 2, ...extra });
539
+ };
540
+ const lines = renderDashboardLines(terminal({ reviewBudget: 3 }), 100);
541
+ assert.ok(lines.some((l) => /review: round 3\/3/.test(l)), "the numeric budget renders");
542
+ const unlimited = renderDashboardLines(terminal({ reviewBudget: "unlimited" }), 100);
543
+ assert.ok(unlimited.some((l) => /review: round 3\/∞/.test(l)), "the unlimited budget renders");
544
+ });
545
+
546
+ it("the expanded view carries the default annotation and the unlimited hard-cap progress", () => {
547
+ const defaulted = renderDashboardTreeLines(withBudget({ reviewBudget: 3, reviewBudgetDefaulted: true }), 120);
548
+ assert.ok(defaulted.some((l) => /Execution review: round 2\/3 \(default\)/.test(l)));
549
+ const userPicked = renderDashboardTreeLines(withBudget({ reviewBudget: 3 }), 120);
550
+ assert.ok(userPicked.some((l) => /Execution review: round 2\/3/.test(l)));
551
+ assert.equal(userPicked.some((l) => /\(default\)/.test(l)), false, "a user pick carries no annotation");
552
+ const unlimited = renderDashboardTreeLines(
553
+ withBudget({ reviewBudget: "unlimited", reviewRoundsTotal: 12, reviewCapExtension: 50 }),
554
+ 120,
555
+ );
556
+ assert.ok(unlimited.some((l) => /Execution review: round 2\/∞ · cap 12\/100/.test(l)), "the run-cumulative cap progress renders");
557
+ });
558
+
559
+ it("keeps the width invariant with the budget row present", () => {
560
+ const lines = renderDashboardLines(withBudget({ reviewBudget: "unlimited", reviewBudgetDefaulted: true }), 60);
561
+ for (const line of lines) {
562
+ assert.equal(localVisibleWidth(line), localVisibleWidth(line));
563
+ assert.ok(visibleWidth(line) <= 60, `line exceeds 60 columns: ${line}`);
564
+ }
565
+ });
566
+ });