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.
- package/AGENTS.md +58 -0
- package/CONTRIBUTING.md +5 -12
- package/README.md +5 -5
- package/agents/execution-reviewer.md +92 -0
- package/index.ts +13 -23
- package/package.json +2 -1
- package/references/pi-planning-workflow.md +14 -7
- package/references/plan-artifact-template.md +11 -1
- package/references/state-and-config.md +3 -3
- package/scripts/validate.ts +20 -3
- package/src/auditor.ts +306 -63
- package/src/code-graph/commands.ts +6 -1
- package/src/dashboard.ts +91 -13
- package/src/exec.ts +835 -142
- package/src/plan.ts +1 -1
- package/src/refine-ui-state.ts +1 -1
- package/src/refine-ui.ts +40 -8
- package/src/resume-command.ts +19 -3
- package/src/resume.ts +5 -1
- package/src/staleness.ts +53 -0
- package/src/state.ts +1 -0
- package/src/task-tool.ts +1 -1
- package/src/tasks.ts +62 -5
- package/src/ui-language.ts +4 -0
- package/src/workflow-state.ts +93 -6
- package/tests/analyze-refs.test.ts +1 -1
- package/tests/auditor.test.ts +299 -16
- package/tests/dashboard.test.ts +202 -2
- package/tests/exec-review-loop.test.ts +724 -0
- package/tests/exec.test.ts +198 -44
- package/tests/extension-load.test.ts +1 -1
- package/tests/refine-ui.test.ts +25 -2
- package/tests/resume-lifecycle.test.ts +5 -1
- package/tests/resume.test.ts +6 -0
- package/tests/staleness.test.ts +76 -0
- package/tests/state.test.ts +4 -0
- package/tests/tasks.test.ts +142 -0
- package/tests/workflow-state.test.ts +105 -0
- package/tools/analyze-refs.ts +17 -6
- package/tools/execute-plan.ts +12 -5
- package/tools/plans.ts +1 -1
- 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
|
|
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) {
|
package/src/refine-ui-state.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
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);
|
package/src/resume-command.ts
CHANGED
|
@@ -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
|
-
|
|
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}\
|
|
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;
|
package/src/staleness.ts
ADDED
|
@@ -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
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
|
|
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
|
|
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[] {
|
package/src/ui-language.ts
CHANGED
|
@@ -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
|
|
package/src/workflow-state.ts
CHANGED
|
@@ -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?: {
|
|
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
|
-
|
|
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;/);
|