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/README.md +2 -2
- package/agents/execution-reviewer.md +62 -10
- package/package.json +2 -2
- package/references/pi-planning-workflow.md +16 -8
- package/references/state-and-config.md +2 -2
- package/src/auditor.ts +170 -21
- package/src/dashboard.ts +102 -19
- package/src/exec.ts +865 -107
- package/src/refine-ui.ts +21 -5
- package/src/resume-command.ts +26 -6
- package/src/review-budget.ts +290 -0
- package/src/tasks.ts +76 -0
- package/src/ui-language.ts +38 -0
- package/src/workflow-state.ts +217 -8
- package/tests/analyze-refs.test.ts +1 -1
- package/tests/auditor.test.ts +185 -1
- package/tests/dashboard.test.ts +165 -1
- package/tests/exec-review-loop.test.ts +1031 -10
- 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/refine-ui.test.ts +25 -2
- 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 +227 -0
- package/tools/analyze-refs.ts +17 -6
- package/tools/execute-plan.ts +12 -5
- package/tools/refine.ts +22 -3
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)
|
|
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)
|
|
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
|
},
|
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,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.
|
|
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." : "";
|
|
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
|
*
|
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
|
|