pi-plans 0.7.0 → 0.8.1

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.
Files changed (42) hide show
  1. package/AGENTS.md +58 -0
  2. package/CONTRIBUTING.md +5 -12
  3. package/README.md +5 -5
  4. package/agents/execution-reviewer.md +92 -0
  5. package/index.ts +13 -23
  6. package/package.json +2 -1
  7. package/references/pi-planning-workflow.md +14 -7
  8. package/references/plan-artifact-template.md +11 -1
  9. package/references/state-and-config.md +3 -3
  10. package/scripts/validate.ts +20 -3
  11. package/src/auditor.ts +306 -63
  12. package/src/code-graph/commands.ts +6 -1
  13. package/src/dashboard.ts +91 -13
  14. package/src/exec.ts +835 -142
  15. package/src/plan.ts +1 -1
  16. package/src/refine-ui-state.ts +1 -1
  17. package/src/refine-ui.ts +40 -8
  18. package/src/resume-command.ts +19 -3
  19. package/src/resume.ts +5 -1
  20. package/src/staleness.ts +53 -0
  21. package/src/state.ts +1 -0
  22. package/src/task-tool.ts +1 -1
  23. package/src/tasks.ts +62 -5
  24. package/src/ui-language.ts +4 -0
  25. package/src/workflow-state.ts +93 -6
  26. package/tests/analyze-refs.test.ts +1 -1
  27. package/tests/auditor.test.ts +299 -16
  28. package/tests/dashboard.test.ts +202 -2
  29. package/tests/exec-review-loop.test.ts +724 -0
  30. package/tests/exec.test.ts +198 -44
  31. package/tests/extension-load.test.ts +1 -1
  32. package/tests/refine-ui.test.ts +25 -2
  33. package/tests/resume-lifecycle.test.ts +5 -1
  34. package/tests/resume.test.ts +6 -0
  35. package/tests/staleness.test.ts +76 -0
  36. package/tests/state.test.ts +4 -0
  37. package/tests/tasks.test.ts +142 -0
  38. package/tests/workflow-state.test.ts +105 -0
  39. package/tools/analyze-refs.ts +17 -6
  40. package/tools/execute-plan.ts +12 -5
  41. package/tools/plans.ts +1 -1
  42. package/tools/refine.ts +22 -3
package/src/plan.ts CHANGED
@@ -503,7 +503,7 @@ export function lintPlanTasks(planText: string): string | null {
503
503
  }
504
504
  }
505
505
  // Covers references must point at known tasks; a check with zero covers
506
- // never enters the completion audit — surface that exemption loudly.
506
+ // never enters the execution review — surface that exemption loudly.
507
507
  for (const item of parseChecklist(planText)) {
508
508
  const covered = extractTaskCoverage(item.text);
509
509
  if (covered.length === 0) {
@@ -1,6 +1,6 @@
1
1
  import type { SubagentProgressEvent, SubagentResult } from "./subagent.ts";
2
2
 
3
- export type RefineOverlayRole = "reviewer" | "refs";
3
+ export type RefineOverlayRole = "reviewer" | "refs" | "auditor";
4
4
  export type RefineLaneStatus = "queued" | "running" | "complete" | "failed" | "cancelled";
5
5
  export type RefineTranscriptEntryType = "assistant-text" | "thinking" | "tool-call" | "tool-result" | "diagnostic";
6
6
 
package/src/refine-ui.ts CHANGED
@@ -135,10 +135,12 @@ function previewTranscriptText(entry: RefineTranscriptEntry, width: number): { l
135
135
  return { lines: lines.slice(-STREAMING_PREVIEW_LINES), truncated: true };
136
136
  }
137
137
 
138
- function footerText(laneCount: number, lang: UiLanguage): string {
138
+ function footerText(laneCount: number, lang: UiLanguage, role: RefineOverlayRole = "reviewer"): string {
139
139
  const chrome = refineChrome(lang);
140
140
  const parts = [chrome.close, chrome.scroll, chrome.page];
141
141
  if (laneCount > 1) parts.push(chrome.switchLane);
142
+ // Auditor overlay only: the reopen shortcut is the way back after ESC.
143
+ if (role === "auditor") parts.push(chrome.reopen);
142
144
  return parts.join(" · ");
143
145
  }
144
146
 
@@ -166,7 +168,7 @@ function summaryFor(role: RefineOverlayRole, lanes: RefineLaneState[], modelLabe
166
168
  const complete = lanes.filter((lane) => lane.status === "complete").length;
167
169
  const terminal = lanes.filter((lane) => ["complete", "failed", "cancelled"].includes(lane.status)).length;
168
170
  const running = lanes.filter((lane) => lane.status === "running").length;
169
- const title = role === "reviewer" ? "Reviewer" : "Refs";
171
+ const title = role === "reviewer" ? "Reviewer" : role === "auditor" ? "Execution review" : "Refs";
170
172
  const visibleTitle = modelLabel ? `${title} (${modelLabel})` : title;
171
173
  const state = terminal === lanes.length ? "done" : running > 0 ? `${running} running` : "queued";
172
174
  return `${visibleTitle} · ${complete}/${lanes.length} done · ${state}`;
@@ -181,10 +183,15 @@ export class RefineOverlayComponent implements Component {
181
183
  private readonly modelLabel?: string;
182
184
  /** Chrome language (issue #3); defaults to English for direct construction. */
183
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;
184
191
  private selectedLane = 0;
185
192
  private disposed = false;
186
193
 
187
- 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) {
188
195
  this.theme = theme;
189
196
  this.role = role;
190
197
  this.lanes = lanes;
@@ -192,6 +199,7 @@ export class RefineOverlayComponent implements Component {
192
199
  this.tui = tui;
193
200
  this.modelLabel = modelLabel;
194
201
  this.lang = lang;
202
+ this.onUnhandledKey = onUnhandledKey;
195
203
  this.tui?.terminal?.write?.("\x1b[?1000h\x1b[?1006h");
196
204
  }
197
205
 
@@ -212,7 +220,10 @@ export class RefineOverlayComponent implements Component {
212
220
  return;
213
221
  }
214
222
  const lane = this.lanes[this.selectedLane];
215
- if (!lane) return;
223
+ if (!lane) {
224
+ this.onUnhandledKey?.(data);
225
+ return;
226
+ }
216
227
  const viewport = Math.max(1, lane.viewportHeight ?? 1);
217
228
  if (matchesTerminalKey(data, "up")) lane.scrollOffset -= 1;
218
229
  else if (matchesTerminalKey(data, "down")) lane.scrollOffset += 1;
@@ -220,7 +231,11 @@ export class RefineOverlayComponent implements Component {
220
231
  else if (matchesTerminalKey(data, "pageDown")) lane.scrollOffset += Math.max(1, viewport - 1);
221
232
  else {
222
233
  const mouse = data.match(/^\x1b\[<(\d+);\d+;\d+[Mm]$/);
223
- 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
+ }
224
239
  lane.scrollOffset += (Number(mouse[1]) & 1) === 0 ? -3 : 3;
225
240
  }
226
241
  lane.followTranscript = false;
@@ -250,7 +265,7 @@ export class RefineOverlayComponent implements Component {
250
265
  lines.push(...this.renderPane(this.lanes[index]!, innerWidth, paneHeight, index === this.selectedLane));
251
266
  }
252
267
  }
253
- lines.push(renderRow(this.theme, this.theme.fg("dim", footerText(this.lanes.length, this.lang)), innerWidth));
268
+ lines.push(renderRow(this.theme, this.theme.fg("dim", footerText(this.lanes.length, this.lang, this.role)), innerWidth));
254
269
  lines.push(renderBorderLine(this.theme, innerWidth, "bottom"));
255
270
  return lines.map((line) => fitLine(line, width));
256
271
  }
@@ -301,11 +316,14 @@ export class RefineOverlayController {
301
316
  private tui: TUI | undefined;
302
317
  private closed = false;
303
318
  private readonly lang: UiLanguage;
319
+ /** Forwarded unhandled keys (see RefineOverlayComponent.onUnhandledKey). */
320
+ private readonly onUnhandledKey?: (data: string) => void;
304
321
 
305
- 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) {
306
323
  this.role = role;
307
324
  this.onCancel = onCancel;
308
325
  this.lang = lang;
326
+ this.onUnhandledKey = onUnhandledKey;
309
327
  this.lanes = laneIds.map((lane) => ({
310
328
  id: lane.id,
311
329
  label: lane.label ?? lane.id,
@@ -328,7 +346,7 @@ export class RefineOverlayController {
328
346
  (_tui, theme, _keybindings, done) => {
329
347
  this.tui = _tui;
330
348
  this.done = done;
331
- 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);
332
350
  if (this.closed) done(undefined);
333
351
  return this.component;
334
352
  },
@@ -352,6 +370,20 @@ export class RefineOverlayController {
352
370
  .catch(() => undefined);
353
371
  }
354
372
 
373
+ /** Repaint without a progress event (the engine mutates lane state directly). */
374
+ rerender(): void {
375
+ if (this.closed) return;
376
+ this.tui?.requestRender();
377
+ }
378
+
379
+ /** Replace the lane with a matching id with engine-held state — used by the
380
+ * execution-review reopen path so a fresh controller continues the SAME
381
+ * transcript (one-shot controllers can never re-open themselves). */
382
+ seedLane(state: RefineLaneState): void {
383
+ const index = this.lanes.findIndex((lane) => lane.id === state.id);
384
+ if (index >= 0) this.lanes[index] = state;
385
+ }
386
+
355
387
  update(laneId: string, event: SubagentProgressEvent): void {
356
388
  if (this.closed) return;
357
389
  const lane = this.lanes.find((candidate) => candidate.id === laneId);
@@ -300,11 +300,27 @@ async function buildBrief(
300
300
  const reverify = load.reverifyAll
301
301
  ? `\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
302
  : `\nPreviously verified and still valid: ${doneList}.`;
303
- const paused = load.pausedReason ? `\nExecution had been paused: ${load.pausedReason} — the pause is cleared by this resume; continue from where it stopped.` : "";
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.
306
+ 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
+ : `\nExecution had been paused: ${load.pausedReason} — the pause is cleared by this resume; continue from where it stopped.`
310
+ : "";
304
311
  const legacy = load.legacyPlan ? "\nThis plan parses through the legacy I-### compatibility mapping; upgrade it to the ## Tasks format at the next revision." : "";
312
+ // v0.9.1 (F-005): outstanding highs surface in the brief itself, not
313
+ // only in the per-turn injection.
314
+ const highs = (load.findings ?? []).filter((f) => f.severity === "high");
315
+ const highLine = highs.length > 0
316
+ ? `\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
+ : "";
318
+ // v0.8: a verifying run keeps checkpoint phase "executing" but the run
319
+ // STATUS is verifying — surface which loop owns the run right now.
320
+ const verifying = run.status === "verifying";
305
321
  return {
306
- phaseLabel: "executing",
307
- text: `[PI-PLANS RESUME] Execution of run ${runId} continues in this session.\nPlan: ${load.planPath}${reverify}${paused}${legacy}\nFollow 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 completion auditor verify the checks. The current wave and remaining tasks are injected each turn.`,
322
+ 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."}`,
308
324
  };
309
325
  }
310
326
  if (load.legacyDelegate) {
package/src/resume.ts CHANGED
@@ -27,7 +27,7 @@ export interface ResumeCandidate {
27
27
  updatedAt: string;
28
28
  }
29
29
 
30
- const RESUMABLE_RUN_STATUSES = new Set(["planning", "accepted", "executing", "stopped"]);
30
+ const RESUMABLE_RUN_STATUSES = new Set(["planning", "accepted", "executing", "verifying", "stopped"]);
31
31
 
32
32
  /** Terminal runs are resumable only when unfinished implementation-review evidence exists (D-008). */
33
33
  function legacyDoneResumable(run: RunInfo, checkpoint: WorkflowCheckpoint | null): boolean {
@@ -52,6 +52,10 @@ function legacyDoneResumable(run: RunInfo, checkpoint: WorkflowCheckpoint | null
52
52
  }
53
53
 
54
54
  function phaseLabelOf(run: RunInfo, checkpoint: WorkflowCheckpoint | null): string {
55
+ // A verifying run keeps checkpoint.phase === "executing" (the execution
56
+ // state machine's phase is unchanged); surface the run status instead so
57
+ // the resume list never hides the verification loop behind "executing".
58
+ if (run.status === "verifying") return "verifying";
55
59
  if (checkpoint !== null) return checkpoint.phase;
56
60
  if (run.status === "stopped") return "executing (stopped)";
57
61
  return run.status;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Stale-extension probe (v0.7.1, extracted).
3
+ *
4
+ * pi imports an extension's module graph once per process, so a fix written
5
+ * to disk while the session is running stays invisible until /reload. Both
6
+ * /plans (which reports it) and the execution reviewer (which suggests it when
7
+ * a verdict cannot be read) need the same answer, so the probe lives here
8
+ * rather than in index.ts -- exec.ts already depends on index.ts's imports and
9
+ * a back-import would close a cycle.
10
+ *
11
+ * index.ts records when its own copy was loaded; this module never holds that
12
+ * state, so callers pass it in and stay testable.
13
+ */
14
+
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+
18
+ /** mtime (ms) of the newest .ts file under the extension root, or 0. */
19
+ export function newestSourceMtime(baseDir: string): number {
20
+ const stack = [baseDir, path.join(baseDir, "src"), path.join(baseDir, "tools")];
21
+ let newest = 0;
22
+ while (stack.length) {
23
+ const dir = stack.pop()!;
24
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
25
+ const full = path.join(dir, entry.name);
26
+ if (entry.isDirectory()) stack.push(full);
27
+ else if (entry.isFile() && entry.name.endsWith(".ts")) {
28
+ const mtime = fs.statSync(full).mtimeMs;
29
+ if (mtime > newest) newest = mtime;
30
+ }
31
+ }
32
+ }
33
+ return newest;
34
+ }
35
+
36
+ /** One line describing whether the loaded copy is current. A clock-skew
37
+ * tolerance keeps a same-second write from reading as staleness. */
38
+ export function stalenessLine(baseDir: string, loadedAt: Date): string {
39
+ try {
40
+ if (newestSourceMtime(baseDir) > loadedAt.getTime() + 2000) {
41
+ return `⚠ extension code on disk is newer than the loaded copy (loaded ${loadedAt.toISOString()}); run /reload to pick it up`;
42
+ }
43
+ return `Extension loaded: ${loadedAt.toISOString()} (up to date)`;
44
+ } catch {
45
+ return `Extension loaded: ${loadedAt.toISOString()}`;
46
+ }
47
+ }
48
+
49
+ /** Short form for inline hints: the /reload advice, or null when current. */
50
+ export function staleReloadHint(baseDir: string, loadedAt: Date): string | null {
51
+ const line = stalenessLine(baseDir, loadedAt);
52
+ return line.startsWith("⚠") ? line : null;
53
+ }
package/src/state.ts CHANGED
@@ -119,6 +119,7 @@ export const VALID_RUN_STATUSES = new Set([
119
119
  "planning",
120
120
  "accepted",
121
121
  "executing",
122
+ "verifying",
122
123
  "stopped",
123
124
  "abandoned",
124
125
  "done",
package/src/task-tool.ts CHANGED
@@ -64,7 +64,7 @@ export function registerTaskStatusTool(ext: ExtensionAPI): void {
64
64
  name: "plans_update_task",
65
65
  label: "Update task",
66
66
  description:
67
- 'Report execution progress for one task of the accepted plan: set status "complete" (with evidence) or "skipped" (with skipReason). Fails outside pi-plans execution mode. Statuses are immutable once set — the independent completion auditor handles any rollback.',
67
+ 'Report execution progress for one task of the accepted plan: set status "complete" (with evidence) or "skipped" (with skipReason). Fails outside pi-plans execution mode. Statuses are immutable once set — the independent execution reviewer handles any rollback.',
68
68
  promptSnippet: "Report plan task completion",
69
69
  parameters: UpdateTaskParams,
70
70
 
package/src/tasks.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Task-tree runtime model (v0.6.1): the execution phase tracks plan tasks
3
3
  * (`## Tasks`) as the unit of progress. Status flows in exclusively through
4
4
  * the task status tool; completion of the whole run is gated by the
5
- * independent completion auditor over the plan's verification checks.
5
+ * independent execution reviewer over the plan's verification checks.
6
6
  */
7
7
 
8
8
  import type { CheckItem, PlanTasks, TaskNode, WaveEntry } from "./plan.ts";
@@ -77,11 +77,13 @@ export function allTasksTerminal(tasks: TaskView[]): boolean {
77
77
  return flattenTaskViews(tasks).length > 0 && flattenTaskViews(tasks).every((task) => taskIsTerminal(task));
78
78
  }
79
79
 
80
- /** Snapshot of statuses for persistence and stall detection. */
80
+ /** Snapshot of statuses for persistence and stall detection. Every task gets
81
+ * a record: a rolled-back task must stay visible with the evidence from its
82
+ * previous attempt, otherwise a rollback erases the run's history and the
83
+ * agent can no longer tell "done and rolled back" from "never started". */
81
84
  export function taskProgressMap(tasks: TaskView[]): TaskProgressMap {
82
85
  const map: TaskProgressMap = {};
83
86
  for (const task of flattenTaskViews(tasks)) {
84
- if (task.status === "pending" && task.evidence === undefined && task.skipReason === undefined) continue;
85
87
  map[task.id] = {
86
88
  status: task.status,
87
89
  evidence: task.evidence,
@@ -132,7 +134,13 @@ export function canTransition(task: TaskView, next: TaskStatus): boolean {
132
134
 
133
135
  /** Rollback set for a failed verification check: every task in its covers
134
136
  * clause (parents cascade to their children, skipped tasks reopen too).
135
- * Returns the ids that actually reopen. */
137
+ * Returns the ids that actually reopen.
138
+ *
139
+ * The task's `evidence` is deliberately kept. It records what the previous
140
+ * attempt actually did, which is the one thing the agent cannot reconstruct
141
+ * once it reopens the task; re-reporting overwrites it. `skipReason` is
142
+ * cleared because it described a deliberate skip that the audit has now
143
+ * overturned. */
136
144
  export function auditRollbackSet(
137
145
  tasks: TaskView[],
138
146
  checklist: CheckItem[],
@@ -146,7 +154,6 @@ export function auditRollbackSet(
146
154
  const reopenNode = (node: TaskView): void => {
147
155
  if (node.status !== "pending") {
148
156
  node.status = "pending";
149
- node.evidence = undefined;
150
157
  node.skipReason = undefined;
151
158
  reopen.push(node.id);
152
159
  }
@@ -158,6 +165,56 @@ export function auditRollbackSet(
158
165
  return reopen;
159
166
  }
160
167
 
168
+ /** Rollback set for high-severity findings (v0.9): reopen exactly the named
169
+ * tasks — parents cascade to their children, skipped tasks reopen too — with
170
+ * semantics identical to auditRollbackSet, but keyed by task id because
171
+ * findings carry task ids, not VC ids. Evidence is kept and skipReason
172
+ * cleared for the same reasons as the VC path. */
173
+ export function findingsRollbackSet(tasks: TaskView[], taskIds: string[]): string[] {
174
+ const wanted = new Set(taskIds);
175
+ const flat = flattenTaskViews(tasks);
176
+ const reopen: string[] = [];
177
+ const reopenNode = (node: TaskView): void => {
178
+ if (node.status !== "pending") {
179
+ node.status = "pending";
180
+ node.skipReason = undefined;
181
+ reopen.push(node.id);
182
+ }
183
+ for (const child of node.children) reopenNode(child);
184
+ };
185
+ for (const node of flat) {
186
+ if (wanted.has(node.id)) reopenNode(node);
187
+ }
188
+ return reopen;
189
+ }
190
+
191
+ /** Checks that lose their satisfied state because a rollback reopened work
192
+ * they were verifying. Returns the ids whose `done` flag was cleared.
193
+ *
194
+ * A check can be presolved without an audit round (skipped-pass), or affirmed
195
+ * in an earlier round, and stay `done` while another check's rollback reopens
196
+ * one of its covered tasks. Left alone it renders as "check passed" next to a
197
+ * task that is open again with stale evidence. The caller owns this: it runs
198
+ * after the rollback set is known. */
199
+ export function invalidateChecksForRolledBackTasks(
200
+ checklist: CheckItem[],
201
+ tasks: TaskView[],
202
+ reopenedIds: string[],
203
+ ): string[] {
204
+ if (reopenedIds.length === 0) return [];
205
+ const reopened = new Set(reopenedIds);
206
+ const cleared: string[] = [];
207
+ for (const item of checklist) {
208
+ if (!item.done) continue;
209
+ const covered = extractTaskCoverage(item.text);
210
+ if (covered.some((id) => reopened.has(id))) {
211
+ item.done = false;
212
+ cleared.push(item.id);
213
+ }
214
+ }
215
+ return cleared;
216
+ }
217
+
161
218
  /** Verification checks that cover no task are excluded from audit (their
162
219
  * pass state cannot be derived from task statuses). */
163
220
  export function auditableChecks(checklist: CheckItem[], tasks: TaskView[]): CheckItem[] {
@@ -103,6 +103,8 @@ export interface RefineChrome {
103
103
  scroll: string;
104
104
  page: string;
105
105
  switchLane: string;
106
+ /** Auditor overlay only (v0.8): the shortcut that reopens the in-flight round. */
107
+ reopen: string;
106
108
  }
107
109
 
108
110
  const REFINE_CHROME: Record<UiLanguage, RefineChrome> = {
@@ -111,12 +113,14 @@ const REFINE_CHROME: Record<UiLanguage, RefineChrome> = {
111
113
  scroll: "↑/↓ 滚动",
112
114
  page: "PgUp/PgDn 翻页",
113
115
  switchLane: "Tab & Shift + Tab 切换 lane",
116
+ reopen: "Ctrl+Shift+R 重开评审面板",
114
117
  },
115
118
  en: {
116
119
  close: "Esc close",
117
120
  scroll: "↑/↓ scroll",
118
121
  page: "PgUp/PgDn page",
119
122
  switchLane: "Tab & Shift + Tab switch lane",
123
+ reopen: "Ctrl+Shift+R reopen review overlay",
120
124
  },
121
125
  };
122
126
 
@@ -152,8 +152,40 @@ export interface ExecutionCheckpoint {
152
152
  * progress record. doneVcIds stays for legacy checkpoints and the final
153
153
  * audit pass. */
154
154
  tasks?: Record<string, { status: string; evidence?: string; skipReason?: string }>;
155
+ /** v0.7.1: watchdog round counter. Persisted so a session restart cannot
156
+ * silently hand a stalled run a fresh budget; optional so checkpoints
157
+ * written before this field keep loading. */
158
+ stallRounds?: number;
159
+ /** v0.9.1 (F-002): set when the execution-review loop mechanically
160
+ * appended finding tasks to the approved plan. The checkpoint's plan
161
+ * identity is re-stamped at that moment so /resume-plans accepts the
162
+ * amended plan; the approval record keeps the ORIGINAL digest as
163
+ * evidence of what the user actually approved. */
164
+ planAmended?: { sha256: string; amendedAt: string; round: number };
155
165
  /** v0.6.1: completion-audit bookkeeping (rounds, last failed set, pass). */
156
- audit?: { rounds: number; lastResult?: string; passed?: boolean };
166
+ audit?: {
167
+ rounds: number;
168
+ lastResult?: string;
169
+ passed?: boolean;
170
+ undeterminable?: string[];
171
+ /** v0.9: unresolved implementation findings from the newest committed
172
+ * review round (stable F-### ids). Optional so pre-v0.9 checkpoints
173
+ * keep loading as "no findings". */
174
+ findings?: ReviewFindingRecord[];
175
+ };
176
+ }
177
+
178
+ /** Serializable shape of one review finding (v0.9). Structurally identical to
179
+ * auditor.ts's ReviewFinding; declared here so the checkpoint layer does not
180
+ * import the auditor. */
181
+ export interface ReviewFindingRecord {
182
+ id: string;
183
+ severity: string;
184
+ taskIds: string[];
185
+ proposedTask?: string;
186
+ note: string;
187
+ evidence: string;
188
+ raw: string;
157
189
  }
158
190
 
159
191
  export interface OwnerInfo {
@@ -461,7 +493,7 @@ function asExecution(value: unknown, label: string): ExecutionCheckpoint {
461
493
  const record = asRecord(value, label);
462
494
  rejectExtraKeys(
463
495
  record,
464
- new Set(["approval", "doneVcIds", "implStatus", "currentI", "usage", "pausedReason", "reverifyAll", "originWorktree", "delegate", "tasks", "audit"]),
496
+ new Set(["approval", "doneVcIds", "implStatus", "currentI", "usage", "pausedReason", "reverifyAll", "originWorktree", "delegate", "tasks", "stallRounds", "planAmended", "audit"]),
465
497
  label,
466
498
  );
467
499
  const execution: ExecutionCheckpoint = {
@@ -505,14 +537,47 @@ function asExecution(value: unknown, label: string): ExecutionCheckpoint {
505
537
  }
506
538
  execution.tasks = tasks;
507
539
  }
540
+ if (record.stallRounds !== undefined && record.stallRounds !== null) {
541
+ execution.stallRounds = asInt(record.stallRounds, `${label}.stallRounds`, 0);
542
+ }
543
+ if (record.planAmended !== undefined && record.planAmended !== null) {
544
+ const rec = asRecord(record.planAmended, `${label}.planAmended`);
545
+ rejectExtraKeys(rec, new Set(["sha256", "amendedAt", "round"]), `${label}.planAmended`);
546
+ execution.planAmended = {
547
+ sha256: asString(rec.sha256, `${label}.planAmended.sha256`),
548
+ amendedAt: asString(rec.amendedAt, `${label}.planAmended.amendedAt`),
549
+ round: asInt(rec.round, `${label}.planAmended.round`, 0),
550
+ };
551
+ }
508
552
  if (record.audit !== undefined && record.audit !== null) {
509
553
  const audit = asRecord(record.audit, `${label}.audit`);
510
- rejectExtraKeys(audit, new Set(["rounds", "lastResult", "passed"]), `${label}.audit`);
511
- const parsed: { rounds: number; lastResult?: string; passed?: boolean } = {
554
+ rejectExtraKeys(audit, new Set(["rounds", "lastResult", "passed", "undeterminable", "findings"]), `${label}.audit`);
555
+ const parsed: { rounds: number; lastResult?: string; passed?: boolean; undeterminable?: string[]; findings?: ReviewFindingRecord[] } = {
512
556
  rounds: asInt(audit.rounds, `${label}.audit.rounds`, 0),
513
557
  };
514
558
  if (audit.lastResult !== undefined) parsed.lastResult = asString(audit.lastResult, `${label}.audit.lastResult`);
515
559
  if (audit.passed !== undefined) parsed.passed = asBool(audit.passed, `${label}.audit.passed`);
560
+ if (audit.undeterminable !== undefined) {
561
+ parsed.undeterminable = asStringArray(audit.undeterminable, `${label}.audit.undeterminable`);
562
+ }
563
+ if (audit.findings !== undefined && audit.findings !== null) {
564
+ const arr = Array.isArray(audit.findings) ? audit.findings : null;
565
+ if (!arr) throw new CheckpointValidationError(`${label}.audit.findings must be an array`);
566
+ parsed.findings = arr.map((entry, i) => {
567
+ const rec = asRecord(entry, `${label}.audit.findings.${i}`);
568
+ rejectExtraKeys(rec, new Set(["id", "severity", "taskIds", "proposedTask", "note", "evidence", "raw"]), `${label}.audit.findings.${i}`);
569
+ const out: ReviewFindingRecord = {
570
+ id: asString(rec.id, `${label}.audit.findings.${i}.id`),
571
+ severity: asString(rec.severity, `${label}.audit.findings.${i}.severity`),
572
+ taskIds: rec.taskIds === undefined ? [] : asStringArray(rec.taskIds, `${label}.audit.findings.${i}.taskIds`),
573
+ note: rec.note === undefined ? "" : asString(rec.note, `${label}.audit.findings.${i}.note`),
574
+ evidence: rec.evidence === undefined ? "" : asString(rec.evidence, `${label}.audit.findings.${i}.evidence`),
575
+ raw: rec.raw === undefined ? "" : asString(rec.raw, `${label}.audit.findings.${i}.raw`),
576
+ };
577
+ if (rec.proposedTask !== undefined) out.proposedTask = asString(rec.proposedTask, `${label}.audit.findings.${i}.proposedTask`);
578
+ return out;
579
+ });
580
+ }
516
581
  execution.audit = parsed;
517
582
  }
518
583
  return execution;
@@ -1070,8 +1135,12 @@ export interface ExecutionProgressInput {
1070
1135
  delegate?: { modelSelector: string; startedAt: string } | null;
1071
1136
  /** v0.6.1: task-tree progress snapshot (authoritative). */
1072
1137
  tasks?: Record<string, { status: string; evidence?: string; skipReason?: string }>;
1073
- /** v0.6.1: completion-audit bookkeeping update. */
1074
- audit?: { rounds: number; lastResult?: string; passed?: boolean };
1138
+ /** v0.6.1: completion-audit bookkeeping update. v0.9: findings rides the
1139
+ * same replace-semantics slot — callers that must preserve findings (e.g.
1140
+ * budget renewal) pass them through explicitly. */
1141
+ audit?: { rounds: number; lastResult?: string; passed?: boolean; undeterminable?: string[]; findings?: ReviewFindingRecord[] };
1142
+ /** v0.7.1: watchdog budget counter, so a restart cannot refresh it. */
1143
+ stallRounds?: number;
1075
1144
  }
1076
1145
 
1077
1146
  export function applyExecutionProgress(cp: WorkflowCheckpoint, progress: ExecutionProgressInput): WorkflowCheckpoint {
@@ -1091,10 +1160,28 @@ export function applyExecutionProgress(cp: WorkflowCheckpoint, progress: Executi
1091
1160
  if (progress.delegate === null) delete execution.delegate;
1092
1161
  else if (progress.delegate !== undefined) execution.delegate = progress.delegate;
1093
1162
  if (progress.tasks !== undefined) execution.tasks = progress.tasks;
1163
+ if (progress.stallRounds !== undefined) execution.stallRounds = progress.stallRounds;
1094
1164
  if (progress.audit !== undefined) execution.audit = progress.audit;
1095
1165
  return { ...cp, execution };
1096
1166
  }
1097
1167
 
1168
+ /** v0.9.1 (F-002): the execution-review loop appended finding tasks to the
1169
+ * approved plan file; re-stamp the checkpoint's plan identity to the amended
1170
+ * digest so a later /resume-plans does not reject the run as plan-mismatch.
1171
+ * The approval record is untouched — it keeps the digest the user actually
1172
+ * approved, and planAmended records when and why the identity moved. */
1173
+ export function applyExecutionPlanAmended(cp: WorkflowCheckpoint, plan: PlanIdentity, round: number): WorkflowCheckpoint {
1174
+ if (cp.phase !== "executing" || !cp.execution) throw new StateError("requires phase \"executing\"");
1175
+ return {
1176
+ ...cp,
1177
+ plan,
1178
+ execution: {
1179
+ ...cp.execution,
1180
+ planAmended: { sha256: plan.sha256, amendedAt: utcNow(), round },
1181
+ },
1182
+ };
1183
+ }
1184
+
1098
1185
  /** D-011/F-001: code state changed under an unchanged plan — keep authorization, re-verify first. */
1099
1186
  export function applyExecutionHeadChanged(cp: WorkflowCheckpoint): WorkflowCheckpoint {
1100
1187
  if (cp.phase !== "executing" || !cp.execution) throw new StateError("requires phase \"executing\"");
@@ -303,7 +303,7 @@ describe("analyze_refs fanout", () => {
303
303
 
304
304
  it("pins the per-batch overlay lifecycle (open before spawn, close in finally, cap 3)", () => {
305
305
  const source = fs.readFileSync(path.join(ROOT, "tools", "analyze-refs.ts"), "utf8");
306
- assert.equal((source.match(/new RefineOverlayController\("refs"/g) ?? []).length, 1, "controller must be constructed per batch inside the loop");
306
+ assert.equal((source.match(/new RefineOverlayController\(\s*"refs"/g) ?? []).length, 1, "controller must be constructed per batch inside the loop");
307
307
  assert.match(source, /overlay\?\.open\(refineOverlayContext\(ctx\), modelLabel\)/);
308
308
  assert.match(source, /await overlay\?\.close\(\);/);
309
309
  assert.match(source, /const BATCH_SIZE = 3;/);