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.
@@ -0,0 +1,1159 @@
1
+ /**
2
+ * Run-level workflow checkpoints for /resume-plans (I-001).
3
+ *
4
+ * A checkpoint is the durable, workspace-owned state of one planning run:
5
+ * logical phase, next action, plan identity, pending/answered questions,
6
+ * review rounds, execution approval evidence, and ownership metadata. Pi
7
+ * session entries keep per-branch execution snapshots; `checkpoint.json` is
8
+ * the cross-session authority that survives session switches and restarts.
9
+ *
10
+ * Contract highlights (PLAN_v2):
11
+ * - Explicit validation: unknown schema versions, malformed shapes, and
12
+ * unexpected keys are rejected — data is never blind-cast into an
13
+ * execution approval.
14
+ * - Missing and corrupt checkpoints are distinct; corrupt files are never
15
+ * silently overwritten.
16
+ * - Writes are atomic (tmp + rename) with monotonic revisions and optional
17
+ * optimistic-concurrency checks.
18
+ * - Review outputs are stored as separate files; the checkpoint keeps only
19
+ * references. All ids are sanitized and resolved strictly inside the run
20
+ * directory.
21
+ * - State-machine reducers enforce whitelisted transitions (e.g. `completed`
22
+ * requires termination evidence) so model-driven `record-checkpoint` calls
23
+ * cannot forge approval or terminal states.
24
+ */
25
+
26
+ import { createHash, randomUUID } from "node:crypto";
27
+ import * as fs from "node:fs";
28
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
29
+ import * as path from "node:path";
30
+ import { StateError, atomicWriteJson, resolveStateRootOrNull, runGit, runDirPath, utcNow } from "./state.ts";
31
+ import { assertOwnership, heldOwnershipRecord } from "./run-ownership.ts";
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Schema
35
+ // ---------------------------------------------------------------------------
36
+
37
+ export const CHECKPOINT_SCHEMA = 1;
38
+
39
+ export type WorkflowPhase =
40
+ | "planning"
41
+ | "reviewing"
42
+ | "executing"
43
+ | "implementation-review"
44
+ | "completed";
45
+
46
+ export type NextAction =
47
+ | "ask-question"
48
+ | "continue-planning"
49
+ | "run-review"
50
+ | "consolidate-review"
51
+ | "revise-plan"
52
+ | "accept-execute"
53
+ | "execute-items"
54
+ | "apply-review-fixes"
55
+ | "verify-review-fixes"
56
+ | "finish-review"
57
+ | "none";
58
+
59
+ export type QuestionSource = "user" | "auto-complete" | "other";
60
+
61
+ export interface PendingQuestion {
62
+ questionId: string;
63
+ question: string;
64
+ options: string[];
65
+ purpose?: string;
66
+ allowOther?: boolean;
67
+ autoComplete?: boolean;
68
+ askedAt: string;
69
+ }
70
+
71
+ export interface AnsweredQuestionRef {
72
+ questionId: string;
73
+ answer: string;
74
+ source: QuestionSource;
75
+ answeredAt: string;
76
+ }
77
+
78
+ export interface PlanIdentity {
79
+ /** Absolute path of the exact PLAN_vN.md this state refers to. */
80
+ path: string;
81
+ /** N parsed from the file name. */
82
+ version: number;
83
+ /** SHA-256 of the full file bytes. */
84
+ sha256: string;
85
+ }
86
+
87
+ export type LaneStatus = "pending" | "running" | "complete" | "failed";
88
+
89
+ export interface ReviewLaneState {
90
+ laneId: string;
91
+ lens?: string;
92
+ status: LaneStatus;
93
+ /** Path relative to the run directory; only for complete lanes. */
94
+ resultFile?: string;
95
+ startedAt?: string;
96
+ completedAt?: string;
97
+ }
98
+
99
+ export interface ReviewRoundState {
100
+ roundId: string;
101
+ role: "reviewer" | "criticizer";
102
+ target: "plan" | "implementation";
103
+ planSha256?: string;
104
+ focus?: string;
105
+ context?: string;
106
+ reviewers: number;
107
+ lanes: ReviewLaneState[];
108
+ consolidated: boolean;
109
+ dispositionArtifact?: string;
110
+ startedAt: string;
111
+ completedAt?: string;
112
+ /** True when this round ran in a different (origin) worktree than the current one. */
113
+ originWorktree?: string;
114
+ }
115
+
116
+ export interface ImplementationReviewState {
117
+ /** Serialized termination condition chosen by the user; undefined = not yet asked. */
118
+ terminationCondition?: string;
119
+ /** Whole rounds fully disposed in the CURRENT worktree (source-worktree rounds are history only). */
120
+ completedRounds: number;
121
+ currentRoundId?: string;
122
+ }
123
+
124
+ export interface ExecutionApproval {
125
+ plan: PlanIdentity;
126
+ /** Absolute worktree root where the approval was given. */
127
+ worktree: string;
128
+ /** `git rev-parse HEAD` at approval time; null = unresolvable (unverifiable code state). */
129
+ headAtApproval: string | null;
130
+ approvedAt: string;
131
+ }
132
+
133
+ export interface ExecutionCheckpoint {
134
+ approval: ExecutionApproval | null;
135
+ doneVcIds: string[];
136
+ implStatus: Record<string, string>;
137
+ currentI?: string;
138
+ usage: { inToks: number; outToks: number };
139
+ pausedReason?: string;
140
+ /** Set when the code state changed after approval: keep authorization, re-verify old VCs first. */
141
+ reverifyAll?: boolean;
142
+ /** True when this approval/progress was produced in a different (origin) worktree. */
143
+ originWorktree?: string;
144
+ }
145
+
146
+ export interface OwnerInfo {
147
+ host: string;
148
+ pid: number;
149
+ sessionId?: string | null;
150
+ processToken: string;
151
+ generation: number;
152
+ acquiredAt: string;
153
+ }
154
+
155
+ export interface MigrationInfo {
156
+ fromWorktree: string;
157
+ migratedAt: string;
158
+ }
159
+
160
+ export interface WorkflowCheckpoint {
161
+ schema: number;
162
+ runId: string;
163
+ /** Monotonic per-write counter. */
164
+ revision: number;
165
+ /** Monotonic ownership epoch; bumped when the active owner changes. */
166
+ generation: number;
167
+ updatedAt: string;
168
+ phase: WorkflowPhase;
169
+ nextAction: NextAction;
170
+ originWorkdir: string;
171
+ workdir: string;
172
+ worktreeRoot: string;
173
+ commonDir: string;
174
+ plan: PlanIdentity | null;
175
+ pendingQuestion: PendingQuestion | null;
176
+ answeredQuestions: AnsweredQuestionRef[];
177
+ reviewRounds: ReviewRoundState[];
178
+ implementationReview?: ImplementationReviewState;
179
+ execution?: ExecutionCheckpoint;
180
+ autoComplete?: boolean;
181
+ owner?: OwnerInfo | null;
182
+ migration?: MigrationInfo | null;
183
+ }
184
+
185
+ const PHASES = new Set<WorkflowPhase>([
186
+ "planning",
187
+ "reviewing",
188
+ "executing",
189
+ "implementation-review",
190
+ "completed",
191
+ ]);
192
+
193
+ const NEXT_ACTIONS = new Set<NextAction>([
194
+ "ask-question",
195
+ "continue-planning",
196
+ "run-review",
197
+ "consolidate-review",
198
+ "revise-plan",
199
+ "accept-execute",
200
+ "execute-items",
201
+ "apply-review-fixes",
202
+ "verify-review-fixes",
203
+ "finish-review",
204
+ "none",
205
+ ]);
206
+
207
+ const LANE_STATUSES = new Set<LaneStatus>(["pending", "running", "complete", "failed"]);
208
+ const QUESTION_SOURCES = new Set<QuestionSource>(["user", "auto-complete", "other"]);
209
+
210
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
211
+ const RUN_ID_RE = /^\d{8}T\d{6}Z-[A-Za-z0-9][A-Za-z0-9._-]*$/;
212
+ const TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
213
+ const SHA256_RE = /^[0-9a-f]{64}$/;
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Validation helpers (explicit, no blind casts)
217
+ // ---------------------------------------------------------------------------
218
+
219
+ class CheckpointValidationError extends StateError {}
220
+
221
+ function asRecord(value: unknown, label: string): Record<string, unknown> {
222
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
223
+ throw new CheckpointValidationError(`${label}: expected an object`);
224
+ }
225
+ return value as Record<string, unknown>;
226
+ }
227
+
228
+ function asString(value: unknown, label: string): string {
229
+ if (typeof value !== "string") throw new CheckpointValidationError(`${label}: expected a string`);
230
+ return value;
231
+ }
232
+
233
+ function asOptionalString(value: unknown, label: string): string | undefined {
234
+ if (value === undefined) return undefined;
235
+ return asString(value, label);
236
+ }
237
+
238
+ function asTimestamp(value: unknown, label: string): string {
239
+ const text = asString(value, label);
240
+ if (!TIMESTAMP_RE.test(text)) throw new CheckpointValidationError(`${label}: malformed UTC timestamp`);
241
+ return text;
242
+ }
243
+
244
+ function asInt(value: unknown, label: string, min: number): number {
245
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min) {
246
+ throw new CheckpointValidationError(`${label}: expected an integer >= ${min}`);
247
+ }
248
+ return value;
249
+ }
250
+
251
+ function asEnum<T extends string>(value: unknown, allowed: Set<T>, label: string): T {
252
+ const text = asString(value, label);
253
+ if (!allowed.has(text as T)) {
254
+ throw new CheckpointValidationError(`${label}: unknown value "${text}"`);
255
+ }
256
+ return text as T;
257
+ }
258
+
259
+ function asBool(value: unknown, label: string): boolean {
260
+ if (typeof value !== "boolean") throw new CheckpointValidationError(`${label}: expected a boolean`);
261
+ return value;
262
+ }
263
+
264
+ function asStringArray(value: unknown, label: string): string[] {
265
+ if (!Array.isArray(value)) throw new CheckpointValidationError(`${label}: expected an array`);
266
+ return value.map((entry, index) => asString(entry, `${label}[${index}]`));
267
+ }
268
+
269
+ function asRecordMap(value: unknown, label: string): Record<string, string> {
270
+ const record = asRecord(value, label);
271
+ const out: Record<string, string> = {};
272
+ for (const [key, entry] of Object.entries(record)) out[key] = asString(entry, `${label}.${key}`);
273
+ return out;
274
+ }
275
+
276
+ function rejectExtraKeys(record: Record<string, unknown>, expected: Set<string>, label: string): void {
277
+ for (const key of Object.keys(record)) {
278
+ if (!expected.has(key)) throw new CheckpointValidationError(`${label}: unexpected key "${key}"`);
279
+ }
280
+ }
281
+
282
+ function asId(value: unknown, label: string): string {
283
+ const text = asString(value, label);
284
+ if (!ID_RE.test(text) || text.includes("..")) throw new CheckpointValidationError(`${label}: invalid id`);
285
+ return text;
286
+ }
287
+
288
+ function asRunId(value: unknown, label: string): string {
289
+ const text = asString(value, label);
290
+ if (!RUN_ID_RE.test(text) || text.includes("..")) throw new CheckpointValidationError(`${label}: invalid run id`);
291
+ return text;
292
+ }
293
+
294
+ function asOptionalId(value: unknown, label: string): string | undefined {
295
+ if (value === undefined) return undefined;
296
+ return asId(value, label);
297
+ }
298
+
299
+ function asPlanIdentity(value: unknown, label: string): PlanIdentity {
300
+ const record = asRecord(value, label);
301
+ rejectExtraKeys(record, new Set(["path", "version", "sha256"]), label);
302
+ const plan: PlanIdentity = {
303
+ path: asString(record.path, `${label}.path`),
304
+ version: asInt(record.version, `${label}.version`, 1),
305
+ sha256: asString(record.sha256, `${label}.sha256`),
306
+ };
307
+ if (!path.isAbsolute(plan.path)) throw new CheckpointValidationError(`${label}.path: must be absolute`);
308
+ if (!SHA256_RE.test(plan.sha256)) throw new CheckpointValidationError(`${label}.sha256: malformed digest`);
309
+ return plan;
310
+ }
311
+
312
+ function asPendingQuestion(value: unknown, label: string): PendingQuestion {
313
+ const record = asRecord(value, label);
314
+ rejectExtraKeys(
315
+ record,
316
+ new Set(["questionId", "question", "options", "purpose", "allowOther", "autoComplete", "askedAt"]),
317
+ label,
318
+ );
319
+ const question: PendingQuestion = {
320
+ questionId: asId(record.questionId, `${label}.questionId`),
321
+ question: asString(record.question, `${label}.question`),
322
+ options: asStringArray(record.options, `${label}.options`),
323
+ askedAt: asTimestamp(record.askedAt, `${label}.askedAt`),
324
+ };
325
+ const purpose = asOptionalString(record.purpose, `${label}.purpose`);
326
+ if (purpose !== undefined) question.purpose = purpose;
327
+ if (record.allowOther !== undefined) question.allowOther = asBool(record.allowOther, `${label}.allowOther`);
328
+ if (record.autoComplete !== undefined) question.autoComplete = asBool(record.autoComplete, `${label}.autoComplete`);
329
+ return question;
330
+ }
331
+
332
+ function asAnsweredQuestion(value: unknown, label: string): AnsweredQuestionRef {
333
+ const record = asRecord(value, label);
334
+ rejectExtraKeys(record, new Set(["questionId", "answer", "source", "answeredAt"]), label);
335
+ return {
336
+ questionId: asId(record.questionId, `${label}.questionId`),
337
+ answer: asString(record.answer, `${label}.answer`),
338
+ source: asEnum(record.source, QUESTION_SOURCES, `${label}.source`),
339
+ answeredAt: asTimestamp(record.answeredAt, `${label}.answeredAt`),
340
+ };
341
+ }
342
+
343
+ function asReviewLane(value: unknown, label: string): ReviewLaneState {
344
+ const record = asRecord(value, label);
345
+ rejectExtraKeys(record, new Set(["laneId", "lens", "status", "resultFile", "startedAt", "completedAt"]), label);
346
+ const lane: ReviewLaneState = {
347
+ laneId: asId(record.laneId, `${label}.laneId`),
348
+ status: asEnum(record.status, LANE_STATUSES, `${label}.status`),
349
+ };
350
+ const lens = asOptionalString(record.lens, `${label}.lens`);
351
+ if (lens !== undefined) lane.lens = lens;
352
+ const resultFile = asOptionalString(record.resultFile, `${label}.resultFile`);
353
+ if (resultFile !== undefined) {
354
+ if (lane.status !== "complete") {
355
+ throw new CheckpointValidationError(`${label}.resultFile: only complete lanes carry results`);
356
+ }
357
+ lane.resultFile = safeRelativePath(resultFile, `${label}.resultFile`);
358
+ }
359
+ if (record.startedAt !== undefined) lane.startedAt = asTimestamp(record.startedAt, `${label}.startedAt`);
360
+ if (record.completedAt !== undefined) lane.completedAt = asTimestamp(record.completedAt, `${label}.completedAt`);
361
+ return lane;
362
+ }
363
+
364
+ function asReviewRound(value: unknown, label: string): ReviewRoundState {
365
+ const record = asRecord(value, label);
366
+ rejectExtraKeys(
367
+ record,
368
+ new Set([
369
+ "roundId", "role", "target", "planSha256", "focus", "context",
370
+ "reviewers", "lanes", "consolidated", "dispositionArtifact", "startedAt", "completedAt", "originWorktree",
371
+ ]),
372
+ label,
373
+ );
374
+ const role = asEnum(record.role, new Set(["reviewer", "criticizer"]), `${label}.role`) as ReviewRoundState["role"];
375
+ const target = asEnum(record.target, new Set(["plan", "implementation"]), `${label}.target`) as ReviewRoundState["target"];
376
+ const round: ReviewRoundState = {
377
+ roundId: asId(record.roundId, `${label}.roundId`),
378
+ role,
379
+ target,
380
+ reviewers: asInt(record.reviewers, `${label}.reviewers`, 1),
381
+ lanes: Array.isArray(record.lanes) ? record.lanes.map((lane, i) => asReviewLane(lane, `${label}.lanes[${i}]`)) : [],
382
+ consolidated: asBool(record.consolidated, `${label}.consolidated`),
383
+ startedAt: asTimestamp(record.startedAt, `${label}.startedAt`),
384
+ };
385
+ if (record.planSha256 !== undefined) {
386
+ const sha = asString(record.planSha256, `${label}.planSha256`);
387
+ if (!SHA256_RE.test(sha)) throw new CheckpointValidationError(`${label}.planSha256: malformed digest`);
388
+ round.planSha256 = sha;
389
+ }
390
+ if (record.focus !== undefined) round.focus = asString(record.focus, `${label}.focus`);
391
+ if (record.context !== undefined) round.context = asString(record.context, `${label}.context`);
392
+ if (record.dispositionArtifact !== undefined) {
393
+ round.dispositionArtifact = safeRelativePath(
394
+ asString(record.dispositionArtifact, `${label}.dispositionArtifact`),
395
+ `${label}.dispositionArtifact`,
396
+ );
397
+ }
398
+ if (record.completedAt !== undefined) round.completedAt = asTimestamp(record.completedAt, `${label}.completedAt`);
399
+ if (record.originWorktree !== undefined) round.originWorktree = asString(record.originWorktree, `${label}.originWorktree`);
400
+ return round;
401
+ }
402
+
403
+ function asImplementationReview(value: unknown, label: string): ImplementationReviewState {
404
+ const record = asRecord(value, label);
405
+ rejectExtraKeys(record, new Set(["terminationCondition", "completedRounds", "currentRoundId"]), label);
406
+ const state: ImplementationReviewState = {
407
+ completedRounds: asInt(record.completedRounds, `${label}.completedRounds`, 0),
408
+ };
409
+ if (record.terminationCondition !== undefined) {
410
+ state.terminationCondition = asString(record.terminationCondition, `${label}.terminationCondition`);
411
+ }
412
+ if (record.currentRoundId !== undefined) {
413
+ state.currentRoundId = asOptionalId(record.currentRoundId, `${label}.currentRoundId`);
414
+ }
415
+ return state;
416
+ }
417
+
418
+ function asExecutionApproval(value: unknown, label: string): ExecutionApproval {
419
+ const record = asRecord(value, label);
420
+ rejectExtraKeys(record, new Set(["plan", "worktree", "headAtApproval", "approvedAt"]), label);
421
+ if (record.headAtApproval !== null && record.headAtApproval !== undefined) {
422
+ asString(record.headAtApproval, `${label}.headAtApproval`);
423
+ }
424
+ return {
425
+ plan: asPlanIdentity(record.plan, `${label}.plan`),
426
+ worktree: asString(record.worktree, `${label}.worktree`),
427
+ headAtApproval: record.headAtApproval === null ? null : (record.headAtApproval as string | undefined) ?? null,
428
+ approvedAt: asTimestamp(record.approvedAt, `${label}.approvedAt`),
429
+ };
430
+ }
431
+
432
+ function asExecution(value: unknown, label: string): ExecutionCheckpoint {
433
+ const record = asRecord(value, label);
434
+ rejectExtraKeys(
435
+ record,
436
+ new Set(["approval", "doneVcIds", "implStatus", "currentI", "usage", "pausedReason", "reverifyAll", "originWorktree"]),
437
+ label,
438
+ );
439
+ const execution: ExecutionCheckpoint = {
440
+ approval: record.approval === null || record.approval === undefined
441
+ ? null
442
+ : asExecutionApproval(record.approval, `${label}.approval`),
443
+ doneVcIds: asStringArray(record.doneVcIds ?? [], `${label}.doneVcIds`),
444
+ implStatus: asRecordMap(record.implStatus ?? {}, `${label}.implStatus`),
445
+ usage: (() => {
446
+ const usage = asRecord(record.usage ?? { inToks: 0, outToks: 0 }, `${label}.usage`);
447
+ rejectExtraKeys(usage, new Set(["inToks", "outToks"]), `${label}.usage`);
448
+ return {
449
+ inToks: asInt(usage.inToks, `${label}.usage.inToks`, 0),
450
+ outToks: asInt(usage.outToks, `${label}.usage.outToks`, 0),
451
+ };
452
+ })(),
453
+ };
454
+ if (record.currentI !== undefined) execution.currentI = asString(record.currentI, `${label}.currentI`);
455
+ if (record.pausedReason !== undefined) execution.pausedReason = asString(record.pausedReason, `${label}.pausedReason`);
456
+ if (record.reverifyAll !== undefined) execution.reverifyAll = asBool(record.reverifyAll, `${label}.reverifyAll`);
457
+ if (record.originWorktree !== undefined) execution.originWorktree = asString(record.originWorktree, `${label}.originWorktree`);
458
+ return execution;
459
+ }
460
+
461
+ function asOwner(value: unknown, label: string): OwnerInfo {
462
+ const record = asRecord(value, label);
463
+ rejectExtraKeys(record, new Set(["host", "pid", "sessionId", "processToken", "generation", "acquiredAt"]), label);
464
+ const owner: OwnerInfo = {
465
+ host: asString(record.host, `${label}.host`),
466
+ pid: asInt(record.pid, `${label}.pid`, 1),
467
+ processToken: asId(record.processToken, `${label}.processToken`),
468
+ generation: asInt(record.generation, `${label}.generation`, 1),
469
+ acquiredAt: asTimestamp(record.acquiredAt, `${label}.acquiredAt`),
470
+ };
471
+ if (record.sessionId !== null && record.sessionId !== undefined) {
472
+ owner.sessionId = asString(record.sessionId, `${label}.sessionId`);
473
+ }
474
+ return owner;
475
+ }
476
+
477
+ /**
478
+ * Validate an unknown payload as a {@link WorkflowCheckpoint}. Throws
479
+ * {@link StateError} with a specific message on any violation; never
480
+ * blind-casts, and rejects unknown schema versions and unexpected keys.
481
+ */
482
+ export function validateCheckpoint(data: unknown): WorkflowCheckpoint {
483
+ const record = asRecord(data, "checkpoint");
484
+ const allowed = new Set([
485
+ "schema", "runId", "revision", "generation", "updatedAt", "phase", "nextAction",
486
+ "originWorkdir", "workdir", "worktreeRoot", "commonDir", "plan", "pendingQuestion",
487
+ "answeredQuestions", "reviewRounds", "implementationReview", "execution",
488
+ "autoComplete", "owner", "migration",
489
+ ]);
490
+ rejectExtraKeys(record, allowed, "checkpoint");
491
+ const schema = asInt(record.schema, "checkpoint.schema", 1);
492
+ if (schema !== CHECKPOINT_SCHEMA) {
493
+ throw new CheckpointValidationError(`checkpoint.schema: unsupported version ${schema} (expected ${CHECKPOINT_SCHEMA})`);
494
+ }
495
+ const phase = asEnum(record.phase, PHASES, "checkpoint.phase");
496
+ const nextAction = asEnum(record.nextAction, NEXT_ACTIONS, "checkpoint.nextAction");
497
+ const checkpoint: WorkflowCheckpoint = {
498
+ schema,
499
+ runId: asRunId(record.runId, "checkpoint.runId"),
500
+ revision: asInt(record.revision, "checkpoint.revision", 1),
501
+ generation: asInt(record.generation, "checkpoint.generation", 1),
502
+ updatedAt: asTimestamp(record.updatedAt, "checkpoint.updatedAt"),
503
+ phase,
504
+ nextAction,
505
+ originWorkdir: asString(record.originWorkdir, "checkpoint.originWorkdir"),
506
+ workdir: asString(record.workdir, "checkpoint.workdir"),
507
+ worktreeRoot: asString(record.worktreeRoot, "checkpoint.worktreeRoot"),
508
+ commonDir: asString(record.commonDir, "checkpoint.commonDir"),
509
+ plan: record.plan === null || record.plan === undefined ? null : asPlanIdentity(record.plan, "checkpoint.plan"),
510
+ pendingQuestion:
511
+ record.pendingQuestion === null || record.pendingQuestion === undefined
512
+ ? null
513
+ : asPendingQuestion(record.pendingQuestion, "checkpoint.pendingQuestion"),
514
+ answeredQuestions: Array.isArray(record.answeredQuestions)
515
+ ? record.answeredQuestions.map((entry, i) => asAnsweredQuestion(entry, `checkpoint.answeredQuestions[${i}]`))
516
+ : [],
517
+ reviewRounds: Array.isArray(record.reviewRounds)
518
+ ? record.reviewRounds.map((entry, i) => asReviewRound(entry, `checkpoint.reviewRounds[${i}]`))
519
+ : [],
520
+ };
521
+ if (record.implementationReview !== undefined) {
522
+ checkpoint.implementationReview = asImplementationReview(record.implementationReview, "checkpoint.implementationReview");
523
+ }
524
+ if (record.execution !== undefined) {
525
+ checkpoint.execution = asExecution(record.execution, "checkpoint.execution");
526
+ }
527
+ if (record.autoComplete !== undefined) checkpoint.autoComplete = asBool(record.autoComplete, "checkpoint.autoComplete");
528
+ if (record.owner !== undefined && record.owner !== null) {
529
+ checkpoint.owner = asOwner(record.owner, "checkpoint.owner");
530
+ }
531
+ if (record.migration !== undefined && record.migration !== null) {
532
+ const migration = asRecord(record.migration, "checkpoint.migration");
533
+ rejectExtraKeys(migration, new Set(["fromWorktree", "migratedAt"]), "checkpoint.migration");
534
+ checkpoint.migration = {
535
+ fromWorktree: asString(migration.fromWorktree, "checkpoint.migration.fromWorktree"),
536
+ migratedAt: asTimestamp(migration.migratedAt, "checkpoint.migration.migratedAt"),
537
+ };
538
+ }
539
+ if (phase === "completed" && nextAction !== "none") {
540
+ throw new CheckpointValidationError("checkpoint: completed phase must use nextAction \"none\"");
541
+ }
542
+ return checkpoint;
543
+ }
544
+
545
+ // ---------------------------------------------------------------------------
546
+ // Safe paths and file digests
547
+ // ---------------------------------------------------------------------------
548
+
549
+ /**
550
+ * Validate a checkpoint-relative path: relative, no traversal, no absolute
551
+ * escape. Returns the normalized POSIX-style relative path.
552
+ */
553
+ export function safeRelativePath(value: string, label: string): string {
554
+ if (value === "" || value.includes("\0")) throw new CheckpointValidationError(`${label}: empty or NUL path`);
555
+ if (path.isAbsolute(value)) throw new CheckpointValidationError(`${label}: must be relative`);
556
+ const segments = value.split(/[\\/]/);
557
+ for (const segment of segments) {
558
+ if (segment === "" || segment === "." || segment === "..") {
559
+ throw new CheckpointValidationError(`${label}: path traversal is not allowed`);
560
+ }
561
+ }
562
+ return segments.join("/");
563
+ }
564
+
565
+ /** Resolve `relative` inside `root`, verifying containment on the real filesystem. */
566
+ export function safeResolveInside(root: string, relative: string, label: string): string {
567
+ const normalized = safeRelativePath(relative, label);
568
+ const resolved = path.resolve(root, normalized);
569
+ if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
570
+ throw new CheckpointValidationError(`${label}: escapes the run directory`);
571
+ }
572
+ if (existsSync(resolved)) {
573
+ const realRoot = fs.realpathSync(root);
574
+ const realResolved = fs.realpathSync(resolved);
575
+ if (realResolved !== realRoot && !realResolved.startsWith(`${realRoot}${path.sep}`)) {
576
+ throw new CheckpointValidationError(`${label}: symlink escapes the run directory`);
577
+ }
578
+ }
579
+ return resolved;
580
+ }
581
+
582
+ /** SHA-256 over the full file bytes; throws StateError when unreadable. */
583
+ export function sha256File(absolutePath: string): string {
584
+ try {
585
+ return createHash("sha256").update(readFileSync(absolutePath)).digest("hex");
586
+ } catch (error) {
587
+ throw new StateError(`cannot hash ${absolutePath}: ${(error as Error).message}`);
588
+ }
589
+ }
590
+
591
+ /** `git rev-parse HEAD` at `workdir`, or null when unresolvable (no commits / no git). */
592
+ export function resolveHeadAt(workdir: string): string | null {
593
+ const result = runGit(workdir, "rev-parse", "HEAD");
594
+ if (result.code !== 0) return null;
595
+ const head = result.stdout.trim();
596
+ return head === "" ? null : head;
597
+ }
598
+
599
+ /** `git rev-parse --show-toplevel` at `workdir`, or null when unresolvable. */
600
+ export function resolveWorktreeRoot(workdir: string): string | null {
601
+ const result = runGit(workdir, "rev-parse", "--show-toplevel");
602
+ if (result.code !== 0) return null;
603
+ const top = result.stdout.trim();
604
+ return top === "" ? null : path.resolve(workdir, top);
605
+ }
606
+
607
+ /** Build a plan identity from an absolute PLAN_vN.md path. */
608
+ export function planIdentityOf(planPath: string, version: number): PlanIdentity {
609
+ if (!path.isAbsolute(planPath)) throw new StateError(`plan path must be absolute: ${planPath}`);
610
+ const match = /PLAN_v(\d+)\.md$/.exec(path.basename(planPath));
611
+ const resolvedVersion = match ? Number.parseInt(match[1]!, 10) : version;
612
+ return { path: planPath, version: resolvedVersion, sha256: sha256File(planPath) };
613
+ }
614
+
615
+ // ---------------------------------------------------------------------------
616
+ // Storage: load / create / mutate
617
+ // ---------------------------------------------------------------------------
618
+
619
+ export type CheckpointLoadResult =
620
+ | { status: "ok"; checkpoint: WorkflowCheckpoint }
621
+ | { status: "missing" }
622
+ | { status: "corrupt"; error: string };
623
+
624
+ export function checkpointFilePath(workdir: string, runId: string): string | null {
625
+ const runDir = runDirPath(workdir, runId);
626
+ return runDir === null ? null : path.join(runDir, "checkpoint.json");
627
+ }
628
+
629
+ export function loadCheckpoint(workdir: string, runId: string): CheckpointLoadResult {
630
+ const filePath = checkpointFilePath(workdir, runId);
631
+ if (filePath === null) return { status: "missing" };
632
+ if (!existsSync(filePath)) return { status: "missing" };
633
+ let data: unknown;
634
+ try {
635
+ data = JSON.parse(readFileSync(filePath, "utf8"));
636
+ } catch (error) {
637
+ return { status: "corrupt", error: `invalid JSON: ${(error as Error).message}` };
638
+ }
639
+ try {
640
+ return { status: "ok", checkpoint: validateCheckpoint(data) };
641
+ } catch (error) {
642
+ return { status: "corrupt", error: (error as Error).message };
643
+ }
644
+ }
645
+
646
+ export interface CreateCheckpointInput {
647
+ runId: string;
648
+ originWorkdir: string;
649
+ workdir: string;
650
+ }
651
+
652
+ function gitContext(workdir: string): { worktreeRoot: string; commonDir: string } {
653
+ const stateRoot = resolveStateRootOrNull(workdir);
654
+ if (stateRoot === null) throw new StateError("no pi-plans state found; run init first");
655
+ const worktreeRoot = resolveWorktreeRoot(workdir) ?? path.resolve(workdir);
656
+ return { worktreeRoot, commonDir: path.dirname(stateRoot) };
657
+ }
658
+
659
+ /** Create the initial checkpoint for a run. Refuses to overwrite existing files. */
660
+ export function createCheckpoint(workdir: string, input: CreateCheckpointInput): WorkflowCheckpoint {
661
+ const runDir = runDirPath(workdir, input.runId);
662
+ if (runDir === null) throw new StateError(`run does not exist: ${input.runId}`);
663
+ const filePath = path.join(runDir, "checkpoint.json");
664
+ if (existsSync(filePath)) throw new StateError(`checkpoint already exists for ${input.runId}; use mutateCheckpoint`);
665
+ const { worktreeRoot, commonDir } = gitContext(input.workdir);
666
+ const checkpoint: WorkflowCheckpoint = {
667
+ schema: CHECKPOINT_SCHEMA,
668
+ runId: input.runId,
669
+ revision: 1,
670
+ generation: 1,
671
+ updatedAt: utcNow(),
672
+ phase: "planning",
673
+ nextAction: "continue-planning",
674
+ originWorkdir: path.resolve(input.originWorkdir),
675
+ workdir: path.resolve(input.workdir),
676
+ worktreeRoot,
677
+ commonDir,
678
+ plan: null,
679
+ pendingQuestion: null,
680
+ answeredQuestions: [],
681
+ reviewRounds: [],
682
+ };
683
+ validateCheckpoint(checkpoint);
684
+ atomicWriteJson(filePath, checkpoint);
685
+ return checkpoint;
686
+ }
687
+
688
+ export class StaleCheckpointError extends StateError {}
689
+
690
+ export interface MutateOptions {
691
+ /** Fail when the on-disk revision moved before the mutator runs. */
692
+ expectRevision?: number;
693
+ /** When set, the write is refused unless this session still owns the run. */
694
+ owner?: { processToken: string; generation: number };
695
+ }
696
+
697
+ /**
698
+ * Read-modify-write the checkpoint atomically. The mutator receives a deep
699
+ * copy and returns the next state; revision and updatedAt are bumped here.
700
+ * Corrupt files are never overwritten — mutate throws instead.
701
+ */
702
+ export function mutateCheckpoint(
703
+ workdir: string,
704
+ runId: string,
705
+ mutator: (current: WorkflowCheckpoint) => WorkflowCheckpoint,
706
+ options?: MutateOptions,
707
+ ): WorkflowCheckpoint {
708
+ const runDir = runDirPath(workdir, runId);
709
+ if (runDir === null) throw new StateError(`run does not exist: ${runId}`);
710
+ const filePath = path.join(runDir, "checkpoint.json");
711
+ const load = loadCheckpoint(workdir, runId);
712
+ if (load.status === "missing") throw new StateError(`no checkpoint for ${runId}; create it first`);
713
+ if (load.status === "corrupt") {
714
+ throw new StateError(
715
+ `checkpoint for ${runId} is corrupt (${load.error}); refusing to overwrite — repair or remove it explicitly`,
716
+ );
717
+ }
718
+ if (options?.expectRevision !== undefined && load.checkpoint.revision !== options.expectRevision) {
719
+ throw new StaleCheckpointError(
720
+ `checkpoint revision moved: expected ${options.expectRevision}, found ${load.checkpoint.revision}`,
721
+ );
722
+ }
723
+ // F-005 (implementation review): whenever THIS process holds the run lease,
724
+ // every advance re-verifies it — a takeover elsewhere fails the write here.
725
+ const heldOwner = options?.owner ?? heldOwnershipRecord(workdir, runId);
726
+ if (heldOwner) assertOwnership(workdir, runId, heldOwner);
727
+ const next = mutator(structuredClone(load.checkpoint));
728
+ next.revision = load.checkpoint.revision + 1;
729
+ next.updatedAt = utcNow();
730
+ if (next.generation < load.checkpoint.generation) next.generation = load.checkpoint.generation;
731
+ validateCheckpoint(next);
732
+ atomicWriteJson(filePath, next);
733
+ return next;
734
+ }
735
+
736
+ // ---------------------------------------------------------------------------
737
+ // Review output files
738
+ // ---------------------------------------------------------------------------
739
+
740
+ /**
741
+ * Persist one lane's full review output under `reviews/` in the run
742
+ * directory. Returns the checkpoint-relative reference path.
743
+ */
744
+ export function writeReviewOutput(
745
+ workdir: string,
746
+ runId: string,
747
+ roundId: string,
748
+ laneId: string,
749
+ content: string,
750
+ ): string {
751
+ const runDir = runDirPath(workdir, runId);
752
+ if (runDir === null) throw new StateError(`run does not exist: ${runId}`);
753
+ const safeRound = asId(roundId, "roundId");
754
+ const safeLane = asId(laneId, "laneId");
755
+ const reviewsDir = path.join(runDir, "reviews");
756
+ mkdirSync(reviewsDir, { recursive: true });
757
+ const fileName = `${safeRound}__${safeLane}.md`;
758
+ const target = safeResolveInside(runDir, path.join("reviews", fileName), "review output");
759
+ const tmp = `${target}.tmp`;
760
+ writeFileSync(tmp, content, "utf8");
761
+ renameSync(tmp, target);
762
+ return path.posix.join("reviews", fileName);
763
+ }
764
+
765
+ /** Read a review output referenced by a checkpoint lane. */
766
+ export function readReviewOutput(workdir: string, runId: string, relativePath: string): string {
767
+ const runDir = runDirPath(workdir, runId);
768
+ if (runDir === null) throw new StateError(`run does not exist: ${runId}`);
769
+ const target = safeResolveInside(runDir, relativePath, "review output");
770
+ return readFileSync(target, "utf8");
771
+ }
772
+
773
+ // ---------------------------------------------------------------------------
774
+ // State-machine reducers (whitelisted transitions only)
775
+ // ---------------------------------------------------------------------------
776
+
777
+ function questionPhases(cp: WorkflowCheckpoint): boolean {
778
+ return cp.phase === "planning" || cp.phase === "reviewing" || cp.phase === "implementation-review";
779
+ }
780
+
781
+ /** F-005: an answered ledger entry always wins over a same-id pending question. */
782
+ export function reconcilePendingWithAnswered(cp: WorkflowCheckpoint): WorkflowCheckpoint {
783
+ if (cp.pendingQuestion === null) return cp;
784
+ if (cp.answeredQuestions.some((entry) => entry.questionId === cp.pendingQuestion?.questionId)) {
785
+ return { ...cp, pendingQuestion: null };
786
+ }
787
+ return cp;
788
+ }
789
+
790
+ export function applyQuestionAsked(cp: WorkflowCheckpoint, question: Omit<PendingQuestion, "askedAt">, askedAt?: string): WorkflowCheckpoint {
791
+ if (!questionPhases(cp)) throw new StateError(`cannot ask a question in phase "${cp.phase}"`);
792
+ if (cp.answeredQuestions.some((entry) => entry.questionId === question.questionId)) {
793
+ throw new StateError(`question ${question.questionId} is already answered; do not re-ask`);
794
+ }
795
+ if (cp.pendingQuestion !== null && cp.pendingQuestion.questionId !== question.questionId) {
796
+ throw new StateError(`another question is pending: ${cp.pendingQuestion.questionId}`);
797
+ }
798
+ return {
799
+ ...cp,
800
+ pendingQuestion: { ...question, askedAt: askedAt ?? utcNow() },
801
+ nextAction: "ask-question",
802
+ };
803
+ }
804
+
805
+ export function applyQuestionAnswered(
806
+ cp: WorkflowCheckpoint,
807
+ questionId: string,
808
+ answer: string,
809
+ source: QuestionSource,
810
+ ): WorkflowCheckpoint {
811
+ if (cp.pendingQuestion?.questionId !== questionId) {
812
+ throw new StateError(`no pending question ${questionId}`);
813
+ }
814
+ return {
815
+ ...cp,
816
+ pendingQuestion: null,
817
+ answeredQuestions: [
818
+ ...cp.answeredQuestions,
819
+ { questionId, answer, source, answeredAt: utcNow() },
820
+ ],
821
+ };
822
+ }
823
+
824
+ export function applyPlanWritten(cp: WorkflowCheckpoint, plan: PlanIdentity): WorkflowCheckpoint {
825
+ if (cp.phase !== "planning" && cp.phase !== "reviewing") {
826
+ throw new StateError(`cannot record a plan version in phase "${cp.phase}"`);
827
+ }
828
+ return { ...cp, plan };
829
+ }
830
+
831
+ export function applyReviewRoundStarted(cp: WorkflowCheckpoint, round: Omit<ReviewRoundState, "startedAt" | "consolidated" | "lanes"> & { lanes: Array<Omit<ReviewLaneState, "status">> }): WorkflowCheckpoint {
832
+ if (cp.phase !== "planning" && cp.phase !== "reviewing" && cp.phase !== "implementation-review") {
833
+ throw new StateError(`cannot start a review round in phase "${cp.phase}"`);
834
+ }
835
+ if (cp.reviewRounds.some((entry) => entry.roundId === round.roundId)) {
836
+ throw new StateError(`review round ${round.roundId} already exists`);
837
+ }
838
+ if (round.target === "plan") {
839
+ if (cp.plan === null) throw new StateError("plan-target review requires a recorded plan identity");
840
+ }
841
+ if (round.target === "implementation") {
842
+ if (cp.phase !== "implementation-review") {
843
+ throw new StateError("implementation review rounds require phase \"implementation-review\"");
844
+ }
845
+ if (cp.implementationReview?.terminationCondition === undefined) {
846
+ throw new StateError("implementation review requires a recorded termination condition first");
847
+ }
848
+ }
849
+ const started: ReviewRoundState = {
850
+ ...round,
851
+ consolidated: false,
852
+ startedAt: utcNow(),
853
+ lanes: round.lanes.map((lane) => ({ ...lane, status: "pending" as LaneStatus, startedAt: utcNow() })),
854
+ };
855
+ const next: WorkflowCheckpoint = { ...cp, reviewRounds: [...cp.reviewRounds, started] };
856
+ if (round.target === "implementation" && cp.implementationReview) {
857
+ next.implementationReview = { ...cp.implementationReview, currentRoundId: round.roundId };
858
+ }
859
+ return next;
860
+ }
861
+
862
+ export function applyLaneResult(
863
+ cp: WorkflowCheckpoint,
864
+ roundId: string,
865
+ laneId: string,
866
+ result: { ok: boolean; resultFile?: string; error?: string },
867
+ ): WorkflowCheckpoint {
868
+ const round = cp.reviewRounds.find((entry) => entry.roundId === roundId);
869
+ if (!round) throw new StateError(`unknown review round: ${roundId}`);
870
+ const lane = round.lanes.find((entry) => entry.laneId === laneId);
871
+ if (!lane) throw new StateError(`unknown lane ${laneId} in round ${roundId}`);
872
+ if (lane.status === "complete") {
873
+ if (lane.resultFile === result.resultFile) return cp; // idempotent replay
874
+ throw new StateError(`lane ${laneId} is already complete with a different result`);
875
+ }
876
+ const nextLane: ReviewLaneState = {
877
+ ...lane,
878
+ status: result.ok ? "complete" : "failed",
879
+ completedAt: utcNow(),
880
+ };
881
+ if (result.ok) {
882
+ if (!result.resultFile) throw new StateError("successful lanes must reference a persisted result file");
883
+ nextLane.resultFile = result.resultFile;
884
+ }
885
+ const nextRound: ReviewRoundState = { ...round, lanes: round.lanes.map((entry) => (entry.laneId === laneId ? nextLane : entry)) };
886
+ return { ...cp, reviewRounds: cp.reviewRounds.map((entry) => (entry.roundId === roundId ? nextRound : entry)) };
887
+ }
888
+
889
+ export function applyReviewConsolidated(cp: WorkflowCheckpoint, roundId: string, dispositionArtifact?: string): WorkflowCheckpoint {
890
+ const round = cp.reviewRounds.find((entry) => entry.roundId === roundId);
891
+ if (!round) throw new StateError(`unknown review round: ${roundId}`);
892
+ if (round.consolidated) return cp; // idempotent
893
+ if (round.lanes.length === 0 || !round.lanes.every((lane) => lane.status === "complete" || lane.status === "failed")) {
894
+ throw new StateError(`round ${roundId} still has non-terminal lanes`);
895
+ }
896
+ if (round.role === "reviewer" && !round.lanes.some((lane) => lane.status === "complete")) {
897
+ throw new StateError(`round ${roundId} has no successful lane to consolidate`);
898
+ }
899
+ const nextRound: ReviewRoundState = {
900
+ ...round,
901
+ consolidated: true,
902
+ completedAt: utcNow(),
903
+ };
904
+ if (dispositionArtifact !== undefined) nextRound.dispositionArtifact = dispositionArtifact;
905
+ return { ...cp, reviewRounds: cp.reviewRounds.map((entry) => (entry.roundId === roundId ? nextRound : entry)) };
906
+ }
907
+
908
+ /** F-004: `completed` requires explicit evidence; approval cannot be forged by state writes. */
909
+ export function applyCompleted(cp: WorkflowCheckpoint, evidence: string): WorkflowCheckpoint {
910
+ if (cp.phase !== "implementation-review") {
911
+ throw new StateError(`cannot complete from phase "${cp.phase}"`);
912
+ }
913
+ const review = cp.implementationReview;
914
+ if (!review || review.terminationCondition === undefined) {
915
+ throw new StateError("cannot complete without a recorded termination condition");
916
+ }
917
+ if (evidence.trim() === "") throw new StateError("completion requires non-empty evidence");
918
+ return { ...cp, phase: "completed", nextAction: "none" };
919
+ }
920
+
921
+ export function applyExecutionApproved(cp: WorkflowCheckpoint, approval: ExecutionApproval): WorkflowCheckpoint {
922
+ // F-007 (implementation review): terminal and review phases cannot approve
923
+ // execution; a re-approval (stop/migration reset approval to null) is legal
924
+ // only while execution is still the owning phase or planning/reviewing.
925
+ if (cp.phase === "implementation-review" || cp.phase === "completed") {
926
+ throw new StateError(`cannot approve execution from phase "${cp.phase}"`);
927
+ }
928
+ if (cp.nextAction !== "accept-execute") {
929
+ throw new StateError(`execution approval requires nextAction "accept-execute" (found "${cp.nextAction}")`);
930
+ }
931
+ if (cp.plan !== null && cp.plan.sha256 !== approval.plan.sha256) {
932
+ throw new StateError("approved plan does not match the checkpoint's recorded plan");
933
+ }
934
+ return {
935
+ ...cp,
936
+ phase: "executing",
937
+ nextAction: "execute-items",
938
+ plan: approval.plan,
939
+ execution: {
940
+ approval,
941
+ doneVcIds: [],
942
+ implStatus: {},
943
+ usage: { inToks: 0, outToks: 0 },
944
+ },
945
+ };
946
+ }
947
+
948
+ export interface ExecutionProgressInput {
949
+ doneVcIds?: string[];
950
+ implStatus?: Record<string, string>;
951
+ currentI?: string;
952
+ usage?: { inToks: number; outToks: number };
953
+ pausedReason?: string | null;
954
+ }
955
+
956
+ export function applyExecutionProgress(cp: WorkflowCheckpoint, progress: ExecutionProgressInput): WorkflowCheckpoint {
957
+ if (cp.phase !== "executing" || !cp.execution) throw new StateError("execution progress requires phase \"executing\"");
958
+ const execution: ExecutionCheckpoint = { ...cp.execution };
959
+ if (progress.doneVcIds !== undefined) execution.doneVcIds = progress.doneVcIds;
960
+ if (progress.implStatus !== undefined) execution.implStatus = progress.implStatus;
961
+ if (progress.currentI !== undefined) execution.currentI = progress.currentI;
962
+ if (progress.usage !== undefined) {
963
+ execution.usage = {
964
+ inToks: execution.usage.inToks + progress.usage.inToks,
965
+ outToks: execution.usage.outToks + progress.usage.outToks,
966
+ };
967
+ }
968
+ if (progress.pausedReason === null) delete execution.pausedReason;
969
+ else if (progress.pausedReason !== undefined) execution.pausedReason = progress.pausedReason;
970
+ return { ...cp, execution };
971
+ }
972
+
973
+ /** D-011/F-001: code state changed under an unchanged plan — keep authorization, re-verify first. */
974
+ export function applyExecutionHeadChanged(cp: WorkflowCheckpoint): WorkflowCheckpoint {
975
+ if (cp.phase !== "executing" || !cp.execution) throw new StateError("requires phase \"executing\"");
976
+ return { ...cp, execution: { ...cp.execution, reverifyAll: true } };
977
+ }
978
+
979
+ export function applyExecutionCompleted(cp: WorkflowCheckpoint): WorkflowCheckpoint {
980
+ if (cp.phase !== "executing" || !cp.execution) throw new StateError("requires phase \"executing\"");
981
+ return {
982
+ ...cp,
983
+ phase: "implementation-review",
984
+ nextAction: "ask-question",
985
+ execution: { ...cp.execution, pausedReason: undefined },
986
+ };
987
+ }
988
+
989
+ export function applyImplementationReviewConfigured(cp: WorkflowCheckpoint, terminationCondition: string): WorkflowCheckpoint {
990
+ if (cp.phase !== "implementation-review") throw new StateError("requires phase \"implementation-review\"");
991
+ if (cp.implementationReview?.terminationCondition !== undefined) {
992
+ throw new StateError("termination condition already configured; do not re-ask");
993
+ }
994
+ return {
995
+ ...cp,
996
+ implementationReview: {
997
+ terminationCondition,
998
+ completedRounds: cp.implementationReview?.completedRounds ?? 0,
999
+ },
1000
+ nextAction: "run-review",
1001
+ };
1002
+ }
1003
+
1004
+ export function applyImplementationRoundFinished(cp: WorkflowCheckpoint): WorkflowCheckpoint {
1005
+ if (cp.phase !== "implementation-review" || !cp.implementationReview) {
1006
+ throw new StateError("requires phase \"implementation-review\"");
1007
+ }
1008
+ const review = cp.implementationReview;
1009
+ if (review.currentRoundId === undefined) throw new StateError("no current round to finish");
1010
+ const round = cp.reviewRounds.find((entry) => entry.roundId === review.currentRoundId);
1011
+ if (!round || !round.consolidated) throw new StateError("current round is not consolidated");
1012
+ return {
1013
+ ...cp,
1014
+ implementationReview: { ...review, completedRounds: review.completedRounds + 1, currentRoundId: undefined },
1015
+ };
1016
+ }
1017
+
1018
+ /**
1019
+ * F-003/D-007: migrate a run into the current worktree. The termination
1020
+ * condition survives; completed rounds, approval, and VC validity do not.
1021
+ */
1022
+ export function applyMigration(
1023
+ cp: WorkflowCheckpoint,
1024
+ target: { workdir: string; worktreeRoot: string; commonDir: string },
1025
+ ): WorkflowCheckpoint {
1026
+ return {
1027
+ ...cp,
1028
+ workdir: path.resolve(target.workdir),
1029
+ worktreeRoot: target.worktreeRoot,
1030
+ commonDir: target.commonDir,
1031
+ migration: { fromWorktree: cp.worktreeRoot, migratedAt: utcNow() },
1032
+ execution: cp.execution
1033
+ ? {
1034
+ approval: null,
1035
+ doneVcIds: [],
1036
+ implStatus: {},
1037
+ usage: cp.execution.usage,
1038
+ originWorktree: cp.execution.originWorktree ?? cp.worktreeRoot,
1039
+ }
1040
+ : undefined,
1041
+ implementationReview: cp.implementationReview
1042
+ ? { terminationCondition: cp.implementationReview.terminationCondition, completedRounds: 0 }
1043
+ : undefined,
1044
+ nextAction: migrationNextAction(cp),
1045
+ };
1046
+ }
1047
+
1048
+ /** F-007 (implementation review): migration must not hand accept-execute to phases that still owe review work. */
1049
+ function migrationNextAction(cp: WorkflowCheckpoint): NextAction {
1050
+ if (cp.phase === "implementation-review") return "run-review";
1051
+ if (cp.phase === "executing") return cp.plan !== null ? "accept-execute" : cp.nextAction;
1052
+ return cp.nextAction;
1053
+ }
1054
+
1055
+ /** Mark a paused stop without erasing the last phase (D-008). */
1056
+ export function applyExecutionStopped(cp: WorkflowCheckpoint, reason: string): WorkflowCheckpoint {
1057
+ if (cp.phase !== "executing" || !cp.execution) throw new StateError("requires phase \"executing\"");
1058
+ return { ...cp, execution: { ...cp.execution, pausedReason: reason } };
1059
+ }
1060
+
1061
+ // ---------------------------------------------------------------------------
1062
+ // Review-round orchestration helpers (refine tool integration, I-004)
1063
+ // ---------------------------------------------------------------------------
1064
+
1065
+ export interface RoundSpec {
1066
+ roundId: string;
1067
+ role: "reviewer" | "criticizer";
1068
+ target: "plan" | "implementation";
1069
+ reviewers: number;
1070
+ lanes: Array<{ laneId: string; lens?: string }>;
1071
+ /** Absolute PLAN path; recorded when the checkpoint has no plan identity yet. */
1072
+ planPath?: string;
1073
+ focus?: string;
1074
+ context?: string;
1075
+ }
1076
+
1077
+ /**
1078
+ * Create the round in the checkpoint, or resume an existing one idempotently.
1079
+ * Resuming validates the plan digest: lanes from a different plan version are
1080
+ * never silently reused.
1081
+ */
1082
+ export function startReviewRound(workdir: string, runId: string, spec: RoundSpec): WorkflowCheckpoint {
1083
+ const load = loadCheckpoint(workdir, runId);
1084
+ if (load.status === "missing") throw new StateError(`no checkpoint for ${runId}; create it first`);
1085
+ if (load.status === "corrupt") throw new StateError(`checkpoint for ${runId} is corrupt (${load.error}); refusing to use it`);
1086
+ const existing = load.checkpoint.reviewRounds.find((round) => round.roundId === spec.roundId);
1087
+ if (existing) {
1088
+ if (existing.role !== spec.role || existing.target !== spec.target) {
1089
+ throw new StateError(`round ${spec.roundId} exists with a different role/target; use a new round id`);
1090
+ }
1091
+ const specLanes = spec.lanes.map((lane) => lane.laneId).sort().join(",");
1092
+ const existingLanes = existing.lanes.map((lane) => lane.laneId).sort().join(",");
1093
+ if (specLanes !== existingLanes) {
1094
+ throw new StateError(`round ${spec.roundId} exists with different lanes; use a new round id`);
1095
+ }
1096
+ if (spec.planPath) {
1097
+ const sha = sha256File(spec.planPath);
1098
+ if (existing.planSha256 !== undefined && existing.planSha256 !== sha) {
1099
+ throw new StateError(
1100
+ `round ${spec.roundId} was recorded against a different plan version; start a new round`,
1101
+ );
1102
+ }
1103
+ }
1104
+ return load.checkpoint;
1105
+ }
1106
+ return mutateCheckpoint(workdir, runId, (cp) => {
1107
+ let next = cp;
1108
+ if (spec.target === "plan" && spec.planPath && next.plan === null) {
1109
+ next = applyPlanWritten(next, planIdentityOf(spec.planPath, 1));
1110
+ }
1111
+ const planSha = spec.planPath ? sha256File(spec.planPath) : next.plan?.sha256;
1112
+ return applyReviewRoundStarted(next, {
1113
+ roundId: spec.roundId,
1114
+ role: spec.role,
1115
+ target: spec.target,
1116
+ reviewers: spec.reviewers,
1117
+ planSha256: planSha,
1118
+ focus: spec.focus,
1119
+ context: spec.context,
1120
+ lanes: spec.lanes,
1121
+ });
1122
+ });
1123
+ }
1124
+
1125
+ /**
1126
+ * Persist one lane outcome: successful outputs are written to a result file
1127
+ * BEFORE the checkpoint references them; failures only mark the lane.
1128
+ */
1129
+ export function recordLaneOutcome(
1130
+ workdir: string,
1131
+ runId: string,
1132
+ roundId: string,
1133
+ laneId: string,
1134
+ result: { ok: boolean; output?: string; error?: string },
1135
+ ): { resultFile?: string } {
1136
+ if (result.ok) {
1137
+ if (!result.output) throw new StateError("successful lanes must carry output to persist");
1138
+ const resultFile = writeReviewOutput(workdir, runId, roundId, laneId, result.output);
1139
+ mutateCheckpoint(workdir, runId, (cp) => applyLaneResult(cp, roundId, laneId, { ok: true, resultFile }));
1140
+ return { resultFile };
1141
+ }
1142
+ mutateCheckpoint(workdir, runId, (cp) =>
1143
+ applyLaneResult(cp, roundId, laneId, { ok: false, error: result.error }),
1144
+ );
1145
+ return {};
1146
+ }
1147
+
1148
+ /** Lanes whose persisted outputs can be reused for a resumed round. */
1149
+ export function reusableLaneOutputs(cp: WorkflowCheckpoint, roundId: string): Array<{ laneId: string; resultFile: string }> {
1150
+ const round = cp.reviewRounds.find((entry) => entry.roundId === roundId);
1151
+ if (!round) return [];
1152
+ return round.lanes
1153
+ .filter((lane) => lane.status === "complete" && lane.resultFile !== undefined)
1154
+ .map((lane) => ({ laneId: lane.laneId, resultFile: lane.resultFile! }));
1155
+ }
1156
+
1157
+ export function newProcessToken(): string {
1158
+ return randomUUID().replace(/-/g, "");
1159
+ }