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/src/workflow-state.ts
CHANGED
|
@@ -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
|
-
...
|
|
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
|
-
|
|
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 } };
|
package/tests/dashboard.test.ts
CHANGED
|
@@ -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
|
+
});
|