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/resume-command.ts
CHANGED
|
@@ -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,14 +301,24 @@ 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.
|
|
304
|
-
//
|
|
305
|
-
//
|
|
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
|
-
?
|
|
308
|
-
? `\nExecution had been paused: ${load.pausedReason} — this pause survives the resume; run /plans-execute to grant a fresh
|
|
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." : "";
|
|
312
323
|
// v0.9.1 (F-005): outstanding highs surface in the brief itself, not
|
|
313
324
|
// only in the per-turn injection.
|
|
@@ -315,12 +326,15 @@ async function buildBrief(
|
|
|
315
326
|
const highLine = highs.length > 0
|
|
316
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.`
|
|
317
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
|
+
: "";
|
|
318
332
|
// v0.8: a verifying run keeps checkpoint phase "executing" but the run
|
|
319
333
|
// STATUS is verifying — surface which loop owns the run right now.
|
|
320
334
|
const verifying = run.status === "verifying";
|
|
321
335
|
return {
|
|
322
336
|
phaseLabel: verifying ? "verifying" : "executing",
|
|
323
|
-
text: `[PI-PLANS RESUME] ${verifying ? "Execution review of" : "Execution of"} run ${runId} continues in this session.\nPlan: ${load.planPath}${reverify}${paused}${legacy}${highLine}\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."}`,
|
|
324
338
|
};
|
|
325
339
|
}
|
|
326
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.
|
package/src/ui-language.ts
CHANGED
|
@@ -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
|
|