pi-plans 0.8.0 → 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/src/refine-ui.ts CHANGED
@@ -183,10 +183,15 @@ export class RefineOverlayComponent implements Component {
183
183
  private readonly modelLabel?: string;
184
184
  /** Chrome language (issue #3); defaults to English for direct construction. */
185
185
  private readonly lang: UiLanguage;
186
+ /** v0.8.1: pi-tui routes input ONLY to the focused component (no
187
+ * bubbling), so an open overlay otherwise swallows every global shortcut
188
+ * — including the Ctrl+Shift+T dashboard toggle users expect to work while
189
+ * watching a review/refine overlay. Unhandled keys are forwarded here. */
190
+ private readonly onUnhandledKey?: (data: string) => void;
186
191
  private selectedLane = 0;
187
192
  private disposed = false;
188
193
 
189
- constructor(theme: Theme, role: RefineOverlayRole, lanes: RefineLaneState[], onCancel: () => void, tui?: TUI, modelLabel?: string, lang: UiLanguage = "en") {
194
+ constructor(theme: Theme, role: RefineOverlayRole, lanes: RefineLaneState[], onCancel: () => void, tui?: TUI, modelLabel?: string, lang: UiLanguage = "en", onUnhandledKey?: (data: string) => void) {
190
195
  this.theme = theme;
191
196
  this.role = role;
192
197
  this.lanes = lanes;
@@ -194,6 +199,7 @@ export class RefineOverlayComponent implements Component {
194
199
  this.tui = tui;
195
200
  this.modelLabel = modelLabel;
196
201
  this.lang = lang;
202
+ this.onUnhandledKey = onUnhandledKey;
197
203
  this.tui?.terminal?.write?.("\x1b[?1000h\x1b[?1006h");
198
204
  }
199
205
 
@@ -214,7 +220,10 @@ export class RefineOverlayComponent implements Component {
214
220
  return;
215
221
  }
216
222
  const lane = this.lanes[this.selectedLane];
217
- if (!lane) return;
223
+ if (!lane) {
224
+ this.onUnhandledKey?.(data);
225
+ return;
226
+ }
218
227
  const viewport = Math.max(1, lane.viewportHeight ?? 1);
219
228
  if (matchesTerminalKey(data, "up")) lane.scrollOffset -= 1;
220
229
  else if (matchesTerminalKey(data, "down")) lane.scrollOffset += 1;
@@ -222,7 +231,11 @@ export class RefineOverlayComponent implements Component {
222
231
  else if (matchesTerminalKey(data, "pageDown")) lane.scrollOffset += Math.max(1, viewport - 1);
223
232
  else {
224
233
  const mouse = data.match(/^\x1b\[<(\d+);\d+;\d+[Mm]$/);
225
- if (!mouse || (Number(mouse[1]) & 64) !== 64) return;
234
+ if (!mouse || (Number(mouse[1]) & 64) !== 64) {
235
+ // Not ours: forward instead of swallowing (see onUnhandledKey note).
236
+ this.onUnhandledKey?.(data);
237
+ return;
238
+ }
226
239
  lane.scrollOffset += (Number(mouse[1]) & 1) === 0 ? -3 : 3;
227
240
  }
228
241
  lane.followTranscript = false;
@@ -303,11 +316,14 @@ export class RefineOverlayController {
303
316
  private tui: TUI | undefined;
304
317
  private closed = false;
305
318
  private readonly lang: UiLanguage;
319
+ /** Forwarded unhandled keys (see RefineOverlayComponent.onUnhandledKey). */
320
+ private readonly onUnhandledKey?: (data: string) => void;
306
321
 
307
- constructor(role: RefineOverlayRole, laneIds: Array<{ id: string; label?: string }>, onCancel: () => void, lang: UiLanguage = "en") {
322
+ constructor(role: RefineOverlayRole, laneIds: Array<{ id: string; label?: string }>, onCancel: () => void, lang: UiLanguage = "en", onUnhandledKey?: (data: string) => void) {
308
323
  this.role = role;
309
324
  this.onCancel = onCancel;
310
325
  this.lang = lang;
326
+ this.onUnhandledKey = onUnhandledKey;
311
327
  this.lanes = laneIds.map((lane) => ({
312
328
  id: lane.id,
313
329
  label: lane.label ?? lane.id,
@@ -330,7 +346,7 @@ export class RefineOverlayController {
330
346
  (_tui, theme, _keybindings, done) => {
331
347
  this.tui = _tui;
332
348
  this.done = done;
333
- this.component = new RefineOverlayComponent(theme, this.role, this.lanes, () => this.cancel(), _tui, modelLabel, this.lang);
349
+ this.component = new RefineOverlayComponent(theme, this.role, this.lanes, () => this.cancel(), _tui, modelLabel, this.lang, this.onUnhandledKey);
334
350
  if (this.closed) done(undefined);
335
351
  return this.component;
336
352
  },
@@ -17,6 +17,7 @@ import * as fs from "node:fs";
17
17
  import { existsSync } from "node:fs";
18
18
  import * as path from "node:path";
19
19
  import { loadExecutionFromCheckpoint } from "./exec.ts";
20
+ import { formatReviewBudget, isReviewPauseReason, unlimitedHardCapCeiling } from "./review-budget.ts";
20
21
  import { bindRun, boundRunId } from "./run-context.ts";
21
22
  import { acquireOwnership, OwnershipError, releaseOwnership } from "./run-ownership.ts";
22
23
  import { loadConfig, resolveArtifactRoot, resolveStateRootOrNull, setRunStatus, updateRunWorkdir } from "./state.ts";
@@ -300,21 +301,40 @@ async function buildBrief(
300
301
  const reverify = load.reverifyAll
301
302
  ? `\nThe code state (HEAD) changed since approval: the authorization is KEPT, but every previously closed task was re-opened and must be re-done. Historically verified checks (evidence only): ${doneList}.`
302
303
  : `\nPreviously verified and still valid: ${doneList}.`;
303
- // v0.8: a review-cap pause is NOT cleared by this resume — only
304
- // /plans-execute (an explicit user confirmation) grants a fresh
305
- // five-round budget; ordinary resumes and input keep it paused.
304
+ // v0.9.3: one shared predicate for every review pause (numeric
305
+ // exhaustion, the unlimited hard cap, the no-progress valve) — the
306
+ // three prefixes live in src/review-budget.ts so this surface can
307
+ // never drift from the pause writer (round-1 F-007).
306
308
  const paused = load.pausedReason
307
- ? load.pausedReason.startsWith("execution review exhausted") || load.pausedReason.startsWith("completion audit exhausted")
308
- ? `\nExecution had been paused: ${load.pausedReason} — this pause survives the resume; run /plans-execute to grant a fresh five-round review budget.`
309
+ ? isReviewPauseReason(load.pausedReason)
310
+ ? `\nExecution had been paused: ${load.pausedReason} — this pause survives the resume; run /plans-execute to grant a fresh review budget (it re-opens the round-count picker).`
309
311
  : `\nExecution had been paused: ${load.pausedReason} — the pause is cleared by this resume; continue from where it stopped.`
310
312
  : "";
313
+ // v0.9.3 (Q-5): the brief repeats the budget and marks the no-UI
314
+ // fallback, so a resumed run never hides which bound is in force.
315
+ const budgetLine = load.reviewBudget !== undefined
316
+ ? `\nExecution review budget: ${formatReviewBudget(load.reviewBudget)}${load.reviewBudgetDefaulted ? " (default)" : ""}${
317
+ load.reviewBudget === "unlimited"
318
+ ? ` · hard cap ${unlimitedHardCapCeiling({ reviewRoundsTotal: load.reviewRoundsTotal ?? 0, reviewCapExtension: load.reviewCapExtension ?? 0 })} rounds (spent ${load.reviewRoundsTotal ?? 0})`
319
+ : ""
320
+ }.`
321
+ : "";
311
322
  const legacy = load.legacyPlan ? "\nThis plan parses through the legacy I-### compatibility mapping; upgrade it to the ## Tasks format at the next revision." : "";
323
+ // v0.9.1 (F-005): outstanding highs surface in the brief itself, not
324
+ // only in the per-turn injection.
325
+ const highs = (load.findings ?? []).filter((f) => f.severity === "high");
326
+ const highLine = highs.length > 0
327
+ ? `\nUnresolved high-severity findings from review round (stable ids): ${highs.map((f) => `${f.id}${f.taskIds.length ? ` (${f.taskIds.join(", ")})` : ""}: ${f.note}`).join("; ")} — fix them, then re-close the affected tasks.`
328
+ : "";
329
+ const blockedLine = (load.blocked?.tasks.length ?? 0) > 0
330
+ ? `\nReview blocked: ${load.blocked!.tasks.join(", ")} ${load.blocked!.tasks.length === 1 ? "was" : "were"} reopened by execution review round ${load.blocked!.round} and ${load.blocked!.tasks.length === 1 ? "is" : "are"} still open — close ${load.blocked!.tasks.length === 1 ? "it" : "them"} with plans_update_task (complete + evidence, or skipped + skipReason); the review starts by itself once every task is terminal. No review round is running while these are open.`
331
+ : "";
312
332
  // v0.8: a verifying run keeps checkpoint phase "executing" but the run
313
333
  // STATUS is verifying — surface which loop owns the run right now.
314
334
  const verifying = run.status === "verifying";
315
335
  return {
316
336
  phaseLabel: verifying ? "verifying" : "executing",
317
- text: `[PI-PLANS RESUME] ${verifying ? "Execution review of" : "Execution of"} run ${runId} continues in this session.\nPlan: ${load.planPath}${reverify}${paused}${legacy}\n${verifying ? "The task tree is terminal and the execution-review loop owns the run: when all tasks are terminal and checks are still owed, a read-only reviewer round runs automatically (status verifying → done when every check passes). If a check fails, its tasks roll back to pending — fix and re-close them with plans_update_task." : "Follow the execution-loop contract: work through tasks in wave order, report every task with the plans_update_task tool (status + evidence / skipReason), and let the execution reviewer verify the checks. The current wave and remaining tasks are injected each turn."}`,
337
+ text: `[PI-PLANS RESUME] ${verifying ? "Execution review of" : "Execution of"} run ${runId} continues in this session.\nPlan: ${load.planPath}${reverify}${budgetLine}${paused}${legacy}${highLine}${blockedLine}\n${verifying ? "The task tree is terminal and the execution-review loop owns the run: when all tasks are terminal and checks are still owed, a read-only reviewer round runs automatically (status verifying → done when every check passes). If a check fails, its tasks roll back to pending — fix and re-close them with plans_update_task." : "Follow the execution-loop contract: work through tasks in wave order, report every task with the plans_update_task tool (status + evidence / skipReason), and let the execution reviewer verify the checks. The current wave and remaining tasks are injected each turn."}`,
318
338
  };
319
339
  }
320
340
  if (load.legacyDelegate) {
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Execution-review budget (v0.9.3).
3
+ *
4
+ * The post-execution review loop used to be bounded by a fixed constant
5
+ * (`REVIEW_MAX_ROUNDS = 5`) and always paused at the cap. The budget is now a
6
+ * per-run choice, resolved exactly once — right before round 1, when every
7
+ * task is terminal — and stored in the run checkpoint (never in the plan
8
+ * file). Pickable values are 1 / 2 / 3 / 5 / unlimited.
9
+ *
10
+ * Semantics (see references/pi-planning-workflow.md):
11
+ * - numeric budget: `audit.rounds >= budget` is exhausted;
12
+ * - unlimited: no round cap, but two safety valves stop it — three consecutive
13
+ * committed rounds with an identical outcome signature (no progress), and a
14
+ * run-cumulative hard cap of `UNLIMITED_HARD_CAP` committed rounds, lifted by
15
+ * another `UNLIMITED_HARD_CAP` on every explicit `/plans-execute` grant that
16
+ * lands on `unlimited`;
17
+ * - exhaustion pauses fail-closed (any mode) unless every verification check is
18
+ * already satisfied, in which case the run completes even with unresolved
19
+ * high findings (they are recorded and disclosed);
20
+ * - only `/plans-execute` lifts a review pause; it re-opens the picker with the
21
+ * current value preselected.
22
+ */
23
+
24
+ import { Container, SelectList, Spacer, Text, getKeybindings } from "@earendil-works/pi-tui";
25
+ import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
26
+ import { REVIEW_MAX_ROUNDS } from "./auditor.ts";
27
+ import { reviewBudgetChrome, type ReviewBudgetChrome, type UiLanguage } from "./ui-language.ts";
28
+
29
+ /** A committed-round budget: a positive round count or the unlimited mode. */
30
+ export type ReviewBudget = number | "unlimited";
31
+
32
+ /** Fallback when no budget was chosen (no UI, headless, auto-approve, RPC). */
33
+ export const DEFAULT_REVIEW_BUDGET = 3;
34
+
35
+ /** Legacy fixed cap: a checkpoint written before this feature that already
36
+ * spent rounds keeps its 5-round bound instead of being cut to the new
37
+ * default mid-flight. */
38
+ export const LEGACY_REVIEW_MAX_ROUNDS = REVIEW_MAX_ROUNDS;
39
+
40
+ /** Run-cumulative hard cap for the unlimited budget (per grant window). */
41
+ export const UNLIMITED_HARD_CAP = 50;
42
+
43
+ /** Consecutive identical outcomes before the no-progress valve pauses. */
44
+ export const NO_PROGRESS_MAX_STREAK = 3;
45
+
46
+ /** Values the picker offers, in display order. */
47
+ export const REVIEW_BUDGET_CHOICES: readonly ReviewBudget[] = [1, 2, 3, 5, "unlimited"];
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Pause reasons (single source of truth, shared with the resume surface)
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /** Budget exhaustion (numeric budget or the unlimited hard cap). */
54
+ export const REVIEW_CAP_PAUSE_PREFIX = "execution review exhausted";
55
+ /** v0.7 protocol value — dual-matched so checkpoints written by older builds
56
+ * keep their cap pause recognized on restore. */
57
+ export const LEGACY_AUDIT_CAP_PAUSE_PREFIX = "completion audit exhausted";
58
+ /** Unlimited-mode no-progress valve. */
59
+ export const REVIEW_NO_PROGRESS_PAUSE_PREFIX = "execution review stalled";
60
+
61
+ /** True for every review-loop pause — the pauses that ONLY `/plans-execute`
62
+ * lifts (ordinary input and session restores never do). Shared by
63
+ * `src/exec.ts` and `src/resume-command.ts` so the two surfaces cannot drift
64
+ * (round-1 F-007). */
65
+ export function isReviewPauseReason(reason: string | undefined | null): boolean {
66
+ if (!reason) return false;
67
+ return (
68
+ reason.startsWith(REVIEW_CAP_PAUSE_PREFIX) ||
69
+ reason.startsWith(LEGACY_AUDIT_CAP_PAUSE_PREFIX) ||
70
+ reason.startsWith(REVIEW_NO_PROGRESS_PAUSE_PREFIX)
71
+ );
72
+ }
73
+
74
+ /** Backwards-compatible alias for the pre-v0.9.3 predicate. */
75
+ export const isReviewCapPause = isReviewPauseReason;
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Budget arithmetic (pure)
79
+ // ---------------------------------------------------------------------------
80
+
81
+ export function formatReviewBudget(budget: ReviewBudget | undefined): string {
82
+ if (budget === undefined) return "?";
83
+ return budget === "unlimited" ? "∞" : String(budget);
84
+ }
85
+
86
+ /** The counts that bound an unlimited budget: committed rounds across the
87
+ * whole run (never reset) and the extension the user explicitly granted. */
88
+ export interface ReviewBudgetCounters {
89
+ reviewRoundsTotal: number;
90
+ reviewCapExtension: number;
91
+ }
92
+
93
+ /** Resolve the stored budget at load time: an explicitly stored value wins; a
94
+ * checkpoint that predates the feature and already spent rounds keeps the
95
+ * legacy 5-round bound; anything else is still undecided (the picker runs
96
+ * before round 1). */
97
+ export function resolveStoredBudget(stored: ReviewBudget | undefined, auditRounds: number): ReviewBudget | undefined {
98
+ if (stored !== undefined) return stored;
99
+ return auditRounds > 0 ? LEGACY_REVIEW_MAX_ROUNDS : undefined;
100
+ }
101
+
102
+ /** True when no further committed round may start under this budget. */
103
+ export function budgetExhausted(
104
+ budget: ReviewBudget,
105
+ committedRounds: number,
106
+ counters: ReviewBudgetCounters,
107
+ ): boolean {
108
+ if (budget === "unlimited") {
109
+ return counters.reviewRoundsTotal >= UNLIMITED_HARD_CAP + counters.reviewCapExtension;
110
+ }
111
+ return committedRounds >= budget;
112
+ }
113
+
114
+ /** Remaining rounds under an unlimited budget; Infinity for a numeric budget
115
+ * that is not exhausted. Display-only. */
116
+ export function unlimitedHardCapCeiling(counters: ReviewBudgetCounters): number {
117
+ return UNLIMITED_HARD_CAP + counters.reviewCapExtension;
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // No-progress valve (unlimited mode)
122
+ // ---------------------------------------------------------------------------
123
+
124
+ export interface NoProgressState {
125
+ /** Canonical signature of the last committed round's outcome. */
126
+ key: string;
127
+ /** Consecutive committed rounds carrying that same signature. */
128
+ streak: number;
129
+ }
130
+
131
+ /** Outcome signature: the sorted failed checks, undeterminable checks, and
132
+ * high-finding ids of one committed round. Computed from the POST-
133
+ * classification triple inside `commitReviewOutcome` (round-1 F-002), so a
134
+ * spawn-failure round (`outcome === null`) and a discard synthesis carry a
135
+ * concrete signature too — the valve must catch the case where the reviewer
136
+ * never produces a report. */
137
+ export function noProgressSignature(
138
+ failed: readonly string[],
139
+ undeterminable: readonly string[],
140
+ highFindingIds: readonly string[],
141
+ ): string {
142
+ const norm = (ids: readonly string[]): string => [...new Set(ids)].sort().join(",");
143
+ return `${norm(failed)}|${norm(undeterminable)}|${norm(highFindingIds)}`;
144
+ }
145
+
146
+ export function bumpNoProgress(previous: NoProgressState | undefined, signature: string): NoProgressState {
147
+ if (previous && previous.key === signature) return { key: signature, streak: previous.streak + 1 };
148
+ return { key: signature, streak: 1 };
149
+ }
150
+
151
+ export function noProgressTripped(state: NoProgressState | undefined): boolean {
152
+ return state !== undefined && state.streak >= NO_PROGRESS_MAX_STREAK;
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Picker panel
157
+ // ---------------------------------------------------------------------------
158
+
159
+ /** Narrow structural view of ExtensionContext the picker needs. */
160
+ export interface ReviewBudgetPanelHost {
161
+ mode?: string | undefined;
162
+ hasUI?: boolean;
163
+ ui?: {
164
+ custom?: (factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: unknown) => void) => unknown, options?: Record<string, unknown>) => Promise<unknown>;
165
+ select?: (title: string, options: string[], opts?: unknown) => Promise<string | undefined>;
166
+ };
167
+ }
168
+
169
+ /**
170
+ * True when some native surface can actually ask the question.
171
+ *
172
+ * `ExtensionUIContext.select` is a REQUIRED SDK method and therefore present on
173
+ * every context — including `json`/`print` sessions where it cannot ask
174
+ * anything. `hasUI` ("true in TUI and RPC modes") is the availability signal:
175
+ * without it a headless session takes the panel path, and every at-pause grant
176
+ * would be "declined" forever, leaving the run permanently paused
177
+ * (round-1 F-001). UI-less sessions fall back to the default budget instead.
178
+ */
179
+ export function reviewBudgetPanelAvailable(host: ReviewBudgetPanelHost): boolean {
180
+ if (host.hasUI !== true) return false;
181
+ const ui = host.ui;
182
+ if (!ui) return false;
183
+ if (typeof ui.select === "function") return true;
184
+ return host.mode === "tui" && typeof ui.custom === "function";
185
+ }
186
+
187
+ interface BudgetItem {
188
+ label: string;
189
+ value: ReviewBudget;
190
+ }
191
+
192
+ const BUDGET_LAYOUT = { minPrimaryColumnWidth: 12, maxPrimaryColumnWidth: 32 };
193
+
194
+ const BUDGET_OVERLAY_OPTIONS = {
195
+ overlay: true,
196
+ overlayOptions: {
197
+ width: "78%",
198
+ minWidth: 60,
199
+ maxHeight: "78%",
200
+ anchor: "top-center",
201
+ margin: { top: 1, left: 2, right: 2 },
202
+ },
203
+ };
204
+
205
+ /** TUI panel: one selectable row per budget value. */
206
+ class BudgetPanelComponent extends Container {
207
+ private readonly selectList: SelectList;
208
+
209
+ constructor(
210
+ items: BudgetItem[],
211
+ preselect: ReviewBudget | undefined,
212
+ chrome: ReviewBudgetChrome,
213
+ onSelect: (budget: ReviewBudget) => void,
214
+ onCancel: () => void,
215
+ ) {
216
+ super();
217
+ this.addChild(new Spacer(1));
218
+ this.addChild(new Text(chrome.panelTitle, 0, 0));
219
+ this.addChild(new Spacer(1));
220
+ this.selectList = new SelectList(items, Math.max(1, items.length), getSelectListTheme(), BUDGET_LAYOUT);
221
+ const index = items.findIndex((item) => item.value === preselect);
222
+ if (index !== -1) this.selectList.setSelectedIndex(index);
223
+ this.selectList.onSelect = (item) => onSelect((item as BudgetItem).value);
224
+ this.selectList.onCancel = () => onCancel();
225
+ this.addChild(this.selectList);
226
+ this.addChild(new Spacer(1));
227
+ this.addChild(new Text(chrome.panelHint, 0, 0));
228
+ }
229
+
230
+ handleInput(keyData: string): void {
231
+ const kb = getKeybindings();
232
+ const isNav =
233
+ kb.matches(keyData, "tui.select.up") ||
234
+ kb.matches(keyData, "tui.select.down") ||
235
+ kb.matches(keyData, "tui.select.confirm") ||
236
+ kb.matches(keyData, "tui.select.cancel");
237
+ if (!isNav) return; // unknown keys are ignored, never a silent cancel
238
+ this.selectList.handleInput(keyData);
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Ask for the execution-review budget. Returns the picked value, or `null`
244
+ * when the question could not be answered — panel Esc/cancel, `ui.custom`
245
+ * unavailable (RPC), a select menu cancelled, or no UI at all. Callers decide
246
+ * what `null` means: the FIRST resolution falls back to
247
+ * `DEFAULT_REVIEW_BUDGET` with a visible note; the at-pause grant keeps the
248
+ * run paused (Esc never silently grants rounds).
249
+ */
250
+ export async function askReviewBudget(
251
+ host: ReviewBudgetPanelHost,
252
+ lang: UiLanguage | undefined,
253
+ current?: ReviewBudget,
254
+ ): Promise<ReviewBudget | null> {
255
+ const chrome = reviewBudgetChrome(lang ?? "en");
256
+ if (!reviewBudgetPanelAvailable(host)) return null;
257
+ const items: BudgetItem[] = REVIEW_BUDGET_CHOICES.map((value) => ({ label: budgetItemLabel(value, chrome), value }));
258
+ if (host.mode === "tui" && typeof host.ui?.custom === "function") {
259
+ try {
260
+ const picked = await host.ui.custom<ReviewBudget | null>((_tui, _theme, _kb, done) => {
261
+ let settled = false;
262
+ const finish = (value: ReviewBudget | null): void => {
263
+ if (settled) return;
264
+ settled = true;
265
+ done(value);
266
+ };
267
+ return new BudgetPanelComponent(items, current, chrome, (budget) => finish(budget), () => finish(null));
268
+ }, BUDGET_OVERLAY_OPTIONS as never);
269
+ if (picked === "unlimited" || typeof picked === "number") return picked;
270
+ if (picked === null) return null; // Esc on the panel
271
+ // undefined: no TUI surface (RPC custom()) — fall through to menus.
272
+ } catch {
273
+ /* construction failure: fall through to menus */
274
+ }
275
+ }
276
+ if (typeof host.ui?.select === "function") {
277
+ const labels = items.map((item) => item.label);
278
+ const title = current === undefined ? chrome.panelTitle : `${chrome.panelTitle} ${chrome.currentSuffix(formatReviewBudget(current))}`;
279
+ const picked = await host.ui.select(title, labels);
280
+ if (typeof picked === "string") {
281
+ const index = labels.indexOf(picked);
282
+ if (index >= 0) return items[index]!.value;
283
+ }
284
+ }
285
+ return null;
286
+ }
287
+
288
+ function budgetItemLabel(budget: ReviewBudget, chrome: ReviewBudgetChrome): string {
289
+ return budget === "unlimited" ? chrome.unlimitedOption : chrome.roundsOption(budget);
290
+ }
package/src/tasks.ts CHANGED
@@ -132,6 +132,59 @@ export function canTransition(task: TaskView, next: TaskStatus): boolean {
132
132
  return false;
133
133
  }
134
134
 
135
+ /** Authoritative provenance of the newest failed review round's rollback.
136
+ * Captured when the round commits: the reopen helpers mutate the tree (and
137
+ * report only the nodes that flipped), so a later "re-derivation" cannot
138
+ * distinguish an already-pending task from a reopened one and would re-apply
139
+ * the rollback to work that has since been re-closed. */
140
+ export interface RollbackSource {
141
+ rolledBack: string[];
142
+ round: number;
143
+ }
144
+
145
+ /** Coverage of a set of failed checks (plus explicit finding task ids) with
146
+ * the same parent→child cascade the rollback applies, computed WITHOUT
147
+ * touching the tree. Used to reconstruct the blocker of a checkpoint written
148
+ * before `execution.blocked` existed: that round's reopen already happened,
149
+ * so only the provenance is missing. Never call it to APPLY a rollback —
150
+ * `auditRollbackSet`/`findingsRollbackSet` own that. */
151
+ export function rollbackCoverageIds(
152
+ tasks: TaskView[],
153
+ checklist: CheckItem[],
154
+ vcIds: readonly string[],
155
+ extraTaskIds: readonly string[] = [],
156
+ ): string[] {
157
+ const wanted = new Set(vcIds);
158
+ const covered = new Set(extraTaskIds);
159
+ for (const item of checklist) {
160
+ if (!wanted.has(item.id)) continue;
161
+ for (const id of extractTaskCoverage(item.text)) covered.add(id);
162
+ }
163
+ if (covered.size === 0) return [];
164
+ const out: string[] = [];
165
+ const walk = (node: TaskView, inherited: boolean): void => {
166
+ const hit = inherited || covered.has(node.id);
167
+ if (hit) out.push(node.id);
168
+ for (const child of node.children) walk(child, hit);
169
+ };
170
+ for (const node of tasks) walk(node, false);
171
+ return out;
172
+ }
173
+
174
+ /** Blocking tasks of the newest rollback set: non-terminal tasks only, in
175
+ * tree order. Pure — it never touches `status`/`evidence`/`skipReason`, so it
176
+ * is safe to call on every wake, dashboard repaint, or checkpoint write. A
177
+ * task that was already pending when the round rolled back IS reported: the
178
+ * caller supplies the authoritative set, unlike `auditRollbackSet` which
179
+ * reports flips. */
180
+ export function blockedReviewTasks(tasks: TaskView[], rolledBackIds: readonly string[]): string[] {
181
+ if (rolledBackIds.length === 0) return [];
182
+ const rolledBack = new Set(rolledBackIds);
183
+ return flattenTaskViews(tasks)
184
+ .filter((task) => rolledBack.has(task.id) && !taskIsTerminal(task))
185
+ .map((task) => task.id);
186
+ }
187
+
135
188
  /** Rollback set for a failed verification check: every task in its covers
136
189
  * clause (parents cascade to their children, skipped tasks reopen too).
137
190
  * Returns the ids that actually reopen.
@@ -165,6 +218,29 @@ export function auditRollbackSet(
165
218
  return reopen;
166
219
  }
167
220
 
221
+ /** Rollback set for high-severity findings (v0.9): reopen exactly the named
222
+ * tasks — parents cascade to their children, skipped tasks reopen too — with
223
+ * semantics identical to auditRollbackSet, but keyed by task id because
224
+ * findings carry task ids, not VC ids. Evidence is kept and skipReason
225
+ * cleared for the same reasons as the VC path. */
226
+ export function findingsRollbackSet(tasks: TaskView[], taskIds: string[]): string[] {
227
+ const wanted = new Set(taskIds);
228
+ const flat = flattenTaskViews(tasks);
229
+ const reopen: string[] = [];
230
+ const reopenNode = (node: TaskView): void => {
231
+ if (node.status !== "pending") {
232
+ node.status = "pending";
233
+ node.skipReason = undefined;
234
+ reopen.push(node.id);
235
+ }
236
+ for (const child of node.children) reopenNode(child);
237
+ };
238
+ for (const node of flat) {
239
+ if (wanted.has(node.id)) reopenNode(node);
240
+ }
241
+ return reopen;
242
+ }
243
+
168
244
  /** Checks that lose their satisfied state because a rollback reopened work
169
245
  * they were verifying. Returns the ids whose `done` flag was cleared.
170
246
  *
@@ -128,4 +128,42 @@ export function refineChrome(lang: UiLanguage): RefineChrome {
128
128
  return REFINE_CHROME[lang];
129
129
  }
130
130
 
131
+ // --------------------------------------------------------------------------
132
+ // Execution-review budget picker chrome (src/review-budget.ts)
133
+ // --------------------------------------------------------------------------
134
+
135
+ export interface ReviewBudgetChrome {
136
+ /** Panel title (TUI) / select title prefix (menus). */
137
+ panelTitle: string;
138
+ /** Suffix appended to the menu title when re-picking at a pause. */
139
+ currentSuffix(current: string): string;
140
+ /** Row label for a numeric budget. */
141
+ roundsOption(rounds: number): string;
142
+ /** Row label for the unlimited budget. */
143
+ unlimitedOption: string;
144
+ /** Footer hint under the TUI panel. */
145
+ panelHint: string;
146
+ }
147
+
148
+ const REVIEW_BUDGET_CHROME: Record<UiLanguage, ReviewBudgetChrome> = {
149
+ zh: {
150
+ panelTitle: "执行评审轮数预算 — 评议会逐轮验证计划里的每条 VC",
151
+ currentSuffix: (current) => `(当前 ${current})`,
152
+ roundsOption: (rounds) => `${rounds} 轮`,
153
+ unlimitedOption: "无上限(直到没有 high findings;3 轮无进展或累计 50 轮时暂停)",
154
+ panelHint: "[↑/↓] 选择 [Enter] 选定 [Esc] 取消(改用默认值)",
155
+ },
156
+ en: {
157
+ panelTitle: "Execution review budget — the reviewer verifies every VC round by round",
158
+ currentSuffix: (current) => `(current: ${current})`,
159
+ roundsOption: (rounds) => `${rounds} round${rounds === 1 ? "" : "s"}`,
160
+ unlimitedOption: "unlimited (until no high findings; pauses after 3 no-progress rounds or 50 committed rounds)",
161
+ panelHint: "[↑/↓] Select [Enter] Choose [Esc] Cancel (use the default)",
162
+ },
163
+ };
164
+
165
+ export function reviewBudgetChrome(lang: UiLanguage): ReviewBudgetChrome {
166
+ return REVIEW_BUDGET_CHROME[lang];
167
+ }
168
+
131
169