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.
@@ -17,6 +17,7 @@ import {
17
17
  setRole,
18
18
  setRunStatus,
19
19
  showConfig,
20
+ runDirPath,
20
21
  startRun,
21
22
  StateError,
22
23
  testHooks,
@@ -327,3 +328,18 @@ describe("set-role invariants", () => {
327
328
  assert.throws(() => setRole(workdir, { role: "reviewer", confirmed: true, resetConfirmation: true }), StateError);
328
329
  });
329
330
  });
331
+
332
+ describe("runDirPath", () => {
333
+ it("resolves run directories read-only and returns null for unknown runs", () => {
334
+ const workdir = mkWorkdir("run-dir-path");
335
+ initState(workdir);
336
+ const { run } = startRun(workdir, { topic: "rdp", skill: "plan-small", requestText: "t" });
337
+ const dir = runDirPath(workdir, run.run_id);
338
+ assert.ok(dir);
339
+ assert.ok(fs.existsSync(path.join(dir, "run.json")));
340
+ assert.equal(runDirPath(workdir, "20990101T000000Z-nope"), null);
341
+ const notARepo = path.join(tmpRoot, "not-a-repo");
342
+ fs.mkdirSync(notARepo, { recursive: true });
343
+ assert.equal(runDirPath(notARepo, run.run_id), null);
344
+ });
345
+ });
@@ -0,0 +1,432 @@
1
+ /** Workflow checkpoint test suite (node:test, stdlib only) — I-001. */
2
+
3
+ import * as assert from "node:assert/strict";
4
+ import { spawnSync } from "node:child_process";
5
+ import * as fs from "node:fs";
6
+ import * as os from "node:os";
7
+ import * as path from "node:path";
8
+ import { after, before, describe, it } from "node:test";
9
+ import {
10
+ applyCompleted,
11
+ applyExecutionApproved,
12
+ applyExecutionCompleted,
13
+ applyExecutionProgress,
14
+ applyExecutionHeadChanged,
15
+ applyExecutionStopped,
16
+ applyImplementationReviewConfigured,
17
+ applyImplementationRoundFinished,
18
+ applyLaneResult,
19
+ applyMigration,
20
+ applyPlanWritten,
21
+ applyQuestionAnswered,
22
+ applyQuestionAsked,
23
+ applyReviewConsolidated,
24
+ applyReviewRoundStarted,
25
+ checkpointFilePath,
26
+ createCheckpoint,
27
+ loadCheckpoint,
28
+ mutateCheckpoint,
29
+ planIdentityOf,
30
+ readReviewOutput,
31
+ reconcilePendingWithAnswered,
32
+ resolveHeadAt,
33
+ safeRelativePath,
34
+ safeResolveInside,
35
+ sha256File,
36
+ StaleCheckpointError,
37
+ validateCheckpoint,
38
+ writeReviewOutput,
39
+ type ExecutionApproval,
40
+ type WorkflowCheckpoint,
41
+ } from "../src/workflow-state.ts";
42
+ import { acquireOwnership } from "../src/run-ownership.ts";
43
+ import { initState, startRun, StateError } from "../src/state.ts";
44
+
45
+ let tmpRoot: string;
46
+
47
+ function mkWorkdir(name: string): string {
48
+ const dir = path.join(tmpRoot, name);
49
+ fs.mkdirSync(dir, { recursive: true });
50
+ return dir;
51
+ }
52
+
53
+ function git(workdir: string, ...args: string[]): void {
54
+ const result = spawnSync("git", args, { cwd: workdir, encoding: "utf8" });
55
+ assert.equal(result.status, 0, result.stderr ?? "");
56
+ }
57
+
58
+ function setupRun(name: string): { workdir: string; runId: string } {
59
+ const workdir = mkWorkdir(name);
60
+ git(workdir, "init");
61
+ git(workdir, "config", "user.email", "t@example.com");
62
+ git(workdir, "config", "user.name", "T");
63
+ initState(workdir);
64
+ const { run } = startRun(workdir, { topic: "checkpoint-tests", skill: "plan-normal", requestText: "test" });
65
+ return { workdir, runId: run.run_id };
66
+ }
67
+
68
+ function baseCheckpoint(workdir: string, runId: string): WorkflowCheckpoint {
69
+ const loaded = loadCheckpoint(workdir, runId);
70
+ assert.equal(loaded.status, "ok");
71
+ return loaded.checkpoint;
72
+ }
73
+
74
+ before(() => {
75
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-wf-"));
76
+ });
77
+
78
+ after(() => {
79
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
80
+ });
81
+
82
+ describe("create / load / mutate storage", () => {
83
+ it("creates a valid initial checkpoint and reloads it", () => {
84
+ const { workdir, runId } = setupRun("storage-create");
85
+ const created = createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
86
+ assert.equal(created.phase, "planning");
87
+ assert.equal(created.nextAction, "continue-planning");
88
+ assert.equal(created.revision, 1);
89
+ const loaded = loadCheckpoint(workdir, runId);
90
+ assert.equal(loaded.status, "ok");
91
+ assert.equal(loaded.checkpoint.runId, runId);
92
+ });
93
+
94
+ it("refuses to overwrite an existing checkpoint via createCheckpoint", () => {
95
+ const { workdir, runId } = setupRun("storage-no-overwrite");
96
+ createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
97
+ assert.throws(() => createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir }), StateError);
98
+ });
99
+
100
+ it("mutateCheckpoint bumps revisions atomically", () => {
101
+ const { workdir, runId } = setupRun("storage-revision");
102
+ createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
103
+ const next = mutateCheckpoint(workdir, runId, (cp) => ({ ...cp, autoComplete: true }));
104
+ assert.equal(next.revision, 2);
105
+ const loaded = loadCheckpoint(workdir, runId);
106
+ assert.equal(loaded.status, "ok");
107
+ assert.equal(loaded.checkpoint.revision, 2);
108
+ assert.equal(loaded.checkpoint.autoComplete, true);
109
+ });
110
+
111
+ it("distinguishes missing from corrupt and never overwrites corrupt files", () => {
112
+ const { workdir, runId } = setupRun("storage-corrupt");
113
+ createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
114
+ const filePath = checkpointFilePath(workdir, runId);
115
+ assert.ok(filePath);
116
+ const bytes = fs.readFileSync(filePath, "utf8");
117
+ fs.writeFileSync(filePath, "{ not json", "utf8");
118
+ const loaded = loadCheckpoint(workdir, runId);
119
+ assert.equal(loaded.status, "corrupt");
120
+ assert.ok(loaded.status === "corrupt" && loaded.error.includes("invalid JSON"));
121
+ assert.throws(
122
+ () => mutateCheckpoint(workdir, runId, (cp) => cp),
123
+ (error: unknown) => error instanceof StateError && /corrupt/.test(error.message),
124
+ );
125
+ assert.equal(fs.readFileSync(filePath, "utf8"), "{ not json");
126
+ fs.writeFileSync(filePath, bytes, "utf8");
127
+ assert.equal(loadCheckpoint(workdir, runId).status, "ok");
128
+ });
129
+
130
+ it("rejects unknown schema versions as corrupt without touching bytes", () => {
131
+ const { workdir, runId } = setupRun("storage-schema");
132
+ createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
133
+ const filePath = checkpointFilePath(workdir, runId)!;
134
+ const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
135
+ data.schema = 99;
136
+ fs.writeFileSync(filePath, JSON.stringify(data), "utf8");
137
+ const loaded = loadCheckpoint(workdir, runId);
138
+ assert.equal(loaded.status, "corrupt");
139
+ assert.ok(loaded.status === "corrupt" && loaded.error.includes("unsupported version"));
140
+ });
141
+
142
+ it("mutate fails cleanly when the checkpoint is missing", () => {
143
+ const { workdir, runId } = setupRun("storage-missing");
144
+ assert.throws(() => mutateCheckpoint(workdir, runId, (cp) => cp), StateError);
145
+ });
146
+
147
+ it("owner-guarded mutation refuses writes after a takeover", () => {
148
+ const { workdir, runId } = setupRun("storage-owner");
149
+ createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
150
+ const owner = acquireOwnership(workdir, runId);
151
+ const next = mutateCheckpoint(workdir, runId, (cp) => ({ ...cp, autoComplete: true }), {
152
+ owner: { processToken: owner.processToken, generation: owner.generation },
153
+ });
154
+ assert.equal(next.revision, 2);
155
+ // Another session takes over: our owner check must fail.
156
+ const foreign = {
157
+ schema: 1,
158
+ host: owner.host,
159
+ pid: process.pid,
160
+ pidStart: "bogus-start",
161
+ sessionId: null,
162
+ processToken: "foreign-token",
163
+ generation: owner.generation + 1,
164
+ acquiredAt: "2026-09-07T00:00:00Z",
165
+ };
166
+ fs.writeFileSync(path.join(path.dirname(checkpointFilePath(workdir, runId)!), "owner.json"), JSON.stringify(foreign), "utf8");
167
+ assert.throws(
168
+ () => mutateCheckpoint(workdir, runId, (cp) => cp, { owner: { processToken: owner.processToken, generation: owner.generation } }),
169
+ StateError,
170
+ );
171
+ });
172
+
173
+ it("optimistic revision checks reject concurrent writes", () => {
174
+ const { workdir, runId } = setupRun("storage-cas");
175
+ createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
176
+ mutateCheckpoint(workdir, runId, (cp) => ({ ...cp }), { expectRevision: 1 });
177
+ assert.throws(
178
+ () => mutateCheckpoint(workdir, runId, (cp) => cp, { expectRevision: 1 }),
179
+ StaleCheckpointError,
180
+ );
181
+ });
182
+ });
183
+
184
+ describe("validation", () => {
185
+ it("rejects malformed shapes and unexpected keys", () => {
186
+ const { workdir, runId } = setupRun("validate-shapes");
187
+ const cp = createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
188
+ assert.throws(() => validateCheckpoint({ ...cp, phase: "bogus" }), StateError);
189
+ assert.throws(() => validateCheckpoint({ ...cp, nextAction: "bogus" }), StateError);
190
+ assert.throws(() => validateCheckpoint({ ...cp, extraKey: true }), StateError);
191
+ assert.throws(() => validateCheckpoint({ ...cp, revision: 0 }), StateError);
192
+ assert.throws(() => validateCheckpoint({ ...cp, updatedAt: "yesterday" }), StateError);
193
+ assert.throws(() => validateCheckpoint(null), StateError);
194
+ });
195
+
196
+ it("rejects run ids that could traverse paths", () => {
197
+ const { workdir, runId } = setupRun("validate-runid");
198
+ const cp = createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
199
+ for (const bad of ["../escape", "foo/bar", ".hidden", "a b"]) {
200
+ assert.throws(() => validateCheckpoint({ ...cp, runId: bad }), StateError, bad);
201
+ }
202
+ });
203
+
204
+ it("rejects forged approval shapes", () => {
205
+ const { workdir, runId } = setupRun("validate-approval");
206
+ const cp = createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
207
+ const forged = {
208
+ ...cp,
209
+ execution: {
210
+ approval: { plan: { path: "/tmp/x", version: "one", sha256: "zz" }, worktree: "/tmp", approvedAt: "nope" },
211
+ doneVcIds: ["VC-001"],
212
+ implStatus: {},
213
+ usage: { inToks: 0, outToks: 0 },
214
+ },
215
+ };
216
+ assert.throws(() => validateCheckpoint(forged), StateError);
217
+ });
218
+
219
+ it("completed phase must use nextAction none", () => {
220
+ const { workdir, runId } = setupRun("validate-terminal");
221
+ const cp = createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
222
+ assert.throws(() => validateCheckpoint({ ...cp, phase: "completed", nextAction: "execute-items" }), StateError);
223
+ });
224
+ });
225
+
226
+ describe("safe paths and digests", () => {
227
+ it("safeRelativePath rejects traversal and absolute inputs", () => {
228
+ assert.throws(() => safeRelativePath("../x", "p"), StateError);
229
+ assert.throws(() => safeRelativePath("a/../../b", "p"), StateError);
230
+ assert.throws(() => safeRelativePath(path.resolve("x"), "p"), StateError);
231
+ assert.throws(() => safeRelativePath("", "p"), StateError);
232
+ assert.equal(safeRelativePath("reviews/a__b.md", "p"), "reviews/a__b.md");
233
+ });
234
+
235
+ it("safeResolveInside rejects symlink escapes", () => {
236
+ const root = mkWorkdir("symlink-root");
237
+ const outside = mkWorkdir("symlink-outside");
238
+ fs.writeFileSync(path.join(outside, "payload.md"), "escaped", "utf8");
239
+ fs.symlinkSync(path.join(outside, "payload.md"), path.join(root, "link.md"));
240
+ assert.throws(() => safeResolveInside(root, "link.md", "p"), StateError);
241
+ });
242
+
243
+ it("sha256File hashes full bytes and fails on missing files", () => {
244
+ const file = path.join(tmpRoot, "hash-target.txt");
245
+ fs.writeFileSync(file, "hello", "utf8");
246
+ assert.equal(sha256File(file), "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824");
247
+ assert.throws(() => sha256File(path.join(tmpRoot, "missing")), StateError);
248
+ });
249
+
250
+ it("resolveHeadAt returns null without commits and a sha with commits", () => {
251
+ const empty = mkWorkdir("head-empty");
252
+ git(empty, "init");
253
+ assert.equal(resolveHeadAt(empty), null);
254
+ const committed = mkWorkdir("head-set");
255
+ git(committed, "init");
256
+ git(committed, "config", "user.email", "t@example.com");
257
+ git(committed, "config", "user.name", "T");
258
+ fs.writeFileSync(path.join(committed, "a.txt"), "a", "utf8");
259
+ git(committed, "add", "a.txt");
260
+ git(committed, "commit", "-m", "a");
261
+ assert.match(resolveHeadAt(committed) ?? "", /^[0-9a-f]{40}$/);
262
+ });
263
+ });
264
+
265
+ describe("review outputs", () => {
266
+ it("writes and reads lane outputs inside the run directory", () => {
267
+ const { workdir, runId } = setupRun("reviews-io");
268
+ const rel = writeReviewOutput(workdir, runId, "r1", "lane-a", "# findings\nbody");
269
+ assert.equal(rel, "reviews/r1__lane-a.md");
270
+ assert.equal(readReviewOutput(workdir, runId, rel), "# findings\nbody");
271
+ });
272
+
273
+ it("sanitizes ids and rejects traversal attempts", () => {
274
+ const { workdir, runId } = setupRun("reviews-sanitize");
275
+ assert.throws(() => writeReviewOutput(workdir, runId, "../r", "lane", "x"), StateError);
276
+ assert.throws(() => writeReviewOutput(workdir, runId, "r", "../lane", "x"), StateError);
277
+ assert.throws(() => writeReviewOutput(workdir, runId, "a/b", "lane", "x"), StateError);
278
+ assert.throws(() => readReviewOutput(workdir, runId, "../decisions.jsonl"), StateError);
279
+ });
280
+ });
281
+
282
+ describe("state machine reducers", () => {
283
+ it("question lifecycle: ask, answer, no re-ask (F-005 answered wins)", () => {
284
+ const { workdir, runId } = setupRun("sm-question");
285
+ createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
286
+ let cp = baseCheckpoint(workdir, runId);
287
+ cp = applyQuestionAsked(cp, { questionId: "q1", question: "Q?", options: ["a", "b"] });
288
+ cp = applyQuestionAnswered(cp, "q1", "a", "user");
289
+ assert.equal(cp.pendingQuestion, null);
290
+ assert.equal(cp.answeredQuestions.length, 1);
291
+ // Same id pending while answered → reconcile drops the pending entry.
292
+ const withPending = { ...cp, pendingQuestion: { questionId: "q1", question: "Q?", options: ["a"], askedAt: cp.updatedAt } };
293
+ assert.equal(reconcilePendingWithAnswered(withPending).pendingQuestion, null);
294
+ assert.throws(() => applyQuestionAsked(cp, { questionId: "q1", question: "Q?", options: ["a"] }), StateError);
295
+ assert.throws(() => applyQuestionAnswered(cp, "q1", "b", "user"), StateError);
296
+ // Another question cannot be pending at the same time.
297
+ const two = applyQuestionAsked(cp, { questionId: "q2", question: "Q2?", options: ["a"] });
298
+ assert.throws(
299
+ () => applyQuestionAsked(two, { questionId: "q3", question: "Q3?", options: ["a"] }),
300
+ StateError,
301
+ );
302
+ });
303
+
304
+ it("review rounds: start requires plan; lanes complete then consolidate", () => {
305
+ const { workdir, runId } = setupRun("sm-review");
306
+ createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
307
+ let cp = baseCheckpoint(workdir, runId);
308
+ assert.throws(
309
+ () => applyReviewRoundStarted(cp, { roundId: "r1", role: "reviewer", target: "plan", reviewers: 1, lanes: [{ laneId: "l1" }] }),
310
+ StateError,
311
+ );
312
+ const planPath = path.join(path.dirname(checkpointFilePath(workdir, runId)!), "PLAN_v1.md");
313
+ fs.writeFileSync(planPath, "# plan", "utf8");
314
+ const plan = planIdentityOf(planPath, 1);
315
+ cp = applyPlanWritten(cp, plan);
316
+ cp = { ...cp, phase: "reviewing", nextAction: "run-review" };
317
+ cp = applyReviewRoundStarted(cp, { roundId: "r1", role: "reviewer", target: "plan", reviewers: 2, lanes: [{ laneId: "l1" }, { laneId: "l2" }] });
318
+ assert.throws(() => applyReviewRoundStarted(cp, { roundId: "r1", role: "reviewer", target: "plan", reviewers: 1, lanes: [{ laneId: "l1" }] }), StateError);
319
+ const file1 = writeReviewOutput(workdir, runId, "r1", "l1", "out1");
320
+ cp = applyLaneResult(cp, "r1", "l1", { ok: true, resultFile: file1 });
321
+ // Idempotent replay with the same file is a no-op.
322
+ assert.equal(applyLaneResult(cp, "r1", "l1", { ok: true, resultFile: file1 }), cp);
323
+ assert.throws(() => applyReviewConsolidated(cp, "r1"), StateError);
324
+ cp = applyLaneResult(cp, "r1", "l2", { ok: false, error: "boom" });
325
+ cp = applyReviewConsolidated(cp, "r1");
326
+ assert.equal(cp.reviewRounds[0]!.consolidated, true);
327
+ // Successful lanes must carry a result file.
328
+ cp = applyReviewRoundStarted(cp, { roundId: "r2", role: "reviewer", target: "plan", reviewers: 1, lanes: [{ laneId: "l1" }] });
329
+ const lane = { ...cp.reviewRounds[1]!, lanes: cp.reviewRounds[1]!.lanes.map((l) => ({ ...l, status: "running" as const })) };
330
+ const staged = { ...cp, reviewRounds: [cp.reviewRounds[0]!, lane] };
331
+ assert.throws(() => applyLaneResult(staged, "r2", "l1", { ok: true }), StateError);
332
+ });
333
+
334
+ it("implementation review: condition first, rounds counted, completed needs evidence (F-004)", () => {
335
+ const { workdir, runId } = setupRun("sm-impl");
336
+ createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
337
+ let cp = baseCheckpoint(workdir, runId);
338
+ const planPath = path.join(path.dirname(checkpointFilePath(workdir, runId)!), "PLAN_v1.md");
339
+ fs.writeFileSync(planPath, "# plan", "utf8");
340
+ const plan = planIdentityOf(planPath, 1);
341
+ cp = { ...cp, plan, phase: "implementation-review", nextAction: "ask-question" };
342
+ assert.throws(
343
+ () => applyReviewRoundStarted(cp, { roundId: "i1", role: "reviewer", target: "implementation", reviewers: 1, lanes: [{ laneId: "l1" }] }),
344
+ StateError,
345
+ );
346
+ cp = applyImplementationReviewConfigured(cp, "until-no-high");
347
+ assert.throws(() => applyImplementationReviewConfigured(cp, "again"), StateError);
348
+ cp = applyReviewRoundStarted(cp, { roundId: "i1", role: "reviewer", target: "implementation", reviewers: 1, lanes: [{ laneId: "l1" }] });
349
+ const file = writeReviewOutput(workdir, runId, "i1", "l1", "out");
350
+ cp = applyLaneResult(cp, "i1", "l1", { ok: true, resultFile: file });
351
+ assert.throws(() => applyImplementationRoundFinished(cp), StateError);
352
+ cp = applyReviewConsolidated(cp, "i1");
353
+ cp = applyImplementationRoundFinished(cp);
354
+ assert.equal(cp.implementationReview?.completedRounds, 1);
355
+ // Completion requires evidence AND a termination condition.
356
+ assert.throws(() => applyCompleted({ ...cp, implementationReview: undefined }, "evidence"), StateError);
357
+ assert.throws(() => applyCompleted(cp, " "), StateError);
358
+ const done = applyCompleted(cp, "no findings in final round");
359
+ assert.equal(done.phase, "completed");
360
+ assert.equal(done.nextAction, "none");
361
+ });
362
+
363
+ it("execution approval and progress (D-003/D-011)", () => {
364
+ const { workdir, runId } = setupRun("sm-exec");
365
+ createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
366
+ let cp = baseCheckpoint(workdir, runId);
367
+ const planPath = path.join(path.dirname(checkpointFilePath(workdir, runId)!), "PLAN_v1.md");
368
+ fs.writeFileSync(planPath, "# plan", "utf8");
369
+ const plan = planIdentityOf(planPath, 1);
370
+ cp = applyPlanWritten(cp, plan);
371
+ const approval: ExecutionApproval = {
372
+ plan,
373
+ worktree: cp.worktreeRoot,
374
+ headAtApproval: null,
375
+ approvedAt: cp.updatedAt,
376
+ };
377
+ assert.throws(() => applyExecutionApproved(cp, approval), StateError);
378
+ cp = { ...cp, nextAction: "accept-execute" };
379
+ cp = applyExecutionApproved(cp, approval);
380
+ assert.equal(cp.phase, "executing");
381
+ assert.deepEqual(cp.execution?.doneVcIds, []);
382
+ cp = applyExecutionProgress(cp, { doneVcIds: ["VC-001"], usage: { inToks: 10, outToks: 5 } });
383
+ assert.deepEqual(cp.execution?.doneVcIds, ["VC-001"]);
384
+ assert.equal(cp.execution?.usage.inToks, 10);
385
+ // Mismatched plan hash is rejected.
386
+ const other = { ...approval, plan: { ...plan, sha256: planIdentityOf(planPath, 1).sha256.replace(/^./, "f") } };
387
+ const fresh = { ...baseCheckpoint(workdir, runId), plan, nextAction: "accept-execute" };
388
+ assert.throws(() => applyExecutionApproved(fresh, other), StateError);
389
+ // Head change keeps authorization but forces re-verification (F-001).
390
+ cp = applyExecutionHeadChanged(cp);
391
+ assert.equal(cp.execution?.approval, approval);
392
+ assert.equal(cp.execution?.reverifyAll, true);
393
+ cp = applyExecutionStopped(cp, "stopped by user");
394
+ assert.equal(cp.execution?.pausedReason, "stopped by user");
395
+ const resumed = applyExecutionProgress(cp, { pausedReason: null });
396
+ assert.equal(resumed.execution?.pausedReason, undefined);
397
+ // Completion hands over to the implementation-review phase (R-005).
398
+ const finished = applyExecutionCompleted(cp);
399
+ assert.equal(finished.phase, "implementation-review");
400
+ assert.equal(finished.nextAction, "ask-question");
401
+ });
402
+
403
+ it("migration resets rounds and approval, keeps termination (F-003)", () => {
404
+ const { workdir, runId } = setupRun("sm-migrate");
405
+ createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
406
+ const planPath = path.join(path.dirname(checkpointFilePath(workdir, runId)!), "PLAN_v1.md");
407
+ fs.writeFileSync(planPath, "# plan", "utf8");
408
+ const plan = planIdentityOf(planPath, 1);
409
+ let cp: WorkflowCheckpoint = {
410
+ ...baseCheckpoint(workdir, runId),
411
+ plan,
412
+ phase: "implementation-review",
413
+ implementationReview: { terminationCondition: "until-no-high", completedRounds: 3, currentRoundId: undefined },
414
+ execution: {
415
+ approval: { plan, worktree: "/origin/wt", headAtApproval: "a".repeat(40), approvedAt: "2026-09-07T00:00:00Z" },
416
+ doneVcIds: ["VC-001", "VC-002"],
417
+ implStatus: { "I-001": "implemented" },
418
+ usage: { inToks: 1, outToks: 2 },
419
+ },
420
+ };
421
+ const migrated = applyMigration(cp, { workdir: "/target/wt", worktreeRoot: "/target/wt", commonDir: "/target/.git" });
422
+ assert.equal(migrated.implementationReview?.terminationCondition, "until-no-high");
423
+ assert.equal(migrated.implementationReview?.completedRounds, 0);
424
+ assert.equal(migrated.execution?.approval, null);
425
+ assert.deepEqual(migrated.execution?.doneVcIds, []);
426
+ assert.equal(migrated.execution?.usage.inToks, 1);
427
+ // F-007: an implementation-review checkpoint keeps its review ordering
428
+ // after migration instead of becoming approvable.
429
+ assert.equal(migrated.nextAction, "run-review");
430
+ assert.equal(migrated.migration?.fromWorktree, cp.worktreeRoot);
431
+ });
432
+ });
@@ -18,6 +18,7 @@ import { Type } from "typebox";
18
18
  import * as fs from "node:fs";
19
19
  import * as path from "node:path";
20
20
  import { loadConfig, normalizeWorkdir, readActive, recordSubagent, resolveStateRootOrNull, StateError } from "../src/state.ts";
21
+ import { resolveActiveRun } from "../src/run-context.ts";
21
22
  import { buildRefAnalystTask, type RefAnalystTaskInput } from "../src/refine-prompts.ts";
22
23
  import { runPiSubagent, stripFrontmatter } from "../src/subagent.ts";
23
24
  import { RefineOverlayController, refineOverlayContext } from "../src/refine-ui.ts";
@@ -102,7 +103,7 @@ export function registerAnalyzeRefsTool(pi: ExtensionAPI, baseDir: string): void
102
103
 
103
104
  // Resolve refs and validate directories up front; missing ones become
104
105
  // FAILED sections instead of aborting the whole batch.
105
- const active = readActive(workdir);
106
+ const active = resolveActiveRun(ctx.sessionManager, workdir);
106
107
  const jobs: AnalysisJob[] = params.refs.map((ref, index) => {
107
108
  const dir = path.resolve(workdir, ref.localPath.replace(/^@/, ""));
108
109
  const missing = fs.existsSync(dir) && fs.statSync(dir).isDirectory() ? null : `reference directory not found: ${dir}`;
@@ -15,9 +15,11 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
15
  import { Text } from "@earendil-works/pi-tui";
16
16
  import { Type } from "typebox";
17
17
  import { disableAutoComplete, enableAutoComplete, isAutoCompleteEnabled, recordAskChoice } from "../src/autocomplete.ts";
18
- import { TERMINATION_QUESTION, TERMINATION_OPTIONS, renderTerminationOptions } from "../src/termination-prompt.ts";
18
+ import { TERMINATION_QUESTION, TERMINATION_OPTIONS, TERMINATION_RECORDING_INSTRUCTIONS, renderTerminationOptions } from "../src/termination-prompt.ts";
19
19
  import { truncateToWidth, visibleWidth } from "../src/refine-ui-helpers.ts";
20
20
  import { normalizeWorkdir, readActive, recordDecision } from "../src/state.ts";
21
+ import { resolveActiveRun } from "../src/run-context.ts";
22
+ import { applyQuestionAnswered, applyQuestionAsked, loadCheckpoint, mutateCheckpoint } from "../src/workflow-state.ts";
21
23
 
22
24
  // ---------------------------------------------------------------------------
23
25
  // Panel fitting: pi's ExtensionSelectorComponent renders each option as an
@@ -144,6 +146,15 @@ const AskChoiceParams = Type.Object({
144
146
  "Offer the Auto-complete option (default true). MUST be false for the execution handoff, install waivers, publishing, deployment, merge, push, credential use, or any external-state change.",
145
147
  }),
146
148
  ),
149
+ questionId: Type.Optional(
150
+ Type.String({
151
+ description:
152
+ "Stable id for this question so cross-session resume can deduplicate (e.g. 'termination-condition'). Provide one for planning/refinement questions that gate progress.",
153
+ }),
154
+ ),
155
+ purpose: Type.Optional(
156
+ Type.String({ description: "Short machine-readable purpose (e.g. 'scope', 'termination-condition')." }),
157
+ ),
147
158
  trailing: Type.Optional(
148
159
  StringEnum(["auto-refine-loop"] as const, {
149
160
  description:
@@ -176,6 +187,40 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
176
187
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
177
188
  const workdir = normalizeWorkdir(params.workdir ?? ctx.cwd);
178
189
  const allowOther = params.allowOther ?? true;
190
+ // I-003: cross-session question durability. Pending is recorded
191
+ // before the panel opens; the answer is recorded before it returns.
192
+ // Runs without a checkpoint (adhoc, pre-start-run setup questions)
193
+ // skip silently — legacy behavior is unchanged.
194
+ const activeRun = resolveActiveRun(ctx.sessionManager, workdir);
195
+ const hasCheckpoint =
196
+ activeRun !== null && loadCheckpoint(workdir, activeRun.run_id).status === "ok";
197
+ const recordQuestionAsked = (): void => {
198
+ if (!hasCheckpoint || !activeRun || !params.questionId) return;
199
+ try {
200
+ mutateCheckpoint(workdir, activeRun.run_id, (cp) =>
201
+ applyQuestionAsked(cp, {
202
+ questionId: params.questionId!,
203
+ question: params.question,
204
+ options: options.map((option) => option.label),
205
+ purpose: params.purpose,
206
+ allowOther,
207
+ autoComplete,
208
+ }),
209
+ );
210
+ } catch {
211
+ /* checkpoint bookkeeping must not block the question itself */
212
+ }
213
+ };
214
+ const recordQuestionAnswered = (answer: string, source: "user" | "auto-complete" | "other"): void => {
215
+ if (!hasCheckpoint || !activeRun || !params.questionId) return;
216
+ try {
217
+ mutateCheckpoint(workdir, activeRun.run_id, (cp) =>
218
+ applyQuestionAnswered(cp, params.questionId!, answer, source),
219
+ );
220
+ } catch {
221
+ /* the decisions ledger already holds the answer; F-005 reconcile covers the gap */
222
+ }
223
+ };
179
224
  // Param normalization: a trailing option replaces Auto-complete entirely,
180
225
  // so an erroneously passed autoComplete flag is suppressed here.
181
226
  const trailing = params.trailing;
@@ -185,7 +230,7 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
185
230
  const recommended = options.find((option) => option.recommended) ?? options[0];
186
231
 
187
232
  const record = (answer: string, source: AskChoiceDetails["source"]) => {
188
- const active = readActive(workdir);
233
+ const active = resolveActiveRun(ctx.sessionManager, workdir);
189
234
  if (!active) return;
190
235
  try {
191
236
  recordDecision(workdir, active.run_id, {
@@ -193,6 +238,10 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
193
238
  options: options.map((option) => option.label),
194
239
  answer,
195
240
  answer_source: source === "auto-complete" ? "auto-complete" : "user",
241
+ // F-005 reconcile (implementation review): the ledger
242
+ // carries the stable id so resume can drop stale pending
243
+ // questions after a crash between the two writes.
244
+ ...(params.questionId ? { questionId: params.questionId } : {}),
196
245
  });
197
246
  } catch {
198
247
  /* recording is best-effort; the question still gets answered */
@@ -211,6 +260,8 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
211
260
  if (autoComplete && isAutoCompleteEnabled(ctx)) {
212
261
  recordAskChoice(ctx, true);
213
262
  record(recommended.label, "auto-complete");
263
+ recordQuestionAsked();
264
+ recordQuestionAnswered(recommended.label, "auto-complete");
214
265
  return {
215
266
  content: [{ type: "text", text: `Auto-complete selected the recommended option: ${recommended.label}` }],
216
267
  details: details(recommended.label, "auto-complete"),
@@ -229,6 +280,8 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
229
280
  enableAutoComplete(ctx);
230
281
  recordAskChoice(ctx, true);
231
282
  record(recommended.label, "auto-complete");
283
+ recordQuestionAsked();
284
+ recordQuestionAnswered(recommended.label, "auto-complete");
232
285
  return {
233
286
  content: [
234
287
  {
@@ -263,6 +316,7 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
263
316
  ctx.ui.notify?.("Terminal too small: the ask_choice panel may overflow even in its minimal form.", "warning");
264
317
  }
265
318
 
319
+ recordQuestionAsked();
266
320
  const selected = await ctx.ui.select(panel.question, panel.labels);
267
321
  if (selected === undefined) {
268
322
  disableAutoComplete(ctx, "question cancelled");
@@ -281,6 +335,7 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
281
335
  enableAutoComplete(ctx);
282
336
  recordAskChoice(ctx, true);
283
337
  record(recommended.label, "auto-complete");
338
+ recordQuestionAnswered(recommended.label, "auto-complete");
284
339
  return {
285
340
  content: [
286
341
  {
@@ -299,7 +354,7 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
299
354
  content: [
300
355
  {
301
356
  type: "text",
302
- text: `User selected Auto-refine loop. Immediately ask the follow-up with ask_choice (autoComplete: false, in the session language): "${TERMINATION_QUESTION}" Options (recommended first): ${renderTerminationOptions()}. Then run the loop per the completion instructions: each round calls refine (role: "reviewer", target: "implementation"), accepts findings on evidence, applies fixes, re-runs relevant tests, and continues until the chosen termination condition — the goal-wait option keeps the loop running until no unpassed VCs remain.`,
357
+ text: `User selected Auto-refine loop. Immediately ask the follow-up with ask_choice (autoComplete: false, in the session language): "${TERMINATION_QUESTION}" Options (recommended first): ${renderTerminationOptions()}. ${TERMINATION_RECORDING_INSTRUCTIONS} Then run the loop per the completion instructions: each round calls refine (role: "reviewer", target: "implementation"), accepts findings on evidence, applies fixes, re-runs relevant tests, and continues until the chosen termination condition — the goal-wait option keeps the loop running until no unpassed VCs remain.`,
303
358
  },
304
359
  ],
305
360
  details: details("Auto-refine loop", "user"),
@@ -318,6 +373,7 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
318
373
  recordAskChoice(ctx, false);
319
374
  const answer = typed.trim();
320
375
  record(answer, "user");
376
+ recordQuestionAnswered(answer, "other");
321
377
  return {
322
378
  content: [{ type: "text", text: `User wrote: ${answer}` }],
323
379
  details: details(answer, "other"),
@@ -328,6 +384,7 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
328
384
  const option = index >= 0 && index < options.length ? options[index] : undefined;
329
385
  if (!option) {
330
386
  recordAskChoice(ctx, false);
387
+ recordQuestionAnswered(selected, "user");
331
388
  return {
332
389
  content: [{ type: "text", text: `User selected: ${selected}` }],
333
390
  details: details(selected, "user"),
@@ -335,6 +392,7 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
335
392
  }
336
393
  recordAskChoice(ctx, false);
337
394
  record(option.label, "user");
395
+ recordQuestionAnswered(option.label, "user");
338
396
  return {
339
397
  content: [{ type: "text", text: `User selected: ${index + 1}. ${option.label}` }],
340
398
  details: details(option.label, "user"),
@@ -22,6 +22,7 @@ import type { Language } from "../src/code-graph/types.ts";
22
22
  import { screeningQuery } from "../src/code-graph/screening.ts";
23
23
  import { deleteFile, listPending, updateFile, updateFunction } from "../src/code-graph/mutations.ts";
24
24
  import { applyGraphCore } from "../src/code-graph/commands.ts";
25
+ import { boundRunId } from "../src/run-context.ts";
25
26
  import { normalizeWorkdir } from "../src/state.ts";
26
27
 
27
28
  const CodeGraphParams = Type.Object({
@@ -110,7 +111,10 @@ export function registerCodeGraphTool(pi: ExtensionAPI): void {
110
111
  details: {},
111
112
  };
112
113
  }
113
- const core = await applyGraphCore(normalizeWorkdir(workdir));
114
+ const resolvedWorkdir = normalizeWorkdir(workdir);
115
+ const core = await applyGraphCore(resolvedWorkdir, {
116
+ sessionRunId: boundRunId(ctx.sessionManager, resolvedWorkdir),
117
+ });
114
118
  if (core.refused || core.failed || !core.report) {
115
119
  return {
116
120
  content: [{ type: "text", text: JSON.stringify({ ok: false, reason: core.refused ?? core.failed ?? "unknown error" }) }],
@@ -16,6 +16,7 @@ import {
16
16
  import { disableAutoComplete } from "../src/autocomplete.ts";
17
17
  import { latestPlanVersion, parseChecklist, parseImplItems } from "../src/plan.ts";
18
18
  import { normalizeWorkdir, readActive } from "../src/state.ts";
19
+ import { resolveActiveRun } from "../src/run-context.ts";
19
20
 
20
21
 
21
22
  const ExecutePlanParams = Type.Object({
@@ -44,7 +45,7 @@ export async function executeHandoff(
44
45
  if (planPathArg) {
45
46
  planPath = path.resolve(workdir, planPathArg.replace(/^@/, ""));
46
47
  } else {
47
- const active = readActive(workdir);
48
+ const active = resolveActiveRun(ctx.sessionManager, workdir);
48
49
  if (!active) {
49
50
  return {
50
51
  status: "error",