@brainervirus/workit-core 0.8.0 → 0.8.2

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.
@@ -1,12 +1,63 @@
1
- import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, existsSync } from "node:fs";
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ fstatSync,
5
+ fsyncSync,
6
+ mkdirSync,
7
+ openSync,
8
+ readFileSync,
9
+ renameSync,
10
+ rmSync,
11
+ statSync,
12
+ unlinkSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { createHash } from "node:crypto";
2
16
  import path from "node:path";
3
17
  import { docsValidate, parseTasksFromPlan, qualitySpec, stripFences } from "./docs-validate";
4
18
  import { resolveCanonicalLayout } from "./docs-layout";
19
+ import { ledgerCompletion } from "./sdd";
20
+ import { runVerifyProject } from "./verify-project";
5
21
 
6
22
  export type FlowHost = "opencode" | "cursor";
7
23
  export type FlowStatus = "draft" | "self_reviewed" | "approved";
8
24
  export type FlowRole = "coordinator" | "delegated";
9
25
 
26
+ /** The canonical document kinds a flow binds approvals to (CA-01). */
27
+ export type FlowDocument = "spec" | "plan";
28
+
29
+ /** Structured approval-drift reasons (CA-04). */
30
+ export type FlowDriftCode =
31
+ | "digest_missing"
32
+ | "document_missing"
33
+ | "document_unreadable"
34
+ | "digest_mismatch";
35
+
36
+ export type FlowDriftReason = {
37
+ document: FlowDocument;
38
+ code: FlowDriftCode;
39
+ path: string;
40
+ };
41
+
42
+ /** Execution lifecycle (CA-11): only these four states exist; no cancellation. */
43
+ export type ExecutionStatus = "pending" | "active" | "paused" | "completed";
44
+ export type ExecutionMode = "subagent-driven" | "inline";
45
+
46
+ /** CLI confirmation evidence (CA-19, CA-21): policy-only, no attestation. */
47
+ export type CliConfirmation = {
48
+ host: "cli";
49
+ attested: false;
50
+ confirmation: "flag" | "tty";
51
+ };
52
+
53
+ export type LifecycleEvidence = NativeChoiceEvidence | CliConfirmation;
54
+
55
+ export type FlowExecutionState = {
56
+ status: ExecutionStatus;
57
+ mode: ExecutionMode | null;
58
+ evidence: LifecycleEvidence | null;
59
+ };
60
+
10
61
  /**
11
62
  * Host-bound identity for every flow/product mutation (FG-05, CA-20, CA-21):
12
63
  * the authoritative host workspace, the coordinator/delegated role, the host
@@ -74,6 +125,8 @@ export type FlowDocState = {
74
125
  path: string;
75
126
  status: FlowStatus;
76
127
  evidence?: NativeChoiceEvidence | null;
128
+ /** SHA-256 (lowercase hex) of the canonical document's exact bytes (CA-01). */
129
+ approved_digest: string | null;
77
130
  };
78
131
 
79
132
  export type FlowMenuState = {
@@ -89,17 +142,33 @@ export type FlowState = {
89
142
  spec: FlowDocState;
90
143
  plan: FlowDocState;
91
144
  menu: FlowMenuState;
145
+ execution: FlowExecutionState;
146
+ handoff_destination: boolean;
92
147
  updated_at: number;
93
148
  };
94
149
 
95
150
  /** One shared result shape for every flow transition and mutation gate (FG-09). */
96
- export type FlowError = { ok: false; error: string; code: string };
151
+ export type FlowError = {
152
+ ok: false;
153
+ error: string;
154
+ code: string;
155
+ details?: Record<string, unknown>;
156
+ };
97
157
  export type FlowGateResult = { ok: true } | FlowError;
98
158
  export type EvidenceResult =
99
159
  | { ok: true; evidence: NativeChoiceEvidence }
100
160
  | { ok: false; error: string };
101
161
  export type StatusTransition = { ok: true; next: FlowStatus } | FlowError;
102
162
 
163
+ /** Persisted state after legacy normalization and approval-integrity reconciliation (CA-02). */
164
+ export type EffectiveFlowState = {
165
+ state: FlowState;
166
+ drift: FlowDriftReason[];
167
+ };
168
+
169
+ /** Structured result of an effective (reconciled) flow-state read (CA-04). */
170
+ export type FlowReadResult = ({ ok: true } & EffectiveFlowState) | FlowError;
171
+
103
172
  export const MENU_CHOICES = [
104
173
  "subagent-driven",
105
174
  "inline",
@@ -109,7 +178,33 @@ export const MENU_CHOICES = [
109
178
  ] as const;
110
179
  export type MenuChoice = (typeof MENU_CHOICES)[number];
111
180
 
112
- const err = (code: string, error: string): FlowError => ({ ok: false, code, error });
181
+ /**
182
+ * The source post-plan menu (CA-08): the full five-way choice set the source
183
+ * session presents after the plan is approved. `DESTINATION_MENU_CHOICES` is
184
+ * the same tuple without `handoff` — a marked destination never re-offers the
185
+ * originating handoff choice.
186
+ */
187
+ export const SOURCE_MENU_CHOICES = MENU_CHOICES;
188
+ export const DESTINATION_MENU_CHOICES = [
189
+ "subagent-driven",
190
+ "inline",
191
+ "review-spec",
192
+ "review-plan",
193
+ ] as const;
194
+ export type DestinationMenuChoice = (typeof DESTINATION_MENU_CHOICES)[number];
195
+
196
+ // The source/destination menu labels and the destination marker live in the
197
+ // import-light menu module (CA-07/CA-08) so session-start hooks select reminder
198
+ // wording without pulling in the full flow-state graph; flow-state re-exports
199
+ // them so every existing consumer keeps the same import site.
200
+ export { DESTINATION_MENU_LABELS, HANDOFF_DESTINATION_MARKER, SOURCE_MENU_LABELS } from "./menu";
201
+
202
+ const err = (code: string, error: string, details?: Record<string, unknown>): FlowError => ({
203
+ ok: false,
204
+ code,
205
+ error,
206
+ ...(details ? { details } : {}),
207
+ });
113
208
 
114
209
  const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
115
210
 
@@ -143,6 +238,7 @@ const normalizeState = (parsed: unknown, slug: string): FlowState => {
143
238
  const spec = (p.spec ?? {}) as Partial<FlowDocState>;
144
239
  const plan = (p.plan ?? {}) as Partial<FlowDocState>;
145
240
  const menu = (p.menu ?? {}) as Partial<FlowMenuState>;
241
+ const execution = (p.execution ?? {}) as Partial<FlowExecutionState>;
146
242
  return {
147
243
  slug: p.slug ?? slug,
148
244
  activated: p.activated ?? true,
@@ -150,17 +246,25 @@ const normalizeState = (parsed: unknown, slug: string): FlowState => {
150
246
  path: spec.path ?? "",
151
247
  status: spec.status ?? "draft",
152
248
  evidence: spec.evidence ?? null,
249
+ approved_digest: spec.approved_digest ?? null,
153
250
  },
154
251
  plan: {
155
252
  path: plan.path ?? "",
156
253
  status: plan.status ?? "draft",
157
254
  evidence: plan.evidence ?? null,
255
+ approved_digest: plan.approved_digest ?? null,
158
256
  },
159
257
  menu: {
160
258
  presented: Boolean(menu.presented),
161
259
  chosen: menu.chosen ?? "",
162
260
  evidence: menu.evidence ?? null,
163
261
  },
262
+ execution: {
263
+ status: (execution.status ?? "pending") as ExecutionStatus,
264
+ mode: (execution.mode ?? null) as ExecutionMode | null,
265
+ evidence: (execution.evidence ?? null) as LifecycleEvidence | null,
266
+ },
267
+ handoff_destination: p.handoff_destination ?? false,
164
268
  updated_at: p.updated_at ?? Date.now(),
165
269
  };
166
270
  };
@@ -168,9 +272,11 @@ const normalizeState = (parsed: unknown, slug: string): FlowState => {
168
272
  const emptyState = (slug: string): FlowState => ({
169
273
  slug,
170
274
  activated: false,
171
- spec: { path: "", status: "draft", evidence: null },
172
- plan: { path: "", status: "draft", evidence: null },
275
+ spec: { path: "", status: "draft", evidence: null, approved_digest: null },
276
+ plan: { path: "", status: "draft", evidence: null, approved_digest: null },
173
277
  menu: { presented: false, chosen: "", evidence: null },
278
+ execution: { status: "pending", mode: null, evidence: null },
279
+ handoff_destination: false,
174
280
  updated_at: Date.now(),
175
281
  });
176
282
 
@@ -184,26 +290,220 @@ export const readFlowState = (root: string, slug: string): FlowState => {
184
290
  }
185
291
  };
186
292
 
293
+ const HEX64_RE = /^[0-9a-f]{64}$/;
294
+ const FLOW_STATUSES: readonly FlowStatus[] = ["draft", "self_reviewed", "approved"];
295
+ const EXECUTION_STATUSES: readonly ExecutionStatus[] = ["pending", "active", "paused", "completed"];
296
+
297
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
298
+ typeof v === "object" && v !== null && !Array.isArray(v);
299
+
300
+ /**
301
+ * Structural validation of persisted choice evidence (CA-18). Approved
302
+ * lifecycle/approval evidence is data, not a host hook: we validate the shape
303
+ * (host + required fields) but never re-check freshness here — freshness is a
304
+ * consume-time property of the host receipt store.
305
+ */
306
+ const validateEvidenceValue = (v: unknown, allowCli: boolean): boolean => {
307
+ if (v === null) return true;
308
+ if (!isRecord(v)) return false;
309
+ if (v.host === "opencode") {
310
+ return (
311
+ v.attested === true &&
312
+ typeof v.callID === "string" &&
313
+ typeof v.selectedLabel === "string" &&
314
+ typeof v.recordedAt === "number"
315
+ );
316
+ }
317
+ if (v.host === "cursor") return v.attested === false && v.confirmation === "contract";
318
+ if (allowCli && v.host === "cli") {
319
+ return v.attested === false && (v.confirmation === "flag" || v.confirmation === "tty");
320
+ }
321
+ return false;
322
+ };
323
+
324
+ /**
325
+ * Strict validation + documented normalization of parsed flow.json (CA-18):
326
+ * unsupported field values are rejected (flow_state_invalid) instead of being
327
+ * coerced; missing OPTIONAL fields are normalized only by the documented rules.
328
+ */
329
+ const validateState = (
330
+ parsed: unknown,
331
+ slug: string,
332
+ ): { ok: true; state: FlowState } | { ok: false; error: string } => {
333
+ if (!isRecord(parsed)) return { ok: false, error: "flow state must be a JSON object" };
334
+ if (parsed.slug !== undefined && (typeof parsed.slug !== "string" || parsed.slug !== slug)) {
335
+ return { ok: false, error: `flow state slug must be ${JSON.stringify(slug)}` };
336
+ }
337
+ if (parsed.activated !== undefined && typeof parsed.activated !== "boolean") {
338
+ return { ok: false, error: "flow state activated must be a boolean" };
339
+ }
340
+ if (parsed.handoff_destination !== undefined && typeof parsed.handoff_destination !== "boolean") {
341
+ return { ok: false, error: "flow state handoff_destination must be a boolean" };
342
+ }
343
+ if (
344
+ parsed.updated_at !== undefined &&
345
+ (typeof parsed.updated_at !== "number" || !Number.isFinite(parsed.updated_at))
346
+ ) {
347
+ return { ok: false, error: "flow state updated_at must be a finite number" };
348
+ }
349
+ const doc = (value: unknown, name: FlowDocument): FlowDocState | string => {
350
+ const p = isRecord(value) ? value : {};
351
+ if (!isRecord(value) && value !== undefined) {
352
+ return `flow state ${name} must be an object`;
353
+ }
354
+ if (p.status !== undefined && !FLOW_STATUSES.includes(p.status as FlowStatus)) {
355
+ return `flow state ${name}.status must be draft, self_reviewed, or approved`;
356
+ }
357
+ if (p.path !== undefined && typeof p.path !== "string") {
358
+ return `flow state ${name}.path must be a string`;
359
+ }
360
+ if (
361
+ p.approved_digest !== undefined &&
362
+ p.approved_digest !== null &&
363
+ (typeof p.approved_digest !== "string" || !HEX64_RE.test(p.approved_digest))
364
+ ) {
365
+ return `flow state ${name}.approved_digest must be 64-char lowercase hex or null`;
366
+ }
367
+ if (p.evidence !== undefined && !validateEvidenceValue(p.evidence, false)) {
368
+ return `flow state ${name}.evidence has an unsupported shape`;
369
+ }
370
+ return {
371
+ path: (p.path as string | undefined) ?? "",
372
+ status: (p.status as FlowStatus | undefined) ?? "draft",
373
+ evidence: (p.evidence as NativeChoiceEvidence | null | undefined) ?? null,
374
+ approved_digest: (p.approved_digest as string | null | undefined) ?? null,
375
+ };
376
+ };
377
+ const spec = doc(parsed.spec, "spec");
378
+ if (typeof spec === "string") return { ok: false, error: spec };
379
+ const plan = doc(parsed.plan, "plan");
380
+ if (typeof plan === "string") return { ok: false, error: plan };
381
+
382
+ const menuRaw = isRecord(parsed.menu) ? parsed.menu : undefined;
383
+ if (parsed.menu !== undefined && !isRecord(parsed.menu)) {
384
+ return { ok: false, error: "flow state menu must be an object" };
385
+ }
386
+ if (menuRaw?.presented !== undefined && typeof menuRaw.presented !== "boolean") {
387
+ return { ok: false, error: "flow state menu.presented must be a boolean" };
388
+ }
389
+ if (menuRaw?.chosen !== undefined && typeof menuRaw.chosen !== "string") {
390
+ return { ok: false, error: "flow state menu.chosen must be a string" };
391
+ }
392
+ // The only persisted `chosen` values are the MENU_CHOICES plus the empty
393
+ // string ("" marks an unpresented/reset menu — markHandoffDestination and the
394
+ // drift resets persist it). Anything else is a bogus/legacy value and fails
395
+ // closed (CA-18).
396
+ if (
397
+ menuRaw?.chosen !== undefined &&
398
+ menuRaw.chosen !== "" &&
399
+ !MENU_CHOICES.includes(menuRaw.chosen as MenuChoice)
400
+ ) {
401
+ return {
402
+ ok: false,
403
+ error: `flow state menu.chosen must be one of: ${MENU_CHOICES.join(", ")} (or an empty string when the menu is unpresented)`,
404
+ };
405
+ }
406
+ if (menuRaw?.evidence !== undefined && !validateEvidenceValue(menuRaw.evidence, false)) {
407
+ return { ok: false, error: "flow state menu.evidence has an unsupported shape" };
408
+ }
409
+
410
+ const execRaw = isRecord(parsed.execution) ? parsed.execution : undefined;
411
+ if (parsed.execution !== undefined && !isRecord(parsed.execution)) {
412
+ return { ok: false, error: "flow state execution must be an object" };
413
+ }
414
+ if (
415
+ execRaw?.status !== undefined &&
416
+ !EXECUTION_STATUSES.includes(execRaw.status as ExecutionStatus)
417
+ ) {
418
+ return {
419
+ ok: false,
420
+ error: "flow state execution.status must be pending, active, paused, or completed",
421
+ };
422
+ }
423
+ if (
424
+ execRaw?.mode !== undefined &&
425
+ execRaw.mode !== null &&
426
+ execRaw.mode !== "subagent-driven" &&
427
+ execRaw.mode !== "inline"
428
+ ) {
429
+ return {
430
+ ok: false,
431
+ error: "flow state execution.mode must be subagent-driven, inline, or null",
432
+ };
433
+ }
434
+ if (execRaw?.evidence !== undefined && !validateEvidenceValue(execRaw.evidence, true)) {
435
+ return { ok: false, error: "flow state execution.evidence has an unsupported shape" };
436
+ }
437
+
438
+ return {
439
+ ok: true,
440
+ state: {
441
+ slug,
442
+ activated: parsed.activated ?? true,
443
+ spec,
444
+ plan,
445
+ menu: {
446
+ presented: menuRaw?.presented ?? false,
447
+ chosen: (menuRaw?.chosen as string | undefined) ?? "",
448
+ evidence: (menuRaw?.evidence as NativeChoiceEvidence | null | undefined) ?? null,
449
+ },
450
+ execution: {
451
+ status: (execRaw?.status as ExecutionStatus | undefined) ?? "pending",
452
+ mode: (execRaw?.mode as ExecutionMode | null | undefined) ?? null,
453
+ evidence: (execRaw?.evidence as LifecycleEvidence | null | undefined) ?? null,
454
+ },
455
+ handoff_destination: parsed.handoff_destination ?? false,
456
+ updated_at: parsed.updated_at ?? Date.now(),
457
+ },
458
+ };
459
+ };
460
+
187
461
  // Strict read for transitions and guards: missing or corrupt state is a
188
- // structured error, never a silent draft fallback (CA-18).
189
- type StrictRead = { ok: true; state: FlowState } | { ok: false; error: string; code: string };
462
+ // structured error, never a silent draft fallback (CA-18). The raw readFlowState
463
+ // above stays a lenient compatibility helper for controlled tests and mutation
464
+ // internals; status, gates, and host adapters use the effective path. The raw
465
+ // parsed JSON is carried so compatibility normalization can distinguish a
466
+ // genuinely missing `execution` key from an explicit persisted state (CA-16).
467
+ type StrictRead =
468
+ | { ok: true; state: FlowState; raw: unknown }
469
+ | { ok: false; error: string; code: string };
190
470
 
191
471
  const readFlowStrict = (root: string, slug: string): StrictRead => {
192
472
  const file = flowPath(root, slug);
473
+ const rel = path.posix.join("docs", slug, "sdd", "flow.json");
193
474
  if (!existsSync(file)) {
194
475
  return err(
195
476
  "flow_not_activated",
196
477
  `flow not activated for ${slug} — run workflow_flow_status first`,
197
478
  );
198
479
  }
480
+ let text: string;
481
+ try {
482
+ text = readFileSync(file, "utf8");
483
+ } catch (error) {
484
+ return err(
485
+ "flow_io_error",
486
+ `cannot read flow state at ${file}: ${error instanceof Error ? error.message : String(error)}`,
487
+ );
488
+ }
489
+ let parsed: unknown;
199
490
  try {
200
- return { ok: true, state: normalizeState(JSON.parse(readFileSync(file, "utf8")), slug) };
491
+ parsed = JSON.parse(text);
201
492
  } catch (error) {
202
493
  return err(
203
- "flow_corrupt",
204
- `corrupt flow state at ${file}: ${error instanceof Error ? error.message : String(error)}`,
494
+ "flow_state_invalid",
495
+ `invalid flow state at ${file}: ${error instanceof Error ? error.message : String(error)}`,
496
+ { path: rel, original_bytes_preserved: true },
205
497
  );
206
498
  }
499
+ const validated = validateState(parsed, slug);
500
+ if (!validated.ok) {
501
+ return err("flow_state_invalid", `invalid flow state at ${file}: ${validated.error}`, {
502
+ path: rel,
503
+ original_bytes_preserved: true,
504
+ });
505
+ }
506
+ return { ok: true, state: validated.state, raw: parsed };
207
507
  };
208
508
 
209
509
  // Unique per-write temporary buffer so two concurrent writers never share the
@@ -211,24 +511,96 @@ const readFlowStrict = (root: string, slug: string): StrictRead => {
211
511
  const uniqueTempPath = (file: string) =>
212
512
  `${file}.${process.pid}-${Math.random().toString(36).slice(2)}.tmp`;
213
513
 
214
- export const writeFlowState = (root: string, state: FlowState) => {
215
- const file = flowPath(root, state.slug);
216
- mkdirSync(path.dirname(file), { recursive: true });
514
+ /**
515
+ * Same-directory atomic replacement (CA-19): write a unique temp file, fsync its
516
+ * descriptor, close it, and rename it into place. The temp shares the target's
517
+ * directory so rename is atomic on the same filesystem; a reader never observes
518
+ * partial JSON. Best-effort removal of the temp on every exit path.
519
+ */
520
+ const writeFlowFileAtomic = (file: string, state: FlowState): void => {
521
+ const text = JSON.stringify(state, null, 2) + "\n";
217
522
  const tmp = uniqueTempPath(file);
218
- writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
219
- renameSync(tmp, file);
523
+ mkdirSync(path.dirname(file), { recursive: true });
524
+ let fd: number | null = null;
525
+ try {
526
+ fd = openSync(tmp, "w");
527
+ writeFileSync(fd, text, "utf8");
528
+ fsyncSync(fd);
529
+ closeSync(fd);
530
+ fd = null;
531
+ renameSync(tmp, file);
532
+ } finally {
533
+ try {
534
+ if (fd !== null) closeSync(fd);
535
+ } catch {
536
+ // best effort
537
+ }
538
+ try {
539
+ if (existsSync(tmp)) rmSync(tmp, { force: true });
540
+ } catch {
541
+ // best effort: a leftover temp is preferable to masking the real error
542
+ }
543
+ }
544
+ };
545
+
546
+ export const writeFlowState = (root: string, state: FlowState) => {
547
+ writeFlowFileAtomic(flowPath(root, state.slug), state);
220
548
  };
221
549
 
222
550
  const MAX_WRITE_ATTEMPTS = 5;
223
551
 
552
+ /**
553
+ * Age threshold for stale-lock recovery (CA-19): a crash between
554
+ * `openSync(lock, "wx")` and `rmSync(lock)` leaves `<flow.json>.lock` forever.
555
+ * A lock file older than this is treated as abandoned and removed before a
556
+ * fresh acquisition attempt, so a crash never wedges every later operation.
557
+ *
558
+ * ponytail: age-based recovery has two documented ceilings. (1) A very slow
559
+ * writer still legitimately holding the lock (or clock skew) can have its lock
560
+ * reclaimed; the CAS below still protects data, but that writer's critical
561
+ * section is no longer mutually exclusive with the new acquirer's. (2)
562
+ * Recovery renames by PATH, not by inode: two simultaneous reclaimers of the
563
+ * same stale lock can still move a freshly re-acquired winner's lock (one
564
+ * reclaimer's rename lands after the other's re-acquisition). No data is lost —
565
+ * `writeFlowStateIfCurrent`'s CAS is the integrity backstop — but mutual
566
+ * exclusion is not absolute. Upgrade path: write PID/host-session into the
567
+ * lock and verify liveness, or lease-renew, when writers that legitimately
568
+ * exceed the threshold matter.
569
+ */
570
+ const STALE_LOCK_MS = 1000;
571
+
572
+ // The lock's mtime, or null when it vanished between the EEXIST and the stat
573
+ // (a concurrent writer removed it) — either way the caller retries acquisition.
574
+ const lockMtimeMs = (lock: string): number | null => {
575
+ try {
576
+ return statSync(lock).mtimeMs;
577
+ } catch {
578
+ return null;
579
+ }
580
+ };
581
+
582
+ // Whether the lock at `lock` is still the inode `fd` opened (CA-19): release
583
+ // must never unlink a successor's fresh lock, only the file this writer owns.
584
+ // ponytail: this is a stat-then-rmSync window — a successor that replaces the
585
+ // path between the stat and the release rmSync (a concurrent recovery of a
586
+ // >1s-held lock) can still lose its fresh lock. Microsecond window, documented
587
+ // ceiling; the CAS backstops data integrity.
588
+ const lockOwnedBy = (fd: number, lock: string): boolean => {
589
+ try {
590
+ return fstatSync(fd).ino === statSync(lock).ino;
591
+ } catch {
592
+ return false;
593
+ }
594
+ };
595
+
224
596
  export type FlowWriteResult =
225
597
  | { ok: true }
226
598
  | { ok: false; conflict: true }
227
599
  | { ok: false; io_error: string };
228
600
 
229
601
  /**
230
- * Compare-and-write (FG-08): write `next` only if the on-disk content still
231
- * equals the version this writer read (`expected`). A stale writer gets
602
+ * Compare-and-write (FG-08, CA-19): write `next` only if the on-disk content
603
+ * still equals the version this writer read (`expected`). A stale writer gets
232
604
  * `conflict` instead of clobbering a concurrent newer write; the caller re-reads
233
605
  * and retries the transition (bounded). Unique per-write temp names keep the
234
606
  * write buffer from being shared between writers.
@@ -236,12 +608,10 @@ export type FlowWriteResult =
236
608
  * The first compare happens before the buffer is staged; the file is re-read
237
609
  * immediately before the rename so a writer that committed between the two
238
610
  * points still wins. Without the re-read, two writers holding the same expected
239
- * text would both pass the compare and both rename — a lost update.
240
- * ponytail: the re-read shrinks but cannot close the cross-process window (a
241
- * writer can still commit between this re-read and the rename). Upgrade path:
242
- * hold an O_EXCL advisory lock on `<file>.lock` across the read-modify-write,
243
- * or move to renameat2(RENAME_EXCHANGE)/an OS-level CAS when a second
244
- * concurrent process becomes a supported topology.
611
+ * text would both pass the compare and both rename — a lost update. This CAS
612
+ * stays as the second safety net under the per-flow `flow.json.lock` (CA-19):
613
+ * cooperating writers are serialized by the lock; the CAS catches any writer
614
+ * that bypasses it.
245
615
  *
246
616
  * A thrown error here is a real IO/permission failure (EACCES, ENOSPC, ...),
247
617
  * not a conflict: it is returned as `io_error` so callers surface it instead of
@@ -259,11 +629,16 @@ export const writeFlowStateIfCurrent = (
259
629
  const nextText = JSON.stringify(next, null, 2) + "\n";
260
630
  if (expectedText === nextText) return { ok: true };
261
631
  const tmp = uniqueTempPath(file);
632
+ let fd: number | null = null;
262
633
  try {
263
634
  const currentText = existsSync(file) ? readFileSync(file, "utf8") : null;
264
635
  if (currentText !== expectedText) return { ok: false, conflict: true };
265
636
  mkdirSync(path.dirname(file), { recursive: true });
266
- writeFileSync(tmp, nextText, "utf8");
637
+ fd = openSync(tmp, "w");
638
+ writeFileSync(fd, nextText, "utf8");
639
+ fsyncSync(fd);
640
+ closeSync(fd);
641
+ fd = null;
267
642
  const reRead = existsSync(file) ? readFileSync(file, "utf8") : null;
268
643
  if (reRead !== expectedText) return { ok: false, conflict: true };
269
644
  renameSync(tmp, file);
@@ -274,6 +649,11 @@ export const writeFlowStateIfCurrent = (
274
649
  // On success the rename moved the buffer into place; on any other exit the
275
650
  // unique temp is orphaned — remove it so crashed writers don't accumulate
276
651
  // `<file>.<pid>-<rand>.tmp` buffers.
652
+ try {
653
+ if (fd !== null) closeSync(fd);
654
+ } catch {
655
+ // best effort
656
+ }
277
657
  try {
278
658
  if (existsSync(tmp)) rmSync(tmp, { force: true });
279
659
  } catch {
@@ -282,32 +662,362 @@ export const writeFlowStateIfCurrent = (
282
662
  }
283
663
  };
284
664
 
665
+ /**
666
+ * One internal strict-byte helper (CA-01, CA-06): resolve the canonical
667
+ * document, read it as a Buffer, validate it with a fatal TextDecoder, and
668
+ * return both the decoded text and the SHA-256 of the exact bytes. Line endings
669
+ * and Unicode are never normalized — any byte change invalidates the approval.
670
+ */
671
+ type CanonicalDigestResult =
672
+ | { ok: true; text: string; digest: string }
673
+ | { ok: false; code: "document_missing" | "document_unreadable" };
674
+
675
+ const readCanonicalDigest = (root: string, rel: string): CanonicalDigestResult => {
676
+ const abs = path.join(root, ...rel.split("/"));
677
+ let bytes: Buffer;
678
+ try {
679
+ bytes = readFileSync(abs);
680
+ } catch (error) {
681
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
682
+ return { ok: false, code: "document_missing" };
683
+ }
684
+ return { ok: false, code: "document_unreadable" };
685
+ }
686
+ let text: string;
687
+ try {
688
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
689
+ } catch {
690
+ return { ok: false, code: "document_unreadable" };
691
+ }
692
+ return { ok: true, text, digest: createHash("sha256").update(bytes).digest("hex") };
693
+ };
694
+
695
+ /**
696
+ * Approval-integrity reconciliation (CA-02, CA-03): recompute approved
697
+ * document digests in spec-before-plan order and return the reset state plus
698
+ * the structured drift reasons. Spec drift resets the whole approval chain;
699
+ * plan drift (spec valid) preserves the spec approval and digest.
700
+ */
701
+ const resetForSpecDrift = (state: FlowState): FlowState => ({
702
+ ...state,
703
+ spec: { ...state.spec, status: "draft", evidence: null, approved_digest: null },
704
+ plan: { ...state.plan, status: "draft", evidence: null, approved_digest: null },
705
+ menu: { presented: false, chosen: "", evidence: null },
706
+ execution: { status: "pending", mode: null, evidence: null },
707
+ handoff_destination: false,
708
+ updated_at: Date.now(),
709
+ });
710
+
711
+ const resetForPlanDrift = (state: FlowState): FlowState => ({
712
+ ...state,
713
+ plan: { ...state.plan, status: "draft", evidence: null, approved_digest: null },
714
+ menu: { presented: false, chosen: "", evidence: null },
715
+ execution: { status: "pending", mode: null, evidence: null },
716
+ handoff_destination: false,
717
+ updated_at: Date.now(),
718
+ });
719
+
720
+ const driftCodeFor = (
721
+ root: string,
722
+ relPath: string,
723
+ storedDigest: string | null,
724
+ ): FlowDriftCode | null => {
725
+ if (storedDigest === null) return "digest_missing";
726
+ const current = readCanonicalDigest(root, relPath);
727
+ if (!current.ok) return current.code;
728
+ return current.digest !== storedDigest ? "digest_mismatch" : null;
729
+ };
730
+
731
+ const reconcileState = (
732
+ root: string,
733
+ slug: string,
734
+ state: FlowState,
735
+ ): { state: FlowState; drift: FlowDriftReason[] } => {
736
+ const specPath = path.posix.join("docs", slug, "spec.md");
737
+ const planPath = path.posix.join("docs", slug, "plan.md");
738
+ if (state.spec.status === "approved") {
739
+ const code = driftCodeFor(root, specPath, state.spec.approved_digest);
740
+ if (code) {
741
+ return {
742
+ state: resetForSpecDrift(state),
743
+ drift: [{ document: "spec", code, path: specPath }],
744
+ };
745
+ }
746
+ }
747
+ if (state.plan.status === "approved") {
748
+ const code = driftCodeFor(root, planPath, state.plan.approved_digest);
749
+ if (code) {
750
+ return {
751
+ state: resetForPlanDrift(state),
752
+ drift: [{ document: "plan", code, path: planPath }],
753
+ };
754
+ }
755
+ }
756
+ return { state, drift: [] };
757
+ };
758
+
759
+ /**
760
+ * Compatibility normalization for legacy persisted shapes (CA-16): a flow.json
761
+ * written before the execution lifecycle has NO `execution` key. Only then is
762
+ * execution derived — active exactly when the persisted plan approval, a
763
+ * subagent-driven menu choice, and an in-progress SDD ledger prove a legacy
764
+ * execution is running; every other combination (and any explicit persisted
765
+ * execution) stays pending/fail-closed. Runs BEFORE digest reconciliation
766
+ * (CA-17) so a drift reset can still pull a derived active state back to
767
+ * pending. Migration evidence is null by design: a legacy flow has no
768
+ * host-observed lifecycle receipt to cite.
769
+ */
770
+ const deriveLegacyExecution = (
771
+ root: string,
772
+ slug: string,
773
+ state: FlowState,
774
+ ): FlowExecutionState => {
775
+ const ledger = ledgerCompletion(root, slug);
776
+ if (
777
+ state.plan.status === "approved" &&
778
+ state.menu.chosen === "subagent-driven" &&
779
+ ledger.started &&
780
+ !ledger.complete
781
+ ) {
782
+ return { status: "active", mode: "subagent-driven", evidence: null };
783
+ }
784
+ return { status: "pending", mode: null, evidence: null };
785
+ };
786
+
787
+ type CompatibilityResult = { state: FlowState; changed: boolean };
788
+
789
+ const normalizeCompatibility = (
790
+ root: string,
791
+ slug: string,
792
+ parsed: unknown,
793
+ state: FlowState,
794
+ ): CompatibilityResult => {
795
+ if (!isRecord(parsed) || !("execution" in parsed)) {
796
+ const derived = deriveLegacyExecution(root, slug, state);
797
+ const current = state.execution;
798
+ if (derived.status !== current.status || derived.mode !== current.mode) {
799
+ return { state: { ...state, execution: derived, updated_at: Date.now() }, changed: true };
800
+ }
801
+ }
802
+ return { state, changed: false };
803
+ };
804
+
285
805
  type MutateResult = { ok: true; next: FlowState } | FlowError;
286
806
 
287
- // Unlocked read-modify-write made safe (FG-08): read strict, mutate in memory,
288
- // then commit only if the on-disk state still matches what was read; otherwise
289
- // re-read and retry the transition, bounded.
807
+ /**
808
+ * Per-flow critical section (CA-19): acquire `<flow.json>.lock` exclusively
809
+ * (openSync "wx"); on contention retry with a bounded 10ms backoff; a lock
810
+ * older than STALE_LOCK_MS (a crashed writer) is removed and acquisition is
811
+ * retried; run the critical section; release the lock and best-effort remove
812
+ * it in `finally`. A never-activated flow (no `docs/<slug>/sdd/` dir) runs
813
+ * without a lock: there is no flow.json to serialize and no filesystem side
814
+ * effect is created — the write helpers create the dir on the first actual
815
+ * write. No lock module and no adapter-side lock: every host shares this one
816
+ * core contract. ponytail: stale recovery and release are path-based with
817
+ * documented TOCTOU ceilings (see STALE_LOCK_MS and lockOwnedBy); data
818
+ * integrity is guaranteed by the CAS, not by absolute mutual exclusion.
819
+ */
820
+ type Locked<T> = { locked: true; value: T } | { locked: false; error: FlowError };
821
+
822
+ const withFlowLock = <T>(file: string, fn: () => T): Locked<T> => {
823
+ const lock = `${file}.lock`;
824
+ // An activated flow's `docs/<slug>/sdd/` dir always exists (a file implies
825
+ // its parent dir); a never-activated flow has neither. Skip the lock for the
826
+ // never-activated case so a failed flow_not_activated gate/status read
827
+ // leaves no filesystem side effect. The skip window is benign: a concurrent
828
+ // first activation writes byte-equivalent initial state through unique temp
829
+ // names + atomic rename, and the CAS serializes every later mutation.
830
+ if (!existsSync(path.dirname(file))) return { locked: true, value: fn() };
831
+ // Best-effort cleanup of a leftover `<file>.lock.stale` from a crashed
832
+ // recovery (a crash between the recovery rename and the unlink strands it).
833
+ // It is never a live lock path — the live lock is always `<file>.lock` — so
834
+ // removing it here is safe. ponytail: on-acquisition best-effort only; an
835
+ // unremovable `.stale` falls through and never blocks the live lock path.
836
+ try {
837
+ if (existsSync(`${lock}.stale`)) rmSync(`${lock}.stale`, { force: true });
838
+ } catch {
839
+ // best effort: a leftover .stale is harmless and cannot wedge the lock
840
+ }
841
+ const wait = new Int32Array(new SharedArrayBuffer(4));
842
+ let fd: number | null = null;
843
+ for (let attempt = 0; attempt < MAX_WRITE_ATTEMPTS; attempt++) {
844
+ try {
845
+ fd = openSync(lock, "wx");
846
+ break;
847
+ } catch (error) {
848
+ const code = (error as NodeJS.ErrnoException).code;
849
+ if (code !== "EEXIST") {
850
+ return {
851
+ locked: false,
852
+ error: err(
853
+ "flow_io_error",
854
+ `flow lock failed for ${file}: ${error instanceof Error ? error.message : String(error)}`,
855
+ ),
856
+ };
857
+ }
858
+ // Stale-lock recovery (CA-19): the lock is older than STALE_LOCK_MS, so
859
+ // its writer crashed after acquiring it. Reclaim it via a PATH-based
860
+ // atomic rename to `<file>.lock.stale`, unlink the stale inode, then
861
+ // re-attempt acquisition inline so the final attempt still acquires
862
+ // instead of falling out of the loop unlocked. ponytail: the rename is
863
+ // NOT inode-conditional — a concurrent reclaimer of the same stale lock
864
+ // can move a freshly re-acquired winner's lock at the same path
865
+ // (documented TOCTOU ceiling; the CAS backstops integrity). A crash
866
+ // between the rename and the unlink strands only `<file>.lock.stale`,
867
+ // which is never a live lock path and is best-effort removed on the next
868
+ // acquisition.
869
+ const mtime = lockMtimeMs(lock);
870
+ if (mtime !== null && Date.now() - mtime > STALE_LOCK_MS) {
871
+ try {
872
+ renameSync(lock, `${lock}.stale`);
873
+ unlinkSync(`${lock}.stale`);
874
+ } catch {
875
+ // best effort: an unremovable or concurrently-reclaimed stale lock
876
+ // falls through to the bounded retries and, ultimately,
877
+ // flow_concurrent_conflict — never past the lock
878
+ }
879
+ try {
880
+ fd = openSync(lock, "wx");
881
+ break;
882
+ } catch (innerError) {
883
+ const innerCode = (innerError as NodeJS.ErrnoException).code;
884
+ if (innerCode !== "EEXIST") {
885
+ return {
886
+ locked: false,
887
+ error: err(
888
+ "flow_io_error",
889
+ `flow lock failed for ${file}: ${innerError instanceof Error ? innerError.message : String(innerError)}`,
890
+ ),
891
+ };
892
+ }
893
+ // another writer won the reclaimed lock — fall through to backoff
894
+ }
895
+ }
896
+ if (attempt === MAX_WRITE_ATTEMPTS - 1) {
897
+ return {
898
+ locked: false,
899
+ error: err(
900
+ "flow_concurrent_conflict",
901
+ `concurrent flow update detected for ${path.dirname(file)}: re-read the flow state and retry the transition`,
902
+ ),
903
+ };
904
+ }
905
+ Atomics.wait(wait, 0, 0, 10);
906
+ }
907
+ }
908
+ if (fd === null) {
909
+ // Every acquisition attempt failed without granting the lock: never run the
910
+ // critical section unlocked.
911
+ return {
912
+ locked: false,
913
+ error: err(
914
+ "flow_concurrent_conflict",
915
+ `concurrent flow update detected for ${path.dirname(file)}: re-read the flow state and retry the transition`,
916
+ ),
917
+ };
918
+ }
919
+ try {
920
+ return { locked: true, value: fn() };
921
+ } finally {
922
+ try {
923
+ if (fd !== null && lockOwnedBy(fd, lock)) rmSync(lock, { force: true });
924
+ } catch {
925
+ // best effort: a leftover lock is preferable to masking the real error
926
+ }
927
+ try {
928
+ if (fd !== null) closeSync(fd);
929
+ } catch {
930
+ // best effort
931
+ }
932
+ }
933
+ };
934
+
935
+ /**
936
+ * Effective flow-state read (CA-02, CA-04): under the per-flow lock, validate
937
+ * persisted state, normalize legacy compatibility (missing execution) first,
938
+ * reconcile approval digests in spec-before-plan order, and persist any reset
939
+ * or migration atomically. Status reads and gates operate ONLY on this
940
+ * reconciled state; drift is reported structurally.
941
+ */
942
+ export const readEffectiveFlowState = (root: string, slug: string): FlowReadResult => {
943
+ const file = flowPath(root, slug);
944
+ const rel = path.posix.join("docs", slug, "sdd", "flow.json");
945
+ const locked = withFlowLock<FlowReadResult>(file, () => {
946
+ const strict = readFlowStrict(root, slug);
947
+ if (!strict.ok) return strict;
948
+ const normalized = normalizeCompatibility(root, slug, strict.raw, strict.state);
949
+ const { state, drift } = reconcileState(root, slug, normalized.state);
950
+ if (normalized.changed || drift.length > 0) {
951
+ try {
952
+ writeFlowFileAtomic(file, state);
953
+ } catch (error) {
954
+ // A read-path persist failure (EACCES, ENOSPC, EROFS) must never throw
955
+ // through the lock: the FlowReadResult contract is structured (CA-04),
956
+ // and every gate/status read now writes on drift. The original
957
+ // flow.json bytes are untouched — the atomic write never got far enough
958
+ // to swap the file.
959
+ return err(
960
+ "flow_io_error",
961
+ `cannot persist reconciled flow state at ${file}: ${error instanceof Error ? error.message : String(error)}`,
962
+ { path: rel, original_bytes_preserved: true },
963
+ );
964
+ }
965
+ }
966
+ return { ok: true, state, drift };
967
+ });
968
+ if (!locked.locked) return locked.error;
969
+ return locked.value;
970
+ };
971
+
972
+ /**
973
+ * Locked read-modify-write (FG-08, CA-19): under the per-flow lock, read strict,
974
+ * normalize legacy compatibility, reconcile approval digests first, mutate on
975
+ * the reconciled state, then commit only if the on-disk state still matches
976
+ * what was read (CAS); otherwise re-read and retry the transition, bounded.
977
+ * A compatibility migration is persisted under the lock first so the CAS
978
+ * baseline matches the on-disk bytes. Reconciliation runs inside this same
979
+ * critical section before every transition (CA-02).
980
+ */
290
981
  const readModifyWrite = (
291
982
  root: string,
292
983
  slug: string,
293
984
  mutate: (state: FlowState) => MutateResult,
294
985
  ): FlowGateResult => {
295
- for (let attempt = 0; attempt < MAX_WRITE_ATTEMPTS; attempt++) {
296
- const strict = readFlowStrict(root, slug);
297
- if (!strict.ok) return strict;
298
- const result = mutate(strict.state);
299
- if (!result.ok) return result;
300
- const commit = writeFlowStateIfCurrent(root, strict.state, result.next);
301
- if (commit.ok) return { ok: true };
302
- if ("io_error" in commit) {
303
- return err("flow_io_error", `flow state write failed for ${slug}: ${commit.io_error}`);
986
+ const file = flowPath(root, slug);
987
+ const locked = withFlowLock<FlowGateResult>(file, () => {
988
+ for (let attempt = 0; attempt < MAX_WRITE_ATTEMPTS; attempt++) {
989
+ const strict = readFlowStrict(root, slug);
990
+ if (!strict.ok) return strict;
991
+ const normalized = normalizeCompatibility(root, slug, strict.raw, strict.state);
992
+ const reconciled = reconcileState(root, slug, normalized.state);
993
+ const result = mutate(reconciled.state);
994
+ if (!result.ok) return result;
995
+ let baseline = strict.state;
996
+ if (normalized.changed) {
997
+ try {
998
+ writeFlowFileAtomic(file, normalized.state);
999
+ } catch (error) {
1000
+ return err(
1001
+ "flow_io_error",
1002
+ `cannot persist normalized flow state at ${file}: ${error instanceof Error ? error.message : String(error)}`,
1003
+ );
1004
+ }
1005
+ baseline = normalized.state;
1006
+ }
1007
+ const commit = writeFlowStateIfCurrent(root, baseline, result.next);
1008
+ if (commit.ok) return { ok: true };
1009
+ if ("io_error" in commit) {
1010
+ return err("flow_io_error", `flow state write failed for ${slug}: ${commit.io_error}`);
1011
+ }
1012
+ // a non-cooperating writer won the race — re-read and retry the transition
304
1013
  }
305
- // a concurrent writer won the race — re-read and retry the transition
306
- }
307
- return err(
308
- "flow_concurrent_conflict",
309
- `concurrent flow update detected for ${slug}: re-read the flow state and retry the transition`,
310
- );
1014
+ return err(
1015
+ "flow_concurrent_conflict",
1016
+ `concurrent flow update detected for ${slug}: re-read the flow state and retry the transition`,
1017
+ );
1018
+ });
1019
+ if (!locked.locked) return locked.error;
1020
+ return locked.value;
311
1021
  };
312
1022
 
313
1023
  // The caller-supplied workspace must be the host workspace the context names
@@ -323,15 +1033,22 @@ const assertMutationWorkspace = (root: string, ctx?: MutationContext): FlowGateR
323
1033
  };
324
1034
 
325
1035
  /**
326
- * Coordinator boundary (FG-05, CA-20): once a plan is subagent-driven, the
327
- * coordinator session cannot mutate product state — only authenticated
328
- * delegated workers can. A delegated worker without a task identity is blocked.
1036
+ * Coordinator boundary (FG-05, CA-20): while a plan's execution is ACTIVE and
1037
+ * subagent-driven, the coordinator session cannot mutate product state — only
1038
+ * authenticated delegated workers can. A historical subagent-driven menu choice
1039
+ * alone is not a boundary: a pending/paused/completed/inline execution leaves
1040
+ * the coordinator unblocked. A delegated worker without a task identity is
1041
+ * blocked.
329
1042
  */
330
1043
  export const assertCoordinatorBoundary = (
331
1044
  ctx: MutationContext | undefined,
332
- menu: FlowMenuState,
1045
+ state: FlowState,
333
1046
  ): FlowGateResult => {
334
- if (ctx?.role === "coordinator" && menu.chosen === "subagent-driven") {
1047
+ if (
1048
+ ctx?.role === "coordinator" &&
1049
+ state.execution.status === "active" &&
1050
+ state.execution.mode === "subagent-driven"
1051
+ ) {
335
1052
  return err("coordinator_blocked", COORDINATOR_RECOVERY_TEXT);
336
1053
  }
337
1054
  if (ctx?.role === "delegated" && !ctx.taskIdentity) {
@@ -347,6 +1064,11 @@ export const assertCoordinatorBoundary = (
347
1064
  * The shared transition matrix (FG-09): draft -> approved in one receipt; a
348
1065
  * legacy self_reviewed state still advances to approved. The self-review
349
1066
  * validation runs automatically inside the draft transition.
1067
+ *
1068
+ * @deprecated public compat — production transitions (transitionSpec /
1069
+ * transitionPlan) hardcode "approved"; this matrix is retained only as the
1070
+ * documented single-source transition contract for tests and external
1071
+ * consumers of the exported API.
350
1072
  */
351
1073
  export const nextFlowStatus = (current: FlowStatus): StatusTransition => {
352
1074
  if (current === "draft") return { ok: true, next: "approved" };
@@ -679,6 +1401,9 @@ export const assertHostEvidence = (host: FlowHost, evidence: unknown): FlowGateR
679
1401
  * Record flow activation and the canonical spec/plan paths when preparation
680
1402
  * begins. The flow store lives under the canonical docs/<slug>/sdd/ layout
681
1403
  * (Task 18 contract). Re-runs keep existing statuses while recording paths.
1404
+ * Activation is a locked critical section (CA-19): existing state is validated
1405
+ * and reconciled before being trusted, and malformed state fails closed without
1406
+ * overwriting the original file (CA-18).
682
1407
  */
683
1408
  export const prepareFlowState = (
684
1409
  root: string,
@@ -697,26 +1422,42 @@ export const prepareFlowState = (
697
1422
  if (!resolved.ok) return err("flow_prepare_failed", resolved.error);
698
1423
  const specPath = path.posix.join("docs", slug, "spec.md");
699
1424
  const planPath = path.posix.join("docs", slug, "plan.md");
700
- const current = readFlowState(root, slug);
701
- const state: FlowState = current.activated
702
- ? {
703
- ...current,
704
- spec: { ...current.spec, path: specPath },
705
- plan: { ...current.plan, path: planPath },
706
- updated_at: Date.now(),
707
- }
708
- : {
1425
+ const file = flowPath(root, slug);
1426
+ const locked = withFlowLock<FlowGateResult>(file, () => {
1427
+ if (!existsSync(file)) {
1428
+ writeFlowFileAtomic(file, {
709
1429
  slug,
710
1430
  activated: true,
711
- spec: { path: specPath, status: "draft", evidence: null },
712
- plan: { path: planPath, status: "draft", evidence: null },
1431
+ spec: { path: specPath, status: "draft", evidence: null, approved_digest: null },
1432
+ plan: { path: planPath, status: "draft", evidence: null, approved_digest: null },
713
1433
  menu: { presented: false, chosen: "", evidence: null },
1434
+ execution: { status: "pending", mode: null, evidence: null },
1435
+ handoff_destination: false,
714
1436
  updated_at: Date.now(),
715
- };
716
- writeFlowState(root, state);
717
- return { ok: true };
1437
+ });
1438
+ return { ok: true };
1439
+ }
1440
+ const strict = readFlowStrict(root, slug);
1441
+ if (!strict.ok) return strict;
1442
+ const reconciled = reconcileState(root, slug, strict.state);
1443
+ writeFlowFileAtomic(file, {
1444
+ ...reconciled.state,
1445
+ spec: { ...reconciled.state.spec, path: specPath },
1446
+ plan: { ...reconciled.state.plan, path: planPath },
1447
+ updated_at: Date.now(),
1448
+ });
1449
+ return { ok: true };
1450
+ });
1451
+ if (!locked.locked) return locked.error;
1452
+ return locked.value;
718
1453
  };
719
1454
 
1455
+ /**
1456
+ * Approve the canonical spec (CA-01): under the locked read/reconcile/mutate
1457
+ * critical section, reset any stale approval first, then read the exact bytes,
1458
+ * run the self-review on the decoded text, and atomically store the approval
1459
+ * evidence TOGETHER WITH the SHA-256 digest of those bytes.
1460
+ */
720
1461
  export const transitionSpec = (
721
1462
  root: string,
722
1463
  slug: string,
@@ -730,51 +1471,59 @@ export const transitionSpec = (
730
1471
  if (!recorded.ok) return err("evidence_invalid", recorded.error);
731
1472
  const doc = resolveDoc(root, slug, specPath, "spec");
732
1473
  if (!doc.ok) return err("path_invalid", doc.error);
1474
+ const relPath = path.posix.join("docs", slug, "spec.md");
733
1475
  return readModifyWrite(root, slug, (state) => {
734
1476
  if (!existsSync(doc.path)) return err("spec_missing", `spec not found: ${specPath}`);
735
- if (state.spec.status === "draft") {
736
- let text: string;
737
- try {
738
- text = readFileSync(doc.path, "utf8");
739
- } catch (error) {
1477
+ if (state.spec.status === "draft" || state.spec.status === "self_reviewed") {
1478
+ const digest = readCanonicalDigest(root, relPath);
1479
+ if (!digest.ok) {
740
1480
  return err(
741
1481
  "spec_self_review_failed",
742
- `spec self-review failed: unreadable spec: ${error instanceof Error ? error.message : String(error)}`,
1482
+ `spec self-review failed: unreadable or invalid UTF-8 canonical spec: ${specPath}`,
743
1483
  );
744
1484
  }
745
- const hard = qualitySpec(text).filter((f) => f.severity === "hard");
746
- const missing: string[] = [];
747
- if (!/^\s*\*+Branch:\*+/im.test(stripFences(text)))
748
- missing.push("**Branch:** header missing");
749
- if (hard.length > 0 || missing.length > 0) {
750
- return err(
751
- "spec_self_review_failed",
752
- "spec self-review failed: " +
753
- hard
754
- .map((f) => `${f.code} — ${f.message}`)
755
- .concat(missing)
756
- .join("; ") +
757
- " see templates/spec-template.md for the required structure",
758
- );
1485
+ if (state.spec.status === "draft") {
1486
+ const hard = qualitySpec(digest.text).filter((f) => f.severity === "hard");
1487
+ const missing: string[] = [];
1488
+ if (!/^\s*\*+Branch:\*+/im.test(stripFences(digest.text)))
1489
+ missing.push("**Branch:** header missing");
1490
+ if (hard.length > 0 || missing.length > 0) {
1491
+ return err(
1492
+ "spec_self_review_failed",
1493
+ "spec self-review failed: " +
1494
+ hard
1495
+ .map((f) => `${f.code} — ${f.message}`)
1496
+ .concat(missing)
1497
+ .join("; ") +
1498
+ " — see templates/spec-template.md for the required structure",
1499
+ );
1500
+ }
759
1501
  }
760
- }
761
- const step = nextFlowStatus(state.spec.status);
762
- if (!step.ok) return step;
763
- return {
764
- ok: true,
765
- next: {
766
- ...state,
767
- spec: {
768
- path: path.posix.join("docs", slug, "spec.md"),
769
- status: step.next,
770
- evidence: recorded.evidence,
1502
+ return {
1503
+ ok: true,
1504
+ next: {
1505
+ ...state,
1506
+ spec: {
1507
+ path: relPath,
1508
+ status: "approved",
1509
+ evidence: recorded.evidence,
1510
+ approved_digest: digest.digest,
1511
+ },
1512
+ updated_at: Date.now(),
771
1513
  },
772
- updated_at: Date.now(),
773
- },
774
- };
1514
+ };
1515
+ }
1516
+ return err("flow_already_approved", "already approved; no further transitions");
775
1517
  });
776
1518
  };
777
1519
 
1520
+ /**
1521
+ * Approve the canonical plan (CA-01): requires a currently valid spec approval;
1522
+ * under the locked read/reconcile/mutate critical section, reset any stale plan
1523
+ * approval first, then read the exact bytes, run the self-review on the decoded
1524
+ * text, and atomically store the approval evidence TOGETHER WITH the SHA-256
1525
+ * digest of those bytes.
1526
+ */
778
1527
  export const transitionPlan = (
779
1528
  root: string,
780
1529
  slug: string,
@@ -788,44 +1537,45 @@ export const transitionPlan = (
788
1537
  if (!recorded.ok) return err("evidence_invalid", recorded.error);
789
1538
  const doc = resolveDoc(root, slug, planPath, "plan");
790
1539
  if (!doc.ok) return err("path_invalid", doc.error);
1540
+ const relPath = path.posix.join("docs", slug, "plan.md");
791
1541
  return readModifyWrite(root, slug, (state) => {
792
1542
  if (!existsSync(doc.path)) return err("plan_missing", `plan not found: ${planPath}`);
793
1543
  if (state.spec.status !== "approved") {
794
1544
  return err("spec_not_approved", "spec must be approved before the plan can be approved");
795
1545
  }
796
- if (state.plan.status === "draft") {
797
- let text: string;
798
- try {
799
- text = readFileSync(doc.path, "utf8");
800
- } catch (error) {
1546
+ if (state.plan.status === "draft" || state.plan.status === "self_reviewed") {
1547
+ const digest = readCanonicalDigest(root, relPath);
1548
+ if (!digest.ok) {
801
1549
  return err(
802
1550
  "plan_self_review_failed",
803
- `plan self-review failed: unreadable plan: ${error instanceof Error ? error.message : String(error)}`,
1551
+ `plan self-review failed: unreadable or invalid UTF-8 canonical plan: ${planPath}`,
804
1552
  );
805
1553
  }
806
- const missing: string[] = [];
807
- const stripped = stripFences(text);
808
- if (parseTasksFromPlan(text).length === 0)
809
- missing.push("no ### Task N: sections outside fences");
810
- if (!/^\s*\*+Spec:\*+/im.test(stripped)) missing.push("**Spec:** header missing");
811
- if (!/^\s*\*+Branch:\*+/im.test(stripped)) missing.push("**Branch:** header missing");
812
- if (missing.length > 0)
813
- return err("plan_self_review_failed", "plan self-review failed: " + missing.join("; "));
814
- }
815
- const step = nextFlowStatus(state.plan.status);
816
- if (!step.ok) return step;
817
- return {
818
- ok: true,
819
- next: {
820
- ...state,
821
- plan: {
822
- path: path.posix.join("docs", slug, "plan.md"),
823
- status: step.next,
824
- evidence: recorded.evidence,
1554
+ if (state.plan.status === "draft") {
1555
+ const missing: string[] = [];
1556
+ const stripped = stripFences(digest.text);
1557
+ if (parseTasksFromPlan(digest.text).length === 0)
1558
+ missing.push("no ### Task N: sections outside fences");
1559
+ if (!/^\s*\*+Spec:\*+/im.test(stripped)) missing.push("**Spec:** header missing");
1560
+ if (!/^\s*\*+Branch:\*+/im.test(stripped)) missing.push("**Branch:** header missing");
1561
+ if (missing.length > 0)
1562
+ return err("plan_self_review_failed", "plan self-review failed: " + missing.join("; "));
1563
+ }
1564
+ return {
1565
+ ok: true,
1566
+ next: {
1567
+ ...state,
1568
+ plan: {
1569
+ path: relPath,
1570
+ status: "approved",
1571
+ evidence: recorded.evidence,
1572
+ approved_digest: digest.digest,
1573
+ },
1574
+ updated_at: Date.now(),
825
1575
  },
826
- updated_at: Date.now(),
827
- },
828
- };
1576
+ };
1577
+ }
1578
+ return err("flow_already_approved", "already approved; no further transitions");
829
1579
  });
830
1580
  };
831
1581
 
@@ -870,18 +1620,262 @@ export const recordMenuChoice = (
870
1620
  return err("spec_not_approved", "spec must be approved before the execution menu");
871
1621
  if (state.plan.status !== "approved")
872
1622
  return err("plan_not_approved", "plan must be approved before the execution menu");
1623
+ // Recursive-handoff rejection (CA-09): a marked destination never re-offers
1624
+ // the originating handoff choice, even when an adapter or CLI caller
1625
+ // bypasses the destination prompt's four-choice wording.
1626
+ if (state.handoff_destination && choice === "handoff") {
1627
+ return err(
1628
+ "recursive_handoff",
1629
+ "this flow is already a handoff destination — a second handoff is rejected",
1630
+ );
1631
+ }
1632
+ // Lifecycle is set ATOMICALLY with the menu evidence (CA-11/CA-13): an
1633
+ // executing choice starts the plan; a review/handoff choice leaves it
1634
+ // pending. The menu evidence IS the lifecycle evidence — the choice the
1635
+ // user selected on the native question.
1636
+ const executing = choice === "subagent-driven" || choice === "inline";
873
1637
  return {
874
1638
  ok: true,
875
1639
  next: {
876
1640
  ...state,
877
- plan: state.plan.path ? state.plan : { path: planPath, status: state.plan.status },
1641
+ // Legacy fixup (CA-16): a hand-crafted legacy flow.json with an empty
1642
+ // plan.path keeps it empty through menu recording unless restored to
1643
+ // the canonical path here.
1644
+ plan: { ...state.plan, path: state.plan.path || `docs/${slug}/plan.md` },
878
1645
  menu: { presented: true, chosen: choice, evidence: recorded.evidence },
1646
+ execution: executing
1647
+ ? { status: "active", mode: choice as ExecutionMode, evidence: recorded.evidence }
1648
+ : { status: "pending", mode: null, evidence: recorded.evidence },
1649
+ updated_at: Date.now(),
1650
+ },
1651
+ };
1652
+ });
1653
+ };
1654
+
1655
+ /**
1656
+ * Atomically mark a flow as a handoff destination (CA-07, CA-09): one effective
1657
+ * state mutation under the existing lock/CAS writer. Requires approved spec and
1658
+ * plan plus the source menu choice `handoff`; rejects an already marked
1659
+ * destination (recursive_handoff). Sets `handoff_destination: true`, resets the
1660
+ * menu presentation/evidence, and keeps execution pending. Host-neutral
1661
+ * (CA-10): OpenCode, Cursor, and the CLI all reach this single core mutation.
1662
+ */
1663
+ export const markHandoffDestination = (
1664
+ root: string,
1665
+ slug: string,
1666
+ planPath: string,
1667
+ ): FlowGateResult => {
1668
+ const doc = resolveDoc(root, slug, planPath, "plan");
1669
+ if (!doc.ok) return err("path_invalid", doc.error);
1670
+ return readModifyWrite(root, slug, (state) => {
1671
+ if (state.spec.status !== "approved")
1672
+ return err("spec_not_approved", "spec must be approved before marking a handoff destination");
1673
+ if (state.plan.status !== "approved")
1674
+ return err("plan_not_approved", "plan must be approved before marking a handoff destination");
1675
+ if (state.handoff_destination) {
1676
+ return err(
1677
+ "recursive_handoff",
1678
+ "this flow is already a handoff destination — a second handoff is rejected",
1679
+ );
1680
+ }
1681
+ if (state.menu.chosen !== "handoff") {
1682
+ return err(
1683
+ "handoff_not_chosen",
1684
+ `source menu choice must be "handoff" to mark a handoff destination (chosen: ${JSON.stringify(state.menu.chosen)})`,
1685
+ );
1686
+ }
1687
+ return {
1688
+ ok: true,
1689
+ next: {
1690
+ ...state,
1691
+ handoff_destination: true,
1692
+ menu: { presented: false, chosen: "", evidence: null },
879
1693
  updated_at: Date.now(),
880
1694
  },
881
1695
  };
882
1696
  });
883
1697
  };
884
1698
 
1699
+ const CLI_CONFIRMATION_KEYS = ["attested", "confirmation", "host"];
1700
+
1701
+ /**
1702
+ * Strict shape validation for lifecycle evidence (CA-19, CA-21): OpenCode and
1703
+ * Cursor use the existing native-choice validation; CLI evidence accepts ONLY
1704
+ * the exact `{ host: "cli", attested: false, confirmation: "flag" | "tty" }`
1705
+ * constant — no caller data, no attestation.
1706
+ */
1707
+ const validateLifecycleEvidence = (
1708
+ input: unknown,
1709
+ ): { ok: true; evidence: LifecycleEvidence } | { ok: false; error: string } => {
1710
+ if (typeof input !== "object" || input === null) {
1711
+ return {
1712
+ ok: false,
1713
+ error: "lifecycle evidence required — native choice evidence or an exact CLI confirmation",
1714
+ };
1715
+ }
1716
+ const record = input as Record<string, unknown>;
1717
+ if (record.host === "cli") {
1718
+ const validValue =
1719
+ record.attested === false &&
1720
+ (record.confirmation === "flag" || record.confirmation === "tty");
1721
+ const keys = Object.keys(record).sort();
1722
+ const exactShape =
1723
+ keys.length === CLI_CONFIRMATION_KEYS.length &&
1724
+ CLI_CONFIRMATION_KEYS.every((key) => keys.includes(key));
1725
+ if (validValue && exactShape) {
1726
+ return {
1727
+ ok: true,
1728
+ evidence: {
1729
+ host: "cli",
1730
+ attested: false,
1731
+ confirmation: record.confirmation as "flag" | "tty",
1732
+ },
1733
+ };
1734
+ }
1735
+ return {
1736
+ ok: false,
1737
+ error:
1738
+ 'cli confirmations accept only the exact { host: "cli", attested: false, confirmation: "flag" | "tty" } shape',
1739
+ };
1740
+ }
1741
+ return assertEvidenceShape(input);
1742
+ };
1743
+
1744
+ const errPendingFlow = (action: string): FlowError =>
1745
+ err("flow_not_active", `cannot ${action} a pending flow — the execution menu has not started it`);
1746
+
1747
+ const errCompletedFlow = (action: string): FlowError =>
1748
+ err("flow_already_completed", `cannot ${action} a completed flow`);
1749
+
1750
+ /**
1751
+ * Completion (CA-23): acquire/read/reconcile/validate and capture the exact
1752
+ * effective state plus the ledger result; RELEASE the lock; run repository
1753
+ * verification outside the lock (no expensive command ever runs while a flow
1754
+ * lock is held); stop on nonzero verification; reacquire and compare-and-swap
1755
+ * the completed state against the captured state — a concurrent mutation during
1756
+ * verification returns flow_concurrent_conflict rather than rerunning
1757
+ * verification or overwriting the newer state.
1758
+ */
1759
+ const completeExecution = (
1760
+ root: string,
1761
+ slug: string,
1762
+ deps?: { verifyProject?: typeof runVerifyProject },
1763
+ ): FlowGateResult => {
1764
+ const file = flowPath(root, slug);
1765
+ const captured = readEffectiveFlowState(root, slug);
1766
+ if (!captured.ok) return captured;
1767
+ const exec = captured.state.execution;
1768
+ if (exec.status === "pending") return errPendingFlow("complete");
1769
+ if (exec.status === "completed") return errCompletedFlow("complete");
1770
+ const ledger = ledgerCompletion(root, slug);
1771
+ if (!ledger.complete) {
1772
+ return err(
1773
+ "execution_incomplete",
1774
+ `execution ledger incomplete for ${slug}: missing tasks ${ledger.missing.join(", ")}`,
1775
+ { required: ledger.required, completed: ledger.completed, missing: ledger.missing },
1776
+ );
1777
+ }
1778
+ const verifier = deps?.verifyProject ?? runVerifyProject;
1779
+ const verify = verifier(root, false);
1780
+ if (verify.exitCode !== 0) {
1781
+ return err(
1782
+ "verification_failed",
1783
+ `repository verification failed for ${slug} (exit ${verify.exitCode}) — see the verification output`,
1784
+ { exitCode: verify.exitCode },
1785
+ );
1786
+ }
1787
+ const locked = withFlowLock<FlowGateResult>(file, () => {
1788
+ const strict = readFlowStrict(root, slug);
1789
+ if (!strict.ok) return strict;
1790
+ const reconciled = reconcileState(root, slug, strict.state);
1791
+ const currentExec = reconciled.state.execution;
1792
+ if (currentExec.status !== exec.status || currentExec.mode !== exec.mode) {
1793
+ return err(
1794
+ "flow_concurrent_conflict",
1795
+ `concurrent execution state change detected for ${slug}: re-read the flow state and retry completion`,
1796
+ );
1797
+ }
1798
+ const next: FlowState = {
1799
+ ...reconciled.state,
1800
+ execution: { ...exec, status: "completed" },
1801
+ // A completed flow is never a destination: clear the context so the next
1802
+ // ordinary session gets the source five-choice reminder, not the stale
1803
+ // four-choice destination wording (CA-08). Both approval-drift resets
1804
+ // (resetForSpecDrift/resetForPlanDrift) and completion clear
1805
+ // handoff_destination; only a new-flow prepareFlowState initializes it.
1806
+ handoff_destination: false,
1807
+ updated_at: Date.now(),
1808
+ };
1809
+ const commit = writeFlowStateIfCurrent(root, captured.state, next);
1810
+ if (commit.ok) return { ok: true };
1811
+ if ("io_error" in commit) {
1812
+ return err("flow_io_error", `flow state write failed for ${slug}: ${commit.io_error}`);
1813
+ }
1814
+ return err(
1815
+ "flow_concurrent_conflict",
1816
+ `concurrent flow update detected for ${slug}: re-read the flow state and retry completion`,
1817
+ );
1818
+ });
1819
+ if (!locked.locked) return locked.error;
1820
+ return locked.value;
1821
+ };
1822
+
1823
+ /**
1824
+ * Execution lifecycle transitions (CA-11, CA-14, CA-23): pause, resume, and
1825
+ * complete move the plan between the only four states — pending, active,
1826
+ * paused, completed. Pause/resume run under the per-flow critical section and
1827
+ * preserve the retained mode and original lifecycle evidence; every SDD
1828
+ * artifact (briefs, reviews, ledger) is untouched. Completion is orchestrated
1829
+ * by completeExecution (ledger check -> verification outside the lock -> CAS).
1830
+ */
1831
+ export const transitionExecution = (
1832
+ root: string,
1833
+ slug: string,
1834
+ planPath: string,
1835
+ action: "pause" | "resume" | "complete",
1836
+ evidence: LifecycleEvidence,
1837
+ ctx?: MutationContext,
1838
+ deps?: { verifyProject?: typeof runVerifyProject },
1839
+ ): FlowGateResult => {
1840
+ const bound = assertMutationWorkspace(root, ctx);
1841
+ if (!bound.ok) return bound;
1842
+ const validated = validateLifecycleEvidence(evidence);
1843
+ if (!validated.ok) return err("evidence_invalid", validated.error);
1844
+ const doc = resolveDoc(root, slug, planPath, "plan");
1845
+ if (!doc.ok) return err("path_invalid", doc.error);
1846
+
1847
+ if (action === "complete") return completeExecution(root, slug, deps);
1848
+
1849
+ if (action === "pause") {
1850
+ return readModifyWrite(root, slug, (state) => {
1851
+ const exec = state.execution;
1852
+ if (exec.status === "pending") return errPendingFlow("pause");
1853
+ if (exec.status === "completed") return errCompletedFlow("pause");
1854
+ if (exec.status === "paused") return err("flow_already_paused", "flow is already paused");
1855
+ return {
1856
+ ok: true,
1857
+ next: { ...state, execution: { ...exec, status: "paused" }, updated_at: Date.now() },
1858
+ };
1859
+ });
1860
+ }
1861
+ return readModifyWrite(root, slug, (state) => {
1862
+ const exec = state.execution;
1863
+ if (exec.status === "completed") return errCompletedFlow("resume");
1864
+ if (exec.status !== "paused") {
1865
+ return err(
1866
+ "flow_not_paused",
1867
+ exec.status === "active"
1868
+ ? "flow is already active — cannot resume"
1869
+ : "cannot resume a pending flow — the execution menu has not started it",
1870
+ );
1871
+ }
1872
+ return {
1873
+ ok: true,
1874
+ next: { ...state, execution: { ...exec, status: "active" }, updated_at: Date.now() },
1875
+ };
1876
+ });
1877
+ };
1878
+
885
1879
  export const slugFromPath = (p: string) => {
886
1880
  const dirName = path.basename(path.dirname(p));
887
1881
  return dirName === "." || dirName === "/" || dirName === "" ? "" : dirName;
@@ -907,7 +1901,11 @@ export const assertFlowGates = (
907
1901
  const doc = resolveDoc(root, "", planPath, "plan");
908
1902
  if (!doc.ok) return err("path_invalid", doc.error);
909
1903
  const slug = slugFromPath(planPath);
910
- const state = readFlowState(root, slug);
1904
+ // Effective read (CA-02): digest reconciliation runs before the gate trusts
1905
+ // persisted approvals; drift resets are persisted before gating.
1906
+ const effective = readEffectiveFlowState(root, slug);
1907
+ if (!effective.ok) return effective;
1908
+ const state = effective.state;
911
1909
  if (state.spec.status !== "approved") {
912
1910
  return err(
913
1911
  "spec_not_approved",
@@ -934,6 +1932,8 @@ export const assertFlowGates = (
934
1932
  * is blocked until the spec is approved, the plan is approved, the execution
935
1933
  * menu has been recorded (when required), and the canonical docs validate.
936
1934
  * The optional MutationContext adds the coordinator boundary (FG-05, CA-20).
1935
+ * The gate reconciles approval digests before trusting persisted approvals
1936
+ * (CA-02); drift resets are persisted before gating.
937
1937
  */
938
1938
  export const assertProductGates = (
939
1939
  root: string,
@@ -943,12 +1943,13 @@ export const assertProductGates = (
943
1943
  ): FlowGateResult => {
944
1944
  const bound = assertMutationWorkspace(root, ctx);
945
1945
  if (!bound.ok) return bound;
946
- // Strict read (CA-18): missing or corrupt state surfaces flow_not_activated /
947
- // flow_corrupt, never a misleading spec_not_approved from a silent draft
948
- // fallback. Fail-closed is preserved — no gate ever passes on absent state.
949
- const strict = readFlowStrict(root, slug);
950
- if (!strict.ok) return strict;
951
- const state = strict.state;
1946
+ // Effective strict read (CA-18): missing state surfaces flow_not_activated,
1947
+ // malformed state flow_state_invalid — never a misleading spec_not_approved
1948
+ // from a silent draft fallback. Fail-closed is preserved — no gate ever
1949
+ // passes on absent state.
1950
+ const effective = readEffectiveFlowState(root, slug);
1951
+ if (!effective.ok) return effective;
1952
+ const state = effective.state;
952
1953
  if (state.spec.status !== "approved") {
953
1954
  return err(
954
1955
  "spec_not_approved",
@@ -977,7 +1978,7 @@ export const assertProductGates = (
977
1978
  });
978
1979
  if (validated.ok === false) return err("docs_invalid", validated.error);
979
1980
  }
980
- return assertCoordinatorBoundary(ctx, state.menu);
1981
+ return assertCoordinatorBoundary(ctx, state);
981
1982
  };
982
1983
 
983
1984
  /**