pi-plans 0.3.2 → 0.3.3
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 +5 -3
- package/index.ts +92 -6
- package/package.json +1 -1
- package/references/pi-planning-workflow.md +7 -1
- package/references/plan-artifact-template.md +4 -0
- package/references/state-and-config.md +19 -0
- package/src/autocomplete.ts +2 -1
- package/src/code-graph/commands.ts +21 -6
- package/src/compaction.ts +10 -0
- package/src/config-command.ts +2 -0
- package/src/exec.ts +256 -10
- package/src/guard.ts +7 -1
- package/src/resume-command.ts +450 -0
- package/src/resume.ts +205 -0
- package/src/run-context.ts +97 -0
- package/src/run-ownership.ts +310 -0
- package/src/state.ts +24 -1
- package/src/termination-prompt.ts +8 -0
- package/src/workflow-state.ts +1159 -0
- package/tests/ask-choice.test.ts +113 -0
- package/tests/compaction.test.ts +39 -0
- package/tests/exec.test.ts +250 -0
- package/tests/guard.test.ts +46 -0
- package/tests/plans.test.ts +71 -0
- package/tests/refine-resume.test.ts +324 -0
- package/tests/resume-lifecycle.test.ts +385 -0
- package/tests/resume.test.ts +384 -0
- package/tests/run-context.test.ts +119 -0
- package/tests/run-ownership.test.ts +170 -0
- package/tests/state.test.ts +16 -0
- package/tests/workflow-state.test.ts +432 -0
- package/tools/analyze-refs.ts +2 -1
- package/tools/ask-choice.ts +61 -3
- package/tools/code-graph.ts +5 -1
- package/tools/execute-plan.ts +2 -1
- package/tools/plans.ts +119 -0
- package/tools/refine.ts +91 -7
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /resume-plans command (I-006): discover the worktree's working plan,
|
|
3
|
+
* restore it into the CURRENT session, and kick exactly one continuation.
|
|
4
|
+
*
|
|
5
|
+
* Guarantees (PLAN_v2):
|
|
6
|
+
* - D-009: busy sessions or a run actively owned elsewhere only notify — no
|
|
7
|
+
* queueing, no interruption, no takeover.
|
|
8
|
+
* - R-009: print/json sessions never run the flow; corrupt checkpoints and
|
|
9
|
+
* missing files report and change nothing.
|
|
10
|
+
* - D-007: cross-worktree resumes require explicit confirmation; artifacts
|
|
11
|
+
* copy without overwriting; approval and VC validity reset.
|
|
12
|
+
* - R-007: after every dialog the world is re-checked; one kickoff max.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import { existsSync } from "node:fs";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import { loadExecutionFromCheckpoint } from "./exec.ts";
|
|
20
|
+
import { bindRun } from "./run-context.ts";
|
|
21
|
+
import { acquireOwnership, OwnershipError, releaseOwnership } from "./run-ownership.ts";
|
|
22
|
+
import { loadConfig, resolveStateRootOrNull, setRunStatus, updateRunWorkdir } from "./state.ts";
|
|
23
|
+
import {
|
|
24
|
+
applyMigration,
|
|
25
|
+
mutateCheckpoint,
|
|
26
|
+
resolveHeadAt,
|
|
27
|
+
resolveWorktreeRoot,
|
|
28
|
+
sha256File,
|
|
29
|
+
type WorkflowCheckpoint,
|
|
30
|
+
} from "./workflow-state.ts";
|
|
31
|
+
import {
|
|
32
|
+
listResumeCandidates,
|
|
33
|
+
pickDefaultCandidate,
|
|
34
|
+
readDecisionLedger,
|
|
35
|
+
reconcileCheckpointWithLedger,
|
|
36
|
+
type ResumeCandidate,
|
|
37
|
+
} from "./resume.ts";
|
|
38
|
+
|
|
39
|
+
/** One kickoff per invocation; repeat invocations are blocked by the idle check. */
|
|
40
|
+
let inFlight = false;
|
|
41
|
+
|
|
42
|
+
export async function resumePlansCommand(
|
|
43
|
+
pi: ExtensionAPI,
|
|
44
|
+
ctx: ExtensionContext,
|
|
45
|
+
baseDir: string,
|
|
46
|
+
): Promise<void> {
|
|
47
|
+
if (inFlight) {
|
|
48
|
+
ctx.ui.notify("/resume-plans is already running in this session.", "info");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
inFlight = true;
|
|
52
|
+
try {
|
|
53
|
+
await run(pi, ctx, baseDir);
|
|
54
|
+
} finally {
|
|
55
|
+
inFlight = false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function run(pi: ExtensionAPI, ctx: ExtensionContext, baseDir: string): Promise<void> {
|
|
60
|
+
if (!ctx.hasUI) {
|
|
61
|
+
ctx.ui.notify?.("/resume-plans needs an interactive session (TUI/RPC); print/json cannot resume.", "warning");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (typeof ctx.isIdle === "function" && !ctx.isIdle()) {
|
|
65
|
+
ctx.ui.notify("Agent is busy; run /resume-plans again once the current turn finishes.", "warning");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const candidates = listResumeCandidates(ctx.cwd);
|
|
70
|
+
const corrupt = candidates.filter((candidate) => candidate.checkpointStatus === "corrupt");
|
|
71
|
+
if (candidates.length === 0) {
|
|
72
|
+
ctx.ui.notify("No resumable pi-plans run found in this repository.", "info");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let candidate = pickDefaultCandidate(ctx.cwd, candidates);
|
|
77
|
+
if (candidate === null) {
|
|
78
|
+
const labels = candidates.map((entry, index) => {
|
|
79
|
+
const cross = entry.crossWorktree ? " · cross-worktree" : "";
|
|
80
|
+
const plan = entry.planVersion !== null ? `PLAN v${entry.planVersion}` : "no plan";
|
|
81
|
+
const corruptMark = entry.checkpointStatus === "corrupt" ? " · ⚠ corrupt checkpoint" : "";
|
|
82
|
+
return `${index + 1}. ${entry.runId} · ${entry.phaseLabel} · ${plan}${cross} · ${entry.updatedAt}${corruptMark}`;
|
|
83
|
+
});
|
|
84
|
+
const selected = await ctx.ui.select("Resume which run?", labels);
|
|
85
|
+
if (selected === undefined) {
|
|
86
|
+
ctx.ui.notify("Cancelled; nothing was changed.", "info");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const index = labels.indexOf(selected);
|
|
90
|
+
candidate = candidates[index] ?? null;
|
|
91
|
+
if (candidate === null) {
|
|
92
|
+
ctx.ui.notify("Cancelled; nothing was changed.", "info");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Re-check the world after the dialog (R-007).
|
|
98
|
+
if (typeof ctx.isIdle === "function" && !ctx.isIdle()) {
|
|
99
|
+
ctx.ui.notify("Agent became busy; run /resume-plans again once the current turn finishes.", "warning");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (candidate.checkpointStatus === "corrupt") {
|
|
103
|
+
ctx.ui.notify(
|
|
104
|
+
`Checkpoint for ${candidate.runId} is corrupt (${candidate.checkpointError ?? "unknown"}). Repair or remove .git/pi_plans/runs/${candidate.runId}/checkpoint.json explicitly; nothing was changed.`,
|
|
105
|
+
"error",
|
|
106
|
+
);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Cross-worktree confirmation + artifact migration (D-007/F-003).
|
|
111
|
+
const crossWorktree = candidate.crossWorktree;
|
|
112
|
+
if (crossWorktree) {
|
|
113
|
+
const source = candidate.checkpoint?.worktreeRoot ?? candidate.run.workdir;
|
|
114
|
+
const ok = await ctx.ui.confirm(
|
|
115
|
+
"Resume this run in the CURRENT worktree?",
|
|
116
|
+
`Run ${candidate.runId} was working in:\n ${source}\nYou are in:\n ${path.resolve(ctx.cwd)}\n\nPlanning artifacts are copied (never overwritten); the old execution approval and verified VCs are reset — execution needs re-approval and the VCs are re-verified here. Implementation-review rounds restart from 0 in this worktree (the termination condition is kept).`,
|
|
117
|
+
);
|
|
118
|
+
if (!ok) {
|
|
119
|
+
ctx.ui.notify("Cancelled; nothing was changed.", "info");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (typeof ctx.isIdle === "function" && !ctx.isIdle()) {
|
|
123
|
+
ctx.ui.notify("Agent became busy; run /resume-plans again once the current turn finishes.", "warning");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Ownership: a live foreign owner refuses the resume (D-009).
|
|
129
|
+
let ownerToken: string | null = null;
|
|
130
|
+
let ownerGeneration = 0;
|
|
131
|
+
try {
|
|
132
|
+
const owner = acquireOwnership(ctx.cwd, candidate.runId, { sessionId: sessionKey(ctx) });
|
|
133
|
+
ownerToken = owner.processToken;
|
|
134
|
+
ownerGeneration = owner.generation;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error instanceof OwnershipError) {
|
|
137
|
+
ctx.ui.notify(`/resume-plans: ${error.message}`, "error");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
if (crossWorktree) {
|
|
145
|
+
const migrated = migrateRunIntoCurrentWorktree(ctx.cwd, candidate);
|
|
146
|
+
if (migrated === null) {
|
|
147
|
+
// F-008 (implementation review): an idle session must not keep
|
|
148
|
+
// the lease after an aborted migration.
|
|
149
|
+
releaseOwnership(ctx.cwd, candidate.runId, ownerToken);
|
|
150
|
+
ctx.ui.notify(
|
|
151
|
+
`Artifact migration for ${candidate.runId} failed (target exists with conflicting content); nothing was changed.`,
|
|
152
|
+
"error",
|
|
153
|
+
);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const brief = await buildBrief(pi, ctx, baseDir, candidate);
|
|
159
|
+
if (brief === null) {
|
|
160
|
+
releaseOwnership(ctx.cwd, candidate.runId, ownerToken);
|
|
161
|
+
return; // buildBrief reported the specific problem
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Exactly one kickoff (R-007). The idle re-check happens above and in
|
|
165
|
+
// buildBrief's dialogs; the message itself starts the continuation.
|
|
166
|
+
await pi.sendUserMessage(brief.text);
|
|
167
|
+
ctx.ui.notify(`Resumed ${candidate.runId} (${brief.phaseLabel}).`, "info");
|
|
168
|
+
} catch (error) {
|
|
169
|
+
releaseOwnership(ctx.cwd, candidate.runId, ownerToken);
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function sessionKey(ctx: ExtensionContext): string {
|
|
175
|
+
const session = ctx.sessionManager as unknown as { getSessionId?: () => string };
|
|
176
|
+
try {
|
|
177
|
+
return session.getSessionId?.() ?? null ?? "unknown";
|
|
178
|
+
} catch {
|
|
179
|
+
return "unknown";
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Non-overwriting artifact copy into the current artifact root (D-007). */
|
|
184
|
+
export function migrateRunIntoCurrentWorktree(workdir: string, candidate: ResumeCandidate): string | null {
|
|
185
|
+
const stateRoot = loadConfigShared(workdir);
|
|
186
|
+
if (stateRoot === null) return null;
|
|
187
|
+
const config = loadConfig(stateRoot);
|
|
188
|
+
let artifactRoot = config.artifact_root;
|
|
189
|
+
if (!path.isAbsolute(artifactRoot)) artifactRoot = path.resolve(workdir, artifactRoot);
|
|
190
|
+
const sourceDir = candidate.run.artifact_dir;
|
|
191
|
+
if (!existsSync(sourceDir)) return null;
|
|
192
|
+
// Artifacts already in a shared location stay put.
|
|
193
|
+
const gitRoot = findGitCommonDir(workdir);
|
|
194
|
+
if (gitRoot !== null && isInside(sourceDir, path.join(gitRoot, "pi_plans"))) return sourceDir;
|
|
195
|
+
const targetDir = path.join(artifactRoot, path.basename(sourceDir));
|
|
196
|
+
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
|
|
197
|
+
if (!entry.isFile()) continue;
|
|
198
|
+
const from = path.join(sourceDir, entry.name);
|
|
199
|
+
const to = path.join(targetDir, entry.name);
|
|
200
|
+
if (!existsSync(to)) continue;
|
|
201
|
+
// F-003 (implementation review): an existing target with DIFFERING
|
|
202
|
+
// content aborts the migration — ownership and references never move
|
|
203
|
+
// onto foreign bytes. Identical bytes are a harmless no-op.
|
|
204
|
+
let same = false;
|
|
205
|
+
try {
|
|
206
|
+
same = fs.readFileSync(from, "utf8") === fs.readFileSync(to, "utf8");
|
|
207
|
+
} catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
if (!same) return null;
|
|
211
|
+
}
|
|
212
|
+
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
|
|
213
|
+
if (!entry.isFile()) continue;
|
|
214
|
+
const from = path.join(sourceDir, entry.name);
|
|
215
|
+
const to = path.join(targetDir, entry.name);
|
|
216
|
+
if (existsSync(to)) continue; // identical bytes: skip
|
|
217
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
218
|
+
fs.copyFileSync(from, to);
|
|
219
|
+
}
|
|
220
|
+
// Record the migration in the checkpoint + run.json.
|
|
221
|
+
const worktreeRoot = resolveWorktreeRoot(workdir) ?? path.resolve(workdir);
|
|
222
|
+
mutateCheckpoint(workdir, candidate.runId, (cp) =>
|
|
223
|
+
applyMigration(cp, { workdir, worktreeRoot, commonDir: gitRoot ?? path.resolve(workdir, ".git") }),
|
|
224
|
+
);
|
|
225
|
+
if (candidate.checkpoint?.plan) {
|
|
226
|
+
// Re-point the plan reference ONLY when the copy is byte-identical to
|
|
227
|
+
// the recorded digest (F-003).
|
|
228
|
+
const copiedPlan = path.join(targetDir, path.basename(candidate.checkpoint.plan.path));
|
|
229
|
+
if (existsSync(copiedPlan) && sha256File(copiedPlan) === candidate.checkpoint.plan.sha256) {
|
|
230
|
+
mutateCheckpoint(workdir, candidate.runId, (cp) => ({
|
|
231
|
+
...cp,
|
|
232
|
+
plan: cp.plan ? { ...cp.plan, path: copiedPlan } : cp.plan,
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
updateRunWorkdir(workdir, candidate.runId, workdir);
|
|
237
|
+
return targetDir;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function loadConfigShared(workdir: string): string | null {
|
|
241
|
+
return resolveStateRootOrNull(workdir);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function findGitCommonDir(workdir: string): string | null {
|
|
245
|
+
const root = resolveStateRootOrNull(workdir);
|
|
246
|
+
return root === null ? null : path.dirname(root);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function isInside(child: string, parent: string): boolean {
|
|
250
|
+
const resolvedChild = path.resolve(child);
|
|
251
|
+
const resolvedParent = path.resolve(parent);
|
|
252
|
+
return resolvedChild === resolvedParent || resolvedChild.startsWith(`${resolvedParent}${path.sep}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export interface ResumeBrief {
|
|
256
|
+
phaseLabel: string;
|
|
257
|
+
text: string;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function buildBrief(
|
|
261
|
+
pi: ExtensionAPI,
|
|
262
|
+
ctx: ExtensionContext,
|
|
263
|
+
baseDir: string,
|
|
264
|
+
candidate: ResumeCandidate,
|
|
265
|
+
): Promise<ResumeBrief | null> {
|
|
266
|
+
const runId = candidate.runId;
|
|
267
|
+
// F-005 reconcile (implementation review): the answered ledger entry wins
|
|
268
|
+
// over a stale pending question left by a crash between the two writes.
|
|
269
|
+
const cp = candidate.checkpoint === null
|
|
270
|
+
? null
|
|
271
|
+
: reconcileCheckpointWithLedger(ctx.cwd, runId, candidate.checkpoint);
|
|
272
|
+
const run = candidate.run;
|
|
273
|
+
|
|
274
|
+
if (cp === null) {
|
|
275
|
+
// Legacy run (R-008): rebuild context from artifacts; ask the user
|
|
276
|
+
// only for the missing essentials.
|
|
277
|
+
return buildLegacyBrief(ctx, baseDir, candidate);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (cp.phase === "completed") {
|
|
281
|
+
ctx.ui.notify(`${runId} is completed; nothing to resume.`, "info");
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
bindRun(ctx.sessionManager, ctx.cwd, runId);
|
|
286
|
+
|
|
287
|
+
if (cp.phase === "executing") {
|
|
288
|
+
const load = loadExecutionFromCheckpoint(pi, ctx, runId);
|
|
289
|
+
if (load.status === "loaded") {
|
|
290
|
+
if (run.status === "stopped" || run.status === "accepted") {
|
|
291
|
+
try {
|
|
292
|
+
setRunStatus(ctx.cwd, runId, "executing");
|
|
293
|
+
} catch {
|
|
294
|
+
/* best-effort */
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const doneList = (load.doneVcIds ?? []).join(", ") || "none";
|
|
298
|
+
const reverify = load.reverifyAll
|
|
299
|
+
? `\nThe code state (HEAD) changed since approval: the authorization is KEPT, but every previously verified VC must be re-verified before new work counts. Historically verified (evidence only): ${doneList}.`
|
|
300
|
+
: `\nPreviously verified and still valid: ${doneList}.`;
|
|
301
|
+
const paused = load.pausedReason ? `\nExecution was paused: ${load.pausedReason}. Continue from where it stopped.` : "";
|
|
302
|
+
return {
|
|
303
|
+
phaseLabel: "executing",
|
|
304
|
+
text: `[PI-PLANS RESUME] Execution of run ${runId} continues in this session.\nPlan: ${load.planPath}${reverify}${paused}\nFollow the execution-loop contract: implement in dependency order, verify each VC, and mark completions with [DONE:VC-xxx]. The remaining checklist is injected each turn.`,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
if (load.status === "plan-missing" || load.status === "plan-mismatch") {
|
|
308
|
+
ctx.ui.notify(`Cannot resume ${runId}: ${load.error ?? "plan file missing"}.`, "error");
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
if (load.status === "corrupt") {
|
|
312
|
+
ctx.ui.notify(`Cannot resume ${runId}: corrupt checkpoint (${load.error}).`, "error");
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
ctx.ui.notify(`Cannot resume ${runId}: no in-flight execution in the checkpoint.`, "error");
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (cp.phase === "implementation-review") {
|
|
320
|
+
const review = cp.implementationReview;
|
|
321
|
+
const condition = review?.terminationCondition;
|
|
322
|
+
const lines: string[] = [
|
|
323
|
+
`[PI-PLANS RESUME] Implementation review of run ${runId} continues in this session.`,
|
|
324
|
+
`Plan: ${cp.plan?.path ?? "(unknown)"}`,
|
|
325
|
+
];
|
|
326
|
+
if (condition === undefined) {
|
|
327
|
+
lines.push(
|
|
328
|
+
`The termination condition was never chosen. Ask it now via ask_choice (autoComplete: false, questionId: "termination-condition"): "How should the implementation-review loop terminate?" Options: goal wait (recommended) / until no high-severity finding (hard cap 5 rounds) / 1 / 2 / 3 rounds. Then persist with plans record-checkpoint (checkpoint: { transition: "implementation-review-configured", terminationCondition: ... }).`,
|
|
329
|
+
);
|
|
330
|
+
} else {
|
|
331
|
+
lines.push(`Termination condition: ${condition}`);
|
|
332
|
+
lines.push(`Completed rounds in this worktree: ${review?.completedRounds ?? 0} (hard cap 5).`);
|
|
333
|
+
}
|
|
334
|
+
const currentRound = review?.currentRoundId
|
|
335
|
+
? cp.reviewRounds.find((round) => round.roundId === review.currentRoundId)
|
|
336
|
+
: undefined;
|
|
337
|
+
if (currentRound) {
|
|
338
|
+
const done = currentRound.lanes.filter((lane) => lane.status === "complete").map((lane) => lane.laneId);
|
|
339
|
+
const pending = currentRound.lanes.filter((lane) => lane.status !== "complete").map((lane) => lane.laneId);
|
|
340
|
+
lines.push(
|
|
341
|
+
`Round ${currentRound.roundId} is in flight — complete lanes: ${done.join(", ") || "none"}; pending/failed lanes: ${pending.join(", ") || "none"}. Resume it with refine (role: "reviewer", target: "implementation", resumeRoundId: "${currentRound.roundId}") so completed lanes are reused, never re-run.`,
|
|
342
|
+
);
|
|
343
|
+
} else {
|
|
344
|
+
lines.push(
|
|
345
|
+
`Start the next round with refine (role: "reviewer", target: "implementation") — do NOT pass a resumeRoundId unless resuming an interrupted round.`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
lines.push(
|
|
349
|
+
`Record boundaries with plans record-checkpoint: review-consolidated → implementation-round-finished per round; completed (with evidence) when the termination condition is met.`,
|
|
350
|
+
);
|
|
351
|
+
return { phaseLabel: "implementation-review", text: lines.join("\n") };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// planning / reviewing: rebuild the workflow context (R-002/R-003).
|
|
355
|
+
const skillDir = path.join(baseDir, "skills", run.skill);
|
|
356
|
+
const skillPath = existsSync(path.join(skillDir, "SKILL.md"))
|
|
357
|
+
? path.join(skillDir, "SKILL.md")
|
|
358
|
+
: path.join(baseDir, "skills", "planning", "SKILL.md");
|
|
359
|
+
const lines: string[] = [
|
|
360
|
+
`[PI-PLANS RESUME] Planning run ${runId} continues in this session (phase: ${cp.phase}).`,
|
|
361
|
+
`Original request: ${run.request_text}`,
|
|
362
|
+
`Skill: read ${skillPath} and follow its contract (do NOT re-run start-run; this run already exists).`,
|
|
363
|
+
`Artifact directory: ${run.artifact_dir}`,
|
|
364
|
+
];
|
|
365
|
+
if (cp.plan) {
|
|
366
|
+
lines.push(`Current plan: ${cp.plan.path} (v${cp.plan.version}, digest ${cp.plan.sha256.slice(0, 12)}…). Higher versions in the directory are NOT approved for execution without a new handoff.`);
|
|
367
|
+
} else {
|
|
368
|
+
lines.push("No plan version recorded yet — continue the interview.");
|
|
369
|
+
}
|
|
370
|
+
const answered = cp.answeredQuestions;
|
|
371
|
+
if (answered.length > 0) {
|
|
372
|
+
lines.push(`Already answered (do NOT re-ask):`);
|
|
373
|
+
for (const entry of answered.slice(-12)) {
|
|
374
|
+
lines.push(`- [${entry.questionId}] ${entry.answer} (${entry.source})`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (cp.pendingQuestion) {
|
|
378
|
+
lines.push(
|
|
379
|
+
`PENDING question (re-ask exactly this via ask_choice with the same questionId): [${cp.pendingQuestion.questionId}] ${cp.pendingQuestion.question} Options: ${cp.pendingQuestion.options.join(" / ")}`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
for (const round of cp.reviewRounds) {
|
|
383
|
+
if (round.consolidated) continue;
|
|
384
|
+
const done = round.lanes.filter((lane) => lane.status === "complete").map((lane) => lane.laneId);
|
|
385
|
+
const pending = round.lanes.filter((lane) => lane.status !== "complete").map((lane) => lane.laneId);
|
|
386
|
+
if (done.length === 0 && pending.length === 0) continue;
|
|
387
|
+
lines.push(
|
|
388
|
+
`Review round ${round.roundId} (${round.role}/${round.target}) is unfinished — completed lanes: ${done.join(", ") || "none"}; pending: ${pending.join(", ") || "none"}. Resume with refine (resumeRoundId: "${round.roundId}") so completed lanes are reused; then consolidate and record via plans record-checkpoint (transition: "review-consolidated").`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
lines.push(`Next action: ${cp.nextAction}. Continue the planning workflow — only the missing work, never a full restart.`);
|
|
392
|
+
return { phaseLabel: cp.phase, text: lines.join("\n") };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Legacy runs without checkpoints (R-008): rebuild what is provable, ask for the rest. */
|
|
396
|
+
async function buildLegacyBrief(
|
|
397
|
+
ctx: ExtensionContext,
|
|
398
|
+
baseDir: string,
|
|
399
|
+
candidate: ResumeCandidate,
|
|
400
|
+
): Promise<ResumeBrief | null> {
|
|
401
|
+
const run = candidate.run;
|
|
402
|
+
const ledger = readDecisionLedger(ctx.cwd, candidate.runId);
|
|
403
|
+
const skillDir = path.join(baseDir, "skills", run.skill);
|
|
404
|
+
const skillPath = existsSync(path.join(skillDir, "SKILL.md"))
|
|
405
|
+
? path.join(skillDir, "SKILL.md")
|
|
406
|
+
: path.join(baseDir, "skills", "planning", "SKILL.md");
|
|
407
|
+
const lines: string[] = [
|
|
408
|
+
`[PI-PLANS RESUME] Legacy run ${candidate.runId} (no checkpoint) continues in this session.`,
|
|
409
|
+
`Original request: ${run.request_text}`,
|
|
410
|
+
`Skill: read ${skillPath} and follow its contract (do NOT re-run start-run).`,
|
|
411
|
+
`Artifact directory: ${run.artifact_dir}`,
|
|
412
|
+
];
|
|
413
|
+
if (ledger.length > 0) {
|
|
414
|
+
lines.push(`Recorded decisions (do NOT re-ask):`);
|
|
415
|
+
for (const entry of ledger.slice(-12)) {
|
|
416
|
+
lines.push(`- ${entry.question ?? "?"} → ${entry.answer ?? "?"}`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// F-009 (implementation review): legacy resumes bind too, so attribution
|
|
420
|
+
// does not fall back to the shared pointer for the rest of the session.
|
|
421
|
+
bindRun(ctx.sessionManager, ctx.cwd, candidate.runId);
|
|
422
|
+
if (run.status === "executing" || run.status === "stopped") {
|
|
423
|
+
const ok = await ctx.ui.confirm(
|
|
424
|
+
"Legacy execution run",
|
|
425
|
+
`${candidate.runId} has no durable approval evidence (created before checkpoints). Execution must be re-approved: the plan's verified progress cannot be proven, so VCs start unverified. Continue to the execution handoff question?`,
|
|
426
|
+
);
|
|
427
|
+
if (!ok) {
|
|
428
|
+
ctx.ui.notify("Cancelled; nothing was changed.", "info");
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
lines.push(
|
|
432
|
+
`This run predates durable checkpoints: treat every VC as unverified and re-run the execution handoff (execute_plan or /plans-execute) for explicit approval before writing any code.`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
if (candidate.run.status === "done") {
|
|
436
|
+
const ok = await ctx.ui.confirm(
|
|
437
|
+
"Finished run with review artifacts",
|
|
438
|
+
`${candidate.runId} is marked done and has review records, but completion of the implementation-review loop cannot be proven for legacy runs. Resume the review loop anyway?`,
|
|
439
|
+
);
|
|
440
|
+
if (!ok) {
|
|
441
|
+
ctx.ui.notify("Cancelled; nothing was changed.", "info");
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
lines.push(
|
|
445
|
+
`Resume the implementation-review loop: ask the termination condition (questionId: "termination-condition") if unknown, then run rounds with refine (target: "implementation").`,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
lines.push(`Continue only the missing work; never restart the interview from scratch.`);
|
|
449
|
+
return { phaseLabel: candidate.phaseLabel, text: lines.join("\n") };
|
|
450
|
+
}
|
package/src/resume.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume candidate discovery and legacy reconstruction (I-006).
|
|
3
|
+
*
|
|
4
|
+
* Read-only: enumerating resumable runs never mutates state. Discovery is
|
|
5
|
+
* repo-wide (the shared common dir, including linked worktrees, per D-006),
|
|
6
|
+
* with the active pointer as a priority hint only (D-001).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { getRun, readActive, resolveStateRootOrNull, runDirPath, type RunInfo } from "./state.ts";
|
|
13
|
+
import { loadCheckpoint, mutateCheckpoint, type WorkflowCheckpoint } from "./workflow-state.ts";
|
|
14
|
+
|
|
15
|
+
export interface ResumeCandidate {
|
|
16
|
+
runId: string;
|
|
17
|
+
run: RunInfo;
|
|
18
|
+
/** Checkpoint state when one exists (new runs); null for legacy runs. */
|
|
19
|
+
checkpoint: WorkflowCheckpoint | null;
|
|
20
|
+
checkpointStatus: "ok" | "missing" | "corrupt";
|
|
21
|
+
checkpointError?: string;
|
|
22
|
+
/** Resumable phase for display: checkpoint phase, else run-status-derived. */
|
|
23
|
+
phaseLabel: string;
|
|
24
|
+
/** True when the run's recorded workdir/worktree differs from `workdir`. */
|
|
25
|
+
crossWorktree: boolean;
|
|
26
|
+
planVersion: number | null;
|
|
27
|
+
updatedAt: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const RESUMABLE_RUN_STATUSES = new Set(["planning", "accepted", "executing", "stopped"]);
|
|
31
|
+
|
|
32
|
+
/** Terminal runs are resumable only when unfinished implementation-review evidence exists (D-008). */
|
|
33
|
+
function legacyDoneResumable(run: RunInfo, checkpoint: WorkflowCheckpoint | null): boolean {
|
|
34
|
+
if (checkpoint !== null) {
|
|
35
|
+
return checkpoint.phase !== "completed";
|
|
36
|
+
}
|
|
37
|
+
// Legacy done run: resumable only with unfinished review artifacts on disk.
|
|
38
|
+
const artifactDir = run.artifact_dir;
|
|
39
|
+
if (!artifactDir || !existsSync(artifactDir)) return false;
|
|
40
|
+
try {
|
|
41
|
+
const names = fs.readdirSync(artifactDir);
|
|
42
|
+
const hasReview = names.some((name) => /_reviewer_comments\.md$|_implementation_review\.md$/.test(name));
|
|
43
|
+
if (!hasReview) return false;
|
|
44
|
+
// Heuristic evidence of an UNFINISHED loop: an implementation review
|
|
45
|
+
// record exists but the ledger shows no completed close-out. We cannot
|
|
46
|
+
// prove completion for legacy runs — surface them and let the command
|
|
47
|
+
// ask the user (R-008: never guess).
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function phaseLabelOf(run: RunInfo, checkpoint: WorkflowCheckpoint | null): string {
|
|
55
|
+
if (checkpoint !== null) return checkpoint.phase;
|
|
56
|
+
if (run.status === "stopped") return "executing (stopped)";
|
|
57
|
+
return run.status;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function planVersionOf(checkpoint: WorkflowCheckpoint | null, run: RunInfo): number | null {
|
|
61
|
+
if (checkpoint?.plan) return checkpoint.plan.version;
|
|
62
|
+
// Legacy: highest PLAN_vN in the artifact dir (R-008 fallback).
|
|
63
|
+
if (run.artifact_dir && existsSync(run.artifact_dir)) {
|
|
64
|
+
try {
|
|
65
|
+
let best: number | null = null;
|
|
66
|
+
for (const name of fs.readdirSync(run.artifact_dir)) {
|
|
67
|
+
const match = name.match(/^PLAN_v(\d+)\.(md|markdown)$/i);
|
|
68
|
+
if (match) {
|
|
69
|
+
const version = Number(match[1]);
|
|
70
|
+
if (best === null || version > best) best = version;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return best;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Enumerate resumable runs for the repo containing `workdir`. Corrupt
|
|
83
|
+
* checkpoints are surfaced (not hidden) so the command can report them;
|
|
84
|
+
* read errors never abort discovery of other runs.
|
|
85
|
+
*/
|
|
86
|
+
export function listResumeCandidates(workdir: string): ResumeCandidate[] {
|
|
87
|
+
const stateRoot = resolveStateRootOrNull(workdir);
|
|
88
|
+
if (stateRoot === null) return [];
|
|
89
|
+
const runsDir = path.join(stateRoot, "runs");
|
|
90
|
+
if (!existsSync(runsDir)) return [];
|
|
91
|
+
const active = readActive(workdir);
|
|
92
|
+
const candidates: ResumeCandidate[] = [];
|
|
93
|
+
let entries: string[] = [];
|
|
94
|
+
try {
|
|
95
|
+
entries = fs.readdirSync(runsDir);
|
|
96
|
+
} catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
for (const runId of entries) {
|
|
100
|
+
const run = getRun(workdir, runId);
|
|
101
|
+
if (!run) continue;
|
|
102
|
+
const runDir = runDirPath(workdir, runId);
|
|
103
|
+
if (runDir === null) continue;
|
|
104
|
+
const load = loadCheckpoint(workdir, runId);
|
|
105
|
+
let checkpoint: WorkflowCheckpoint | null = null;
|
|
106
|
+
let checkpointStatus: ResumeCandidate["checkpointStatus"] = "missing";
|
|
107
|
+
let checkpointError: string | undefined;
|
|
108
|
+
if (load.status === "ok") {
|
|
109
|
+
checkpoint = load.checkpoint;
|
|
110
|
+
checkpointStatus = "ok";
|
|
111
|
+
} else if (load.status === "corrupt") {
|
|
112
|
+
checkpointStatus = "corrupt";
|
|
113
|
+
checkpointError = load.error;
|
|
114
|
+
}
|
|
115
|
+
const resumable =
|
|
116
|
+
RESUMABLE_RUN_STATUSES.has(run.status) ||
|
|
117
|
+
(run.status === "done" && legacyDoneResumable(run, checkpoint));
|
|
118
|
+
if (!resumable) continue;
|
|
119
|
+
const recordedWorkdir = checkpoint?.workdir ?? run.workdir;
|
|
120
|
+
const crossWorktree = path.resolve(recordedWorkdir) !== path.resolve(workdir);
|
|
121
|
+
candidates.push({
|
|
122
|
+
runId,
|
|
123
|
+
run,
|
|
124
|
+
checkpoint,
|
|
125
|
+
checkpointStatus,
|
|
126
|
+
checkpointError,
|
|
127
|
+
phaseLabel: phaseLabelOf(run, checkpoint),
|
|
128
|
+
crossWorktree,
|
|
129
|
+
planVersion: planVersionOf(checkpoint, run),
|
|
130
|
+
updatedAt: checkpoint?.updatedAt ?? run.updated_at,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
// Active pointer first (priority, not exclusivity), then newest updated.
|
|
134
|
+
candidates.sort((a, b) => {
|
|
135
|
+
const aActive = active?.run_id === a.runId ? 1 : 0;
|
|
136
|
+
const bActive = active?.run_id === b.runId ? 1 : 0;
|
|
137
|
+
if (aActive !== bActive) return bActive - aActive;
|
|
138
|
+
return b.updatedAt.localeCompare(a.updatedAt);
|
|
139
|
+
});
|
|
140
|
+
return candidates;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Pick the default candidate: active-unfinished first, else unique candidate (D-001). */
|
|
144
|
+
export function pickDefaultCandidate(workdir: string, candidates: ResumeCandidate[]): ResumeCandidate | null {
|
|
145
|
+
if (candidates.length === 0) return null;
|
|
146
|
+
const active = readActive(workdir);
|
|
147
|
+
const activeCandidate = active ? candidates.find((candidate) => candidate.runId === active.run_id) ?? null : null;
|
|
148
|
+
if (activeCandidate !== null) return activeCandidate;
|
|
149
|
+
if (candidates.length === 1) return candidates[0]!;
|
|
150
|
+
return null; // ambiguous: the command must ask
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface DecisionLedgerEntry {
|
|
154
|
+
question?: string;
|
|
155
|
+
answer?: string;
|
|
156
|
+
answer_source?: string;
|
|
157
|
+
artifact?: string;
|
|
158
|
+
questionId?: string;
|
|
159
|
+
recorded_at?: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* F-005 reconcile (implementation review): a crash between the decisions-ledger
|
|
164
|
+
* append and the checkpoint's pending-clear leaves the same question both
|
|
165
|
+
* answered and pending. The answered ledger entry wins — drop the stale
|
|
166
|
+
* pending question (ids match) and persist the reconciled checkpoint.
|
|
167
|
+
*/
|
|
168
|
+
export function reconcileCheckpointWithLedger(workdir: string, runId: string, checkpoint: WorkflowCheckpoint): WorkflowCheckpoint {
|
|
169
|
+
const ledger = readDecisionLedger(workdir, runId);
|
|
170
|
+
const answeredIds = new Set(
|
|
171
|
+
ledger.filter((entry) => typeof entry.questionId === "string").map((entry) => entry.questionId),
|
|
172
|
+
);
|
|
173
|
+
if (checkpoint.pendingQuestion === null || !answeredIds.has(checkpoint.pendingQuestion.questionId)) {
|
|
174
|
+
return checkpoint;
|
|
175
|
+
}
|
|
176
|
+
// The ledger (not the checkpoint) carries the answer: drop the stale
|
|
177
|
+
// pending entry directly and persist the reconciled state.
|
|
178
|
+
try {
|
|
179
|
+
mutateCheckpoint(workdir, runId, (cp) =>
|
|
180
|
+
cp.pendingQuestion !== null && answeredIds.has(cp.pendingQuestion.questionId)
|
|
181
|
+
? { ...cp, pendingQuestion: null }
|
|
182
|
+
: cp,
|
|
183
|
+
);
|
|
184
|
+
} catch {
|
|
185
|
+
/* corrupt/missing checkpoints are handled by the caller */
|
|
186
|
+
}
|
|
187
|
+
return { ...checkpoint, pendingQuestion: null };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Read a legacy run's decision ledger for the resume brief (R-008). */
|
|
191
|
+
export function readDecisionLedger(workdir: string, runId: string): DecisionLedgerEntry[] {
|
|
192
|
+
const runDir = runDirPath(workdir, runId);
|
|
193
|
+
if (runDir === null) return [];
|
|
194
|
+
const ledger = path.join(runDir, "decisions.jsonl");
|
|
195
|
+
if (!existsSync(ledger)) return [];
|
|
196
|
+
try {
|
|
197
|
+
return readFileSync(ledger, "utf8")
|
|
198
|
+
.split("\n")
|
|
199
|
+
.map((line) => line.trim())
|
|
200
|
+
.filter(Boolean)
|
|
201
|
+
.map((line) => JSON.parse(line) as DecisionLedgerEntry);
|
|
202
|
+
} catch {
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
}
|