thinkpool-pair 0.7.360 → 0.7.362

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bridge.mjs CHANGED
@@ -77,7 +77,7 @@ import { explicitKeepAwakeChoice, keepAwakeEnabled, saveKeepAwakePreference, sta
77
77
 
78
78
  const STRUCTURED_MODES = new Set(['default', 'acceptEdits', 'plan', 'review', 'bypassPermissions'])
79
79
  import { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, FLOW_CODEX_CONDUCTOR_PROMPT, FLOW_CODEX_LANE_PROMPT, buildConductorEnv, assembleCrossWaveContext, buildLanePrompt } from './flow-conductor.mjs'
80
- import { legacyBuilderCompletionAllowed, normalizePlanOutput, validatePlanForRuntime, validReviewTargetShape } from './flow-task-graph.mjs'
80
+ import { legacyBuilderCompletionAllowed, normalizeBaselineEvidence, normalizePlanOutput, validatePlanForRuntime, validateTaskContractSeal, validReviewTargetShape } from './flow-task-graph.mjs'
81
81
  import { normalizeFlowRuntime, flowLaneModelFor, flowConductorModelFor, spawnedLaneModelFor, modelCatalogValues, resolveCodexModel, resolveHermesOpenModel, assertRuntimeModelCompatible } from './flow-models.mjs'
82
82
  // S1 (context-offload) — durable digest store. mark_flow_done digests a closed slice in;
83
83
  // the dispatch loop reads the bounded cross-wave context back out (via assembleCrossWaveContext,
@@ -97,6 +97,7 @@ function stopFlowPreviews (flowId, laneId = null) {
97
97
  }
98
98
  }
99
99
  import { FLOW_REVIEWER_PROMPT, FLOW_CODEX_REVIEWER_PROMPT, revertLane, parseReviewVerdict, reviewVerdictToReflection } from './flow-review.mjs'
100
+ import { createFlowReviewReceipt, createFlowReviewSingleFlight, isFlowReviewReceipt } from './flow-receipt.mjs'
100
101
  import { reviewGateDecision } from './flow-review-gate.mjs'
101
102
  import { IMMUTABLE_REVIEW_READ_TOOL_EXTRAS, UNSANDBOXED_REVIEW_CHECK_TOOL_EXTRAS, readReviewFile, runReviewCheck } from './review-check.mjs'
102
103
  import { pairAdjudicationPrompt, reviewReflectionDecision, REVIEW_DEFAULTS } from './flow-review-reflect.mjs'
@@ -130,7 +131,7 @@ import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce,
130
131
  import { supersedeDispatchLease } from './dispatch-lease.mjs'
131
132
  import { realtimeRecoveryDecision, turnInFlight } from './update-gate.mjs'
132
133
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage, acknowledgePendingScheduledOutcome, commitRecordedScheduledOutcome, deletePendingScheduledOutcome, loadPendingScheduledOutcome, loadPendingScheduledOutcomes, savePendingScheduledOutcome } from './session-store.mjs'
133
- import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, ImageEventQueue, imageQueueConfig, uploadCodeImage as uploadCodeImageRequest, usageReportLine, codexUsageReportLine, appendCurrentPersonRequest, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
134
+ import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, ImageEventQueue, imageQueueConfig, uploadCodeImage as uploadCodeImageRequest, usageReportLine, codexUsageReportLine, appendCurrentPersonRequest, buildCheckpointFromLog, buildRecapFromLog, resolveCheckpointCarry, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
134
135
  import { createLatestReplayPump, requestedReplayIds } from './replay-transport.mjs'
135
136
  import { classifyCodeEvent, unknownCodeEventNotice } from './code-event-contract.mjs'
136
137
  import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
@@ -966,11 +967,13 @@ const MIN_TURN_MS = Number(process.env.TP_NOTIFY_MIN_TURN_MS) > 0
966
967
  ? Number(process.env.TP_NOTIFY_MIN_TURN_MS) : DEFAULT_MIN_TURN_MS
967
968
  function persistAgentEvent (payload) {
968
969
  if (AGENT_NOTIFY_OFF || !codeAuthToken || !myServeUid || !room) return
970
+ const { eventId, ...eventPayload } = payload || {}
971
+ const stableId = typeof eventId === 'string' && /^[A-Za-z0-9_-]{1,120}$/.test(eventId) ? eventId : randomUUID()
969
972
  const row = {
970
- id: `ae:${randomUUID()}`,
973
+ id: `ae:${stableId}`,
971
974
  session_code: room,
972
975
  author_id: myServeUid,
973
- payload: { __agent: true, ts: Date.now(), ...payload },
976
+ payload: { __agent: true, ts: Date.now(), ...eventPayload },
974
977
  }
975
978
  fetch(`${SUPABASE_URL}/rest/v1/code_messages`, {
976
979
  method: 'POST',
@@ -2427,6 +2430,13 @@ function emitCodexCompactionControl(entry, text, by = null) {
2427
2430
  bcast('code-event', { term: entry.id, evt })
2428
2431
  }
2429
2432
 
2433
+ function armCompactionCheckpoint(entry) {
2434
+ const checkpoint = buildCheckpointFromLog(entry?.log)
2435
+ if (!checkpoint) return false
2436
+ entry.pendingCheckpoint = checkpoint
2437
+ return true
2438
+ }
2439
+
2430
2440
  async function runCodexCompaction(entry, request = {}) {
2431
2441
  const recap = buildRecapFromLog(entry.log, RECAP_CAP, { reason: 'compact' })
2432
2442
  if (!recap) {
@@ -2450,6 +2460,7 @@ async function runCodexCompaction(entry, request = {}) {
2450
2460
  if (!entry.session.turnActive && settleLaneControl(entry)) announce()
2451
2461
  }
2452
2462
  if (nativeCompacted === true) {
2463
+ armCompactionCheckpoint(entry)
2453
2464
  const evt = { kind: 'compaction', trigger: 'manual', preTokens, by: request.by, native: true }
2454
2465
  pushLog(entry, evt)
2455
2466
  bcast('code-event', { term: entry.id, evt })
@@ -2468,7 +2479,9 @@ async function runCodexCompaction(entry, request = {}) {
2468
2479
  emitCodexCompactionControl(entry, 'Codex context compaction unavailable right now', request.by)
2469
2480
  return
2470
2481
  }
2471
- entry.pendingRecap = recap
2482
+ const carry = resolveCheckpointCarry(recap, entry.pendingCheckpoint)
2483
+ entry.pendingRecap = carry.pendingRecap
2484
+ entry.pendingCheckpoint = carry.pendingCheckpoint
2472
2485
  const evt = { kind: 'compaction', trigger: 'manual', preTokens, by: request.by }
2473
2486
  pushLog(entry, evt)
2474
2487
  bcast('code-event', { term: entry.id, evt })
@@ -2523,7 +2536,7 @@ function worktreeSnapshot(cwd) {
2523
2536
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
2524
2537
  // persist to the host file; tool calls round-trip through the perm card; the
2525
2538
  // rolling log replays to joiners and survives bridge restarts (session-store).
2526
- function openStructured({ id, runtime = 'claude', model, models, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, rolePrompt, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage, receivedTurnCids, scheduleRunId, scheduleDeadlineAt, scheduleOutcomeRecorded, scheduleAdmissionLease }) {
2539
+ function openStructured({ id, runtime = 'claude', model, models, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, rolePrompt, sliceType, flowSessionId, flowTaskKey, flowTaskContract, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, flowReviewConclusion, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, carryCheckpoint, lastUsage, receivedTurnCids, scheduleRunId, scheduleDeadlineAt, scheduleOutcomeRecorded, scheduleAdmissionLease }) {
2527
2540
  if (sessions.has(id)) return
2528
2541
  runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
2529
2542
  // Fail closed before exposing a native lane if its bridge semantic contract
@@ -2596,6 +2609,9 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2596
2609
  flowReviewTargets: Array.isArray(flowReviewTargets) && flowReviewTargets.length ? flowReviewTargets.filter(Boolean) : (flowReviewTarget ? [flowReviewTarget] : []),
2597
2610
  flowReviewSnapshots: Array.isArray(flowReviewSnapshots) ? flowReviewSnapshots.filter((item) => item?.taskKey && item?.sha && item?.cwd) : [],
2598
2611
  flowReviewRound: Number.isInteger(flowReviewRound) && flowReviewRound >= 0 ? flowReviewRound : 0,
2612
+ flowReviewConclusion: flowReviewConclusion?.taskKey && ['pass', 'reject', 'surface'].includes(flowReviewConclusion?.action) && isFlowReviewReceipt(flowReviewConclusion?.receipt)
2613
+ ? { taskKey: flowReviewConclusion.taskKey, action: flowReviewConclusion.action, round: flowReviewConclusion.round, receipt: flowReviewConclusion.receipt }
2614
+ : null,
2599
2615
  dispatchBaseSha: dispatchBaseSha || null,
2600
2616
  revertTarget: revertTarget || null,
2601
2617
  // Stable creation order — persisted so a bridge restart restores tabs in the SAME
@@ -2635,6 +2651,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2635
2651
  // event-id.mjs + src/pages/code/seqDedup.js). pushLog() is the single stamp point.
2636
2652
  entry.seq = makeSeqCounter(maxSeq(entry.log))
2637
2653
  entry.rolePrompt = rolePrompt || null // FL-M6 — persisted so a bridge restart restores the flow role prompt
2654
+ entry.flowTaskContract = flowTaskContract && typeof flowTaskContract === 'object' ? flowTaskContract : null
2638
2655
  // lastUsage — the terminal's most recent usage/ctx meter. Persisted (sessionData) +
2639
2656
  // restored so a bridge restart can re-emit it on replay: usage is chrome (kept out of
2640
2657
  // the replayed transcript log), so without this a (re)joiner sees "—" for context until
@@ -2649,10 +2666,17 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2649
2666
  // MCP tool AND the FLOW_DONE sentinel-Write intercept: like the conductor's submit, the
2650
2667
  // MCP tool is DEFERRED (needs ToolSearch, which hangs), so lanes signal done by Writing a
2651
2668
  // FLOW_DONE file — a DIRECT tool — which the PreToolUse hook routes here. Returns a message.
2652
- const markFlowDone = async ({ reviewPass = false } = {}) => {
2669
+ const markFlowDone = async ({ reviewPass = false, baselineEvidence = '' } = {}) => {
2653
2670
  if (!entry.flowSessionId || !entry.flowTaskKey) return 'Not a Flow lane — nothing to mark done.'
2654
2671
  if (entry.flowRole !== 'builder' && !(reviewPass && entry.flowRole === 'reviewer')) return 'This Flow role cannot mark a builder slice done.'
2655
2672
  if (entry.flowDone) return 'This slice is already recorded as done.'
2673
+ let baselineReceipt = ''
2674
+ const requiresBaselineReceipt = !reviewPass && !!entry.flowTaskContract?.baseline?.gate
2675
+ if (requiresBaselineReceipt) {
2676
+ try { baselineReceipt = normalizeBaselineEvidence(baselineEvidence) } catch (e) {
2677
+ return `Slice "${entry.flowTaskKey}" is not done: submit the real bounded baselineEvidence receipt (${e?.message || e}). Observe the assigned pre-edit gate; do not fabricate a RED sentence.`
2678
+ }
2679
+ }
2656
2680
  let commitSha = null
2657
2681
  try { commitSha = execFileSync('git', ['-C', entry.cwd || process.cwd(), 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() } catch { /* no commits yet */ }
2658
2682
  if (!reviewPass && entry.dispatchBaseSha && commitSha === entry.dispatchBaseSha) {
@@ -2681,13 +2705,16 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2681
2705
  ]
2682
2706
  const digest = digestSlice(
2683
2707
  { key: entry.flowTaskKey, title: termNames[id] || entry.flowTaskKey },
2684
- { acceptanceProof: commitSha ? `committed ${commitSha.slice(0, 8)}` : 'slice done', artifacts },
2708
+ { acceptanceProof: `${baselineReceipt ? `baseline: ${baselineReceipt}; ` : ''}${commitSha ? `committed ${commitSha.slice(0, 8)}` : 'slice done'}`, artifacts },
2685
2709
  )
2686
2710
  appendDigest(entry.flowSessionId, digest, { baseDir: process.cwd() }) // idempotent per (flowId, taskKey)
2687
2711
  } catch (e) {
2688
2712
  process.stderr.write(`\n ${A.dim}◆ flow digest skip ${entry.flowTaskKey} — ${e?.message || e}${A.rst}\n`)
2689
2713
  }
2690
- bcast('flow-task-done', { term: id, flowId: entry.flowSessionId, taskKey: entry.flowTaskKey, laneId: id, commitSha, previewUrl, ...(reviewPass ? { reviewAction: 'pass' } : {}) }, flowChannel)
2714
+ const completedContract = requiresBaselineReceipt
2715
+ ? { ...entry.flowTaskContract, baseline: { ...entry.flowTaskContract.baseline, evidence: baselineReceipt } }
2716
+ : entry.flowTaskContract || null
2717
+ bcast('flow-task-done', { term: id, flowId: entry.flowSessionId, taskKey: entry.flowTaskKey, laneId: id, commitSha, previewUrl, contract: completedContract, ...(reviewPass ? { reviewAction: 'pass' } : {}) }, flowChannel)
2691
2718
  process.stderr.write(`\n ${A.cyan}◆ flow slice done — ${entry.flowTaskKey} (${commitSha ? commitSha.slice(0, 8) : 'no commit'})${previewUrl ? ` · preview ${previewUrl}` : ''}${A.rst}\n`)
2692
2719
  entry.flowDone = true // FL-B3 — retire immediately so it drops from the ≤8 lane cap
2693
2720
  setTimeout(() => { try { endStructured(id) } catch { /* already gone */ } }, 2500)
@@ -2698,7 +2725,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2698
2725
  // review gate was dead code: reviewers had no working emit path, so a failing slice was
2699
2726
  // never reverted). On FAIL → broadcast flow-revert for the reviewed slice (the client flips
2700
2727
  // it to pending → the next wave rebuilds it). Either way the review lane itself is done.
2701
- const onReviewVerdict = async (raw) => {
2728
+ const processReviewVerdict = async (raw) => {
2702
2729
  if (!entry.flowSessionId || !entry.flowTaskKey || entry.flowRole !== 'reviewer') return { ok: false, message: 'Not a Flow review lane.' }
2703
2730
  let v, target = null
2704
2731
  try {
@@ -2716,6 +2743,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2716
2743
  ? `Review verdict REJECTED: ${e?.message || e}. Re-call submit_flow_review with a valid authorized taskKey.`
2717
2744
  : `Review verdict REJECTED: ${e?.message || e}. Re-Write FLOW_REVIEW.json with {"pass":<boolean>,"reasons":["<specific finding>"],"taskKey":"<the slice you reviewed>"}.` }
2718
2745
  }
2746
+ if (entry.flowReviewConclusion) return { ok: true, message: `Review already concluded for ${entry.flowReviewConclusion.taskKey}; receipt ${entry.flowReviewConclusion.receipt.cid || 'recorded'}.` }
2719
2747
  // E1 A1/A2 — the BOUNDED reviewer loop, live side. Each FLOW_REVIEW.json write is ONE
2720
2748
  // hunt round; the governor decides continue-vs-stop from the round count + the lane's
2721
2749
  // REAL budget (flowBudgets ledger). A bare pass:true (happy path held, not yet exhausted)
@@ -2757,15 +2785,51 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2757
2785
  } else {
2758
2786
  process.stderr.write(`\n ${A.cyan}◆ review ${decision.action.toUpperCase()} (round ${round}) — ${target || entry.flowTaskKey}${A.rst}\n`)
2759
2787
  }
2760
- // Durable room-visible verdict. `persistAgentEvent` below is intentionally
2761
- // filtered from the transcript (push/unread transport only), while this control
2762
- // row is archived with the reviewer lane and broadcast to both room members.
2763
- // In particular, a held review must show its exact findings instead of leaving
2764
- // the red task state unexplained after the ephemeral Flow broadcast is gone.
2765
- const visibleReview = {
2766
- kind: 'control',
2767
- text: `Flow review ${decision.action.toUpperCase()} — ${target || entry.flowTaskKey}: ${v.reasons.join('; ') || decision.reason}`,
2788
+ // Durable room-visible verdict. Bind a conclusive pass to the exact immutable
2789
+ // builder snapshot plus the acceptance digest inherited at approval time. A
2790
+ // missing/malformed proof can never look like a verified pass: it becomes a
2791
+ // held receipt and assembly stays blocked for pair adjudication.
2792
+ const reviewedTaskKey = target || entry.flowTaskKey
2793
+ const targetSnapshot = entry.flowReviewSnapshots.find((item) => item.taskKey === reviewedTaskKey) || null
2794
+ const acceptanceDigest = entry.flowTaskContract?.digest || null
2795
+ let terminalAction = decision.action
2796
+ let receiptReasons = v.reasons.length ? v.reasons : [decision.reason]
2797
+ let visibleReview
2798
+ try {
2799
+ visibleReview = createFlowReviewReceipt({
2800
+ taskKey: reviewedTaskKey,
2801
+ outcome: terminalAction === 'surface' ? 'held' : terminalAction,
2802
+ rounds: round,
2803
+ candidateSha: targetSnapshot?.sha,
2804
+ acceptanceDigest,
2805
+ reasons: receiptReasons,
2806
+ })
2807
+ } catch (error) {
2808
+ if (terminalAction !== 'pass') {
2809
+ return { ok: false, message: `Review verdict REJECTED: ${error?.message || error}.` }
2810
+ }
2811
+ terminalAction = 'surface'
2812
+ receiptReasons = [...receiptReasons, 'Exact candidate or sealed acceptance proof is missing; pass was held.']
2813
+ visibleReview = createFlowReviewReceipt({
2814
+ taskKey: reviewedTaskKey,
2815
+ outcome: 'held',
2816
+ rounds: round,
2817
+ candidateSha: targetSnapshot?.sha,
2818
+ acceptanceDigest,
2819
+ reasons: receiptReasons,
2820
+ })
2821
+ }
2822
+ visibleReview.cid = randomUUID()
2823
+ entry.flowReviewConclusion = {
2824
+ taskKey: reviewedTaskKey,
2825
+ action: terminalAction,
2826
+ round,
2827
+ receipt: visibleReview,
2768
2828
  }
2829
+ // Persist the conclusion before emitting side effects. A retry or restart
2830
+ // can reconstruct a missing transcript row from this safe receipt, but can
2831
+ // never conclude the same review twice.
2832
+ entry.flush?.()
2769
2833
  pushLog(entry, visibleReview)
2770
2834
  bcast('code-event', { term: id, evt: visibleReview })
2771
2835
  entry.flush?.()
@@ -2775,45 +2839,53 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2775
2839
  // broadcast, does NOT change the revert/gate flow.
2776
2840
  bcast('flow-review-verdict', {
2777
2841
  term: id, flowId: entry.flowSessionId,
2778
- taskKey: target || entry.flowTaskKey,
2779
- pass: v.pass,
2780
- reasons: v.reasons,
2781
- action: decision.action,
2842
+ taskKey: reviewedTaskKey,
2843
+ pass: terminalAction === 'pass',
2844
+ reasons: receiptReasons,
2845
+ action: terminalAction,
2782
2846
  rounds: round,
2783
- surfaceToPair: decision.surfaceToPair,
2847
+ surfaceToPair: terminalAction === 'surface' || decision.surfaceToPair,
2784
2848
  prompt: pairAdjudicationPrompt({
2785
- action: decision.action,
2786
- reason: decision.reason,
2787
- taskKey: target || entry.flowTaskKey,
2788
- findings: v.reasons,
2849
+ action: terminalAction,
2850
+ reason: terminalAction === decision.action ? decision.reason : receiptReasons.at(-1),
2851
+ taskKey: reviewedTaskKey,
2852
+ findings: receiptReasons,
2789
2853
  }),
2790
2854
  }, flowChannel)
2791
2855
  let doneMsg = ''
2792
- if (decision.action === 'pass') {
2856
+ if (terminalAction === 'pass') {
2793
2857
  doneMsg = await markFlowDone({ reviewPass: true })
2794
2858
  } else {
2795
- if (decision.action === 'surface') {
2796
- bcast('flow-review-held', { term: id, flowId: entry.flowSessionId, reviewTaskKey: entry.flowTaskKey, taskKey: target, reasons: v.reasons }, flowChannel)
2797
- persistAgentEvent({ kind: 'needs-input', term: id, termName: termNames[id] || null, summary: `Flow review held ${target || entry.flowTaskKey}: ${v.reasons.join('; ') || decision.reason}` })
2859
+ if (terminalAction === 'surface') {
2860
+ bcast('flow-review-held', { term: id, flowId: entry.flowSessionId, reviewTaskKey: entry.flowTaskKey, taskKey: target, reasons: receiptReasons }, flowChannel)
2861
+ persistAgentEvent({ eventId: visibleReview.cid, kind: 'needs-input', term: id, termName: termNames[id] || null, summary: `Flow review held ${reviewedTaskKey}: ${receiptReasons.join('; ') || decision.reason}` })
2798
2862
  }
2799
2863
  // A reject must rebuild the target and then run a fresh reviewer. Retire that
2800
2864
  // reviewer now. A surfaced inconclusive review is different: keep its lane
2801
2865
  // alive with the durable control row above so either room member can open the
2802
2866
  // held review, read the exact findings, and continue/adjudicate after reload.
2803
2867
  // Neither outcome emits task-done or permits assembly.
2804
- if (decision.action === 'reject') {
2868
+ if (terminalAction === 'reject') {
2805
2869
  entry.flowDone = true
2806
2870
  entry.flush?.()
2807
2871
  setTimeout(() => { try { endStructured(id) } catch { /* already gone */ } }, 2500)
2808
2872
  }
2809
2873
  }
2810
- const label = decision.action === 'reject'
2811
- ? `REJECT (round ${round}) — reverting ${target || '(no target)'} (${v.reasons.join('; ').slice(0, 140)})`
2812
- : decision.action === 'surface'
2813
- ? `SURFACED to the pair after ${round} round${round === 1 ? '' : 's'} — ${decision.reason}`
2874
+ const label = terminalAction === 'reject'
2875
+ ? `REJECT (round ${round}) — reverting ${target || '(no target)'} (${receiptReasons.join('; ').slice(0, 140)})`
2876
+ : terminalAction === 'surface'
2877
+ ? `SURFACED to the pair after ${round} round${round === 1 ? '' : 's'} — ${receiptReasons.at(-1) || decision.reason}`
2814
2878
  : `PASS (round ${round})`
2815
2879
  return { ok: true, message: `Review verdict recorded: ${label}.${doneMsg ? ` ${doneMsg}` : ''}` }
2816
2880
  }
2881
+ const runFlowReviewVerdict = createFlowReviewSingleFlight()
2882
+ const onReviewVerdict = async (raw) => {
2883
+ if (entry.flowReviewConclusion) return { ok: true, message: `Review already concluded for ${entry.flowReviewConclusion.taskKey}; receipt ${entry.flowReviewConclusion.receipt.cid || 'recorded'}.` }
2884
+ // One terminal verdict is one transaction. In particular, reject awaits the
2885
+ // host revert before it can persist its conclusion; without this single-flight
2886
+ // fence two simultaneous submissions can both cross that await and revert/emit.
2887
+ return runFlowReviewVerdict(() => processReviewVerdict(raw))
2888
+ }
2817
2889
  // Identity for the durable archive — pushLog appends every new transcript event to
2818
2890
  // <room>/<id>.events.jsonl keyed off these. Seed the archive once from the restored
2819
2891
  // (≤2000) log so the retained window is immediately pageable; no-op if it already
@@ -2870,13 +2942,19 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2870
2942
  // restart. Without this, sessionData omitted it → on restart the resumed session
2871
2943
  // re-launched on the host default (Opus) regardless of the last switch, and the
2872
2944
  // switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
2873
- const sessionData = () => ({ sessionId: entry.session?.sessionId || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, models: entry.runtime === 'hermes' ? (entry.models || []) : undefined, provider: entry.provider || null, spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth || 0, cascadeRole: entry.cascadeRole || null, hop: entry.hop || 0, sideParent: entry.sideParent, sideTask: entry.sideTask, scheduleRunId: entry.scheduleRunId || null, scheduleDeadlineAt: entry.scheduleDeadlineAt || null, scheduleOutcomeRecorded: entry.scheduleOutcomeRecorded === true, scheduleAdmissionLease: entry.scheduleAdmissionLease || null, pendingSideContexts: entry.pendingSideContexts, pendingWorkerCompletions: entry.pendingWorkerCompletions, workerCompletionsInFlight: entry.workerCompletionsInFlight, sliceType: entry.sliceType, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], flowReviewSnapshots: entry.flowReviewSnapshots || [], flowReviewRound: entry.flowReviewRound || 0, dispatchBaseSha: entry.dispatchBaseSha || null, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null, carryRecap: entry.pendingRecap || null, receivedTurnCids: [...entry.receivedTurnCids].slice(-1000) })
2945
+ const sessionData = () => ({ sessionId: entry.session?.sessionId || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, models: entry.runtime === 'hermes' ? (entry.models || []) : undefined, provider: entry.provider || null, spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth || 0, cascadeRole: entry.cascadeRole || null, hop: entry.hop || 0, sideParent: entry.sideParent, sideTask: entry.sideTask, scheduleRunId: entry.scheduleRunId || null, scheduleDeadlineAt: entry.scheduleDeadlineAt || null, scheduleOutcomeRecorded: entry.scheduleOutcomeRecorded === true, scheduleAdmissionLease: entry.scheduleAdmissionLease || null, pendingSideContexts: entry.pendingSideContexts, pendingWorkerCompletions: entry.pendingWorkerCompletions, workerCompletionsInFlight: entry.workerCompletionsInFlight, sliceType: entry.sliceType, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowTaskContract: entry.flowTaskContract, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], flowReviewSnapshots: entry.flowReviewSnapshots || [], flowReviewRound: entry.flowReviewRound || 0, flowReviewConclusion: entry.flowReviewConclusion, dispatchBaseSha: entry.dispatchBaseSha || null, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null, carryRecap: entry.pendingRecap || null, carryCheckpoint: entry.pendingCheckpoint || null, receivedTurnCids: [...entry.receivedTurnCids].slice(-1000) })
2874
2946
  const persist = () => saveSession(room, id, sessionData())
2875
2947
  // Synchronous flush of this session's record. Used on open (so a brand-new session
2876
2948
  // has a file under its id BEFORE its first event — surviving a restart inside the
2877
2949
  // 1.5s saveSession debounce window) and on shutdown (so events since the last
2878
2950
  // debounced write aren't lost). Contract #2: restart resumes, no lost messages.
2879
2951
  entry.flush = () => flushSession(room, id, sessionData())
2952
+ if (entry.flowReviewConclusion?.receipt && !entry.log.some((event) => event?.cid === entry.flowReviewConclusion.receipt.cid)) {
2953
+ const restoredReceipt = { ...entry.flowReviewConclusion.receipt }
2954
+ pushLog(entry, restoredReceipt)
2955
+ bcast('code-event', { term: id, evt: restoredReceipt })
2956
+ entry.flush()
2957
+ }
2880
2958
  if (entry.scheduleOutcomeRecorded && entry.scheduleAdmissionLease) {
2881
2959
  void releaseScheduledAdmissionForEntry(entry).then((released) => {
2882
2960
  if (released) entry.flush?.()
@@ -3450,9 +3528,9 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3450
3528
  // (slices whose deps just got satisfied).
3451
3529
  ...(entry.flowRole === 'builder' ? [tool(
3452
3530
  'mark_flow_done',
3453
- "ThinkPool Flow ONLY — call this ONCE when your slice is built, RUNS, and meets its acceptance criteria. Records your latest commit as the slice's atomic-revert target and signals the room that this slice is done (which unblocks slices that depended on you). No arguments: your commit is read from your worktree. Do not call before your slice actually runs + meets acceptance.",
3454
- {},
3455
- async () => ({ content: [{ type: 'text', text: await markFlowDone() }] }),
3531
+ "ThinkPool Flow ONLY — call this ONCE when your slice is built, RUNS, and meets its acceptance criteria. For a gate-first task, pass `baselineEvidence`: the real, bounded pre-edit command/behavior receipt proving the assigned failure/absence. Never fabricate a RED sentence. Records your latest commit as the slice's atomic-revert target and signals the room that this slice is done (which unblocks slices that depended on you). Do not call before the slice actually runs + meets acceptance.",
3532
+ { baselineEvidence: z.string().max(1400).optional().describe('real single-line pre-edit baseline command/behavior receipt; required for new gate-first tasks') },
3533
+ async (args) => ({ content: [{ type: 'text', text: await markFlowDone({ baselineEvidence: args?.baselineEvidence || '' }) }] }),
3456
3534
  )] : []),
3457
3535
  // FL-B1 — the conductor SUBMITS its decomposition through THIS tool, not the built-in
3458
3536
  // ExitPlanMode. In the current SDK, ExitPlanMode is a deferred tool the conductor must
@@ -3462,17 +3540,17 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3462
3540
  // reject malformed plans back to the conductor so it re-emits.
3463
3541
  ...(entry.flowRole === 'conductor' ? [tool(
3464
3542
  'submit_flow_plan',
3465
- 'ThinkPool Flow CONDUCTOR ONLY — submit your decomposition for human approval. Call this ONCE when your task-graph is ready. Pass it as `plan`: a JSON string {"summary":"<one line>","tasks":[{"key":"<kebab>","title":"…","scope":"…","acceptance":"…","deps":["<key>"],"sliceType":"feature|scaffold|review|fix"}]}. The room validates it is an acyclic DAG, persists the tasks, and shows the approval card. This is how a conductor FINISHES — do NOT use ExitPlanMode, do NOT write a plan file.',
3543
+ 'ThinkPool Flow CONDUCTOR ONLY — submit your decomposition for human approval. Call this ONCE when your task-graph is ready. Pass it as `plan`: a JSON string {"summary":"<one line; assumptions: …>","tasks":[{"key":"<kebab>","title":"…","scope":"…","acceptance":"observable proof","nonGoals":["bounded exclusion"],"baseline":{"gate":"what fails/is absent before edits","evidence":"optional observed receipt"},"deps":["<key>"],"sliceType":"feature|scaffold|review|fix"}]}. Each builder/fix/scaffold needs acceptance, nonGoals, and a safe bounded baseline gate; review tasks inherit one builder contract. When uncertain, choose and record a safe reversible default; ask only when a choice materially expands scope or authority. The room validates and persists the graph, then shows approval. Do NOT use ExitPlanMode or write a plan file.',
3466
3544
  { plan: z.string().describe('the task-graph as a JSON string (the {summary, tasks:[…]} object)') },
3467
3545
  async (args) => {
3468
3546
  const okText = (t) => ({ content: [{ type: 'text', text: t }] })
3469
3547
  if (!entry.flowSessionId || entry.flowRole !== 'conductor') return okText('Not a Flow conductor — there is no flow to submit a plan for.')
3470
3548
  let norm
3471
3549
  try { norm = validatePlanForRuntime(normalizePlanOutput(args?.plan || ''), entry.runtime) }
3472
- catch (e) { return okText(`Plan REJECTED: ${e?.message || e}. Re-call submit_flow_plan with valid JSON — a non-empty "tasks" array, every "deps" entry referencing an existing task "key", and no cycles.`) }
3550
+ catch (e) { return okText(`Plan REJECTED: ${e?.message || e}. Re-call submit_flow_plan with valid JSON: a non-empty acyclic tasks array; each builder/fix/scaffold has observable acceptance, bounded nonGoals, and a safe baseline gate that says what fails/is absent; each review targets exactly one builder contract.`) }
3473
3551
  bcast('flow-plan', { term: id, flowId: entry.flowSessionId, plan: JSON.stringify(norm) }, flowChannel)
3474
3552
  process.stderr.write(`\n ${A.mag}◆ flow plan submitted — flow ${String(entry.flowSessionId).slice(0, 8)} (${norm.tasks.length} slice${norm.tasks.length === 1 ? '' : 's'}).${A.rst}\n`)
3475
- return okText(`Plan submitted (${norm.tasks.length} slice${norm.tasks.length === 1 ? '' : 's'}) — the room is showing the human an approval card. You are DONE; stop here and wait for approval (lane dispatch is the room's job).`)
3553
+ return okText(`Plan submitted (${norm.tasks.length} slice${norm.tasks.length === 1 ? '' : 's'}) with gate-first contracts. Safe reversible assumptions belong in the summary; user steering remains authoritative. The room is showing approval. You are DONE; stop here and wait for approval (lane dispatch is the room's job).`)
3476
3554
  },
3477
3555
  )] : []),
3478
3556
  ...(entry.flowRole === 'reviewer' ? [tool(
@@ -3523,7 +3601,13 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3523
3601
  // human turn cannot strand a fresh Codex thread without its recap. It is fired
3524
3602
  // on the init `system` event (see onEvent) — a sendTurn before the input stream is consumed
3525
3603
  // is silently lost (the 2026-07-02 auto-resume bug). Empty string → nothing to carry.
3526
- entry.pendingRecap = (typeof carryRecap === 'string' && carryRecap.trim()) ? carryRecap : null
3604
+ const carry = resolveCheckpointCarry(carryRecap, carryCheckpoint)
3605
+ entry.pendingRecap = carry.pendingRecap
3606
+ // Native compaction keeps the runtime thread alive, so this checkpoint must not
3607
+ // create a synthetic turn. Persist it separately and prepend it exactly once to
3608
+ // the next real person turn. That makes the post-compact continuation explicit
3609
+ // while preserving the runtime's own compacted context.
3610
+ entry.pendingCheckpoint = carry.pendingCheckpoint
3527
3611
  const terminalRolePrompt = [buildTerminalRolePrompt({
3528
3612
  spawnedBy: entry.spawnedBy,
3529
3613
  spawnDepth: entry.spawnDepth,
@@ -3617,14 +3701,21 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3617
3701
  if (!entry.flowSessionId || entry.flowRole !== 'conductor') return { ok: false, message: 'Not a Flow conductor — nothing to submit.' }
3618
3702
  let norm
3619
3703
  try { norm = validatePlanForRuntime(normalizePlanOutput(planText || ''), entry.runtime) }
3620
- catch (e) { return { ok: false, message: `Plan REJECTED: ${e?.message || e}. Re-Write FLOW_PLAN.json with valid JSON — a non-empty "tasks" array, every "deps" entry referencing an existing task "key", and no cycles.` } }
3704
+ catch (e) { return { ok: false, message: `Plan REJECTED: ${e?.message || e}. Re-Write FLOW_PLAN.json with valid JSON: a non-empty acyclic tasks array; each builder/fix/scaffold has observable acceptance, bounded nonGoals, and a baseline gate that says what fails/is absent; each review targets exactly one builder contract.` } }
3621
3705
  bcast('flow-plan', { term: id, flowId: entry.flowSessionId, plan: JSON.stringify(norm) }, flowChannel)
3622
3706
  process.stderr.write(`\n ${A.mag}◆ flow plan submitted — flow ${String(entry.flowSessionId).slice(0, 8)} (${norm.tasks.length} slice${norm.tasks.length === 1 ? '' : 's'}).${A.rst}\n`)
3623
- return { ok: true, message: `Plan SUBMITTED (${norm.tasks.length} slice${norm.tasks.length === 1 ? '' : 's'}) — the room is showing the human an approval card. You are DONE: stop here, do not write anything else, wait for approval (lane dispatch is the room's job).` }
3707
+ return { ok: true, message: `Plan SUBMITTED (${norm.tasks.length} slice${norm.tasks.length === 1 ? '' : 's'}) with gate-first contracts. Record safe reversible assumptions in the summary; user steering remains authoritative. The room is showing approval. You are DONE: stop here and wait for approval.` }
3624
3708
  },
3625
3709
  // FL-B1 (lane side) — a flow lane signals slice-done by Writing FLOW_DONE; the hook
3626
3710
  // routes that Write here (mark_flow_done MCP tool is deferred → ToolSearch → hangs).
3627
- onLaneDone: entry.flowRole === 'builder' ? (async () => ({ ok: true, message: await markFlowDone() })) : null,
3711
+ onLaneDone: entry.flowRole === 'builder' ? (async (raw) => {
3712
+ let baselineEvidence = ''
3713
+ try {
3714
+ const value = JSON.parse(String(raw || '').trim())
3715
+ baselineEvidence = typeof value?.baselineEvidence === 'string' ? value.baselineEvidence : ''
3716
+ } catch { /* legacy FLOW_DONE content remains compatible with legacy tasks */ }
3717
+ return { ok: true, message: await markFlowDone({ baselineEvidence }) }
3718
+ }) : null,
3628
3719
  // FL-B4 — a review lane records its verdict via FLOW_REVIEW.json (revert-on-fail).
3629
3720
  onReviewVerdict: entry.flowRole === 'reviewer' ? onReviewVerdict : null,
3630
3721
  mcpServers: { thinkpool: peekServer },
@@ -3718,12 +3809,12 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3718
3809
  id, runtime: entry.runtime, model: entry.model || model, effort: entry.effort,
3719
3810
  provider: entry.provider, log: entry.log, commands: entry.commands, mode: entry.mode,
3720
3811
  spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth, cascadeRole: entry.cascadeRole, hop: entry.hop, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, pendingWorkerCompletions: entry.pendingWorkerCompletions, workerCompletionsInFlight: entry.workerCompletionsInFlight, sliceType: entry.sliceType, flowSessionId: entry.flowSessionId,
3721
- flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole, flowReviewTarget: entry.flowReviewTarget,
3722
- flowReviewTargets: entry.flowReviewTargets, flowReviewSnapshots: entry.flowReviewSnapshots, flowReviewRound: entry.flowReviewRound,
3812
+ flowTaskKey: entry.flowTaskKey, flowTaskContract: entry.flowTaskContract, flowRole: entry.flowRole, flowReviewTarget: entry.flowReviewTarget,
3813
+ flowReviewTargets: entry.flowReviewTargets, flowReviewSnapshots: entry.flowReviewSnapshots, flowReviewRound: entry.flowReviewRound, flowReviewConclusion: entry.flowReviewConclusion,
3723
3814
  dispatchBaseSha: entry.dispatchBaseSha, revertTarget: entry.revertTarget, cwd: entry.cwd,
3724
3815
  managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt,
3725
3816
  reviewSliceRoots: entry.reviewSliceRoots, openedAt: entry.openedAt,
3726
- lastUsage: entry.lastUsage, carryRecap,
3817
+ lastUsage: entry.lastUsage, carryRecap, carryCheckpoint: entry.pendingCheckpoint,
3727
3818
  })
3728
3819
  return sessions.get(id) || null
3729
3820
  },
@@ -3874,6 +3965,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3874
3965
  // mid-turn) flash a brief "Compacting…" beat so it isn't silent — we only learn of it
3875
3966
  // post-hoc, so there's no real duration to animate (Max, 2026-07-02).
3876
3967
  if (evt.kind === 'compaction') {
3968
+ armCompactionCheckpoint(entry)
3877
3969
  if (entry.compacting) {
3878
3970
  // MANUAL /compact: attribute the recap card, then CLEAR the "Compacting…"
3879
3971
  // indicator right here — the compact_boundary milestone IS the "done" signal.
@@ -4245,7 +4337,7 @@ function respawnStructured(id, provider) {
4245
4337
  // openStructured seed from the TARGET provider's configured model, which is the
4246
4338
  // only model this lane was ever asked for. A same-env model change never reaches
4247
4339
  // here — that path is an in-place setModel (see provider-switch).
4248
- const { runtime, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage } = s
4340
+ const { runtime, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, sliceType, flowSessionId, flowTaskKey, flowTaskContract, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, flowReviewConclusion, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage, pendingCheckpoint } = s
4249
4341
  // Context-carry (2026-07-08): a provider switch is not an SDK resume — the new backend
4250
4342
  // starts blank mid-conversation. Synthesize a plain-text recap from the VISIBLE log NOW
4251
4343
  // (before teardown) and hand it to the fresh session as its first turn so the agent
@@ -4264,7 +4356,7 @@ function respawnStructured(id, provider) {
4264
4356
  // sessionData() (provider included) synchronously on open, so a bridge restart
4265
4357
  // restores the lane on its CURRENT provider, not the original — and its next
4266
4358
  // announce carries the new provider badge (additive {id,name} projection).
4267
- openStructured({ id, runtime, provider, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage, carryRecap })
4359
+ openStructured({ id, runtime, provider, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, sliceType, flowSessionId, flowTaskKey, flowTaskContract, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, flowReviewConclusion, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage, carryRecap, carryCheckpoint: pendingCheckpoint })
4268
4360
  return true
4269
4361
  }
4270
4362
 
@@ -4969,6 +5061,7 @@ channel
4969
5061
  }
4970
5062
  if (/^\/clear\s*$/.test(text)) {
4971
5063
  s.pendingRecap = null // /clear means forget context — drop any un-fired carry recap
5064
+ s.pendingCheckpoint = null
4972
5065
  s.pendingCodexCompaction = null // explicit clear supersedes a deferred compact
4973
5066
  if (s.runtime === 'codex' || s.runtime === 'hermes') s.session.clearContext?.()
4974
5067
  else s.session.sendTurn(text)
@@ -5041,6 +5134,7 @@ channel
5041
5134
  let sendText = text
5042
5135
  const carried = []
5043
5136
  if (s.pendingRecap) { carried.push(s.pendingRecap); s.pendingRecap = null }
5137
+ if (s.pendingCheckpoint) { carried.push(s.pendingCheckpoint); s.pendingCheckpoint = null }
5044
5138
  if (s.pendingSideContexts?.length) { carried.push(...s.pendingSideContexts); s.pendingSideContexts = [] }
5045
5139
  if (carried.length) { sendText = appendCurrentPersonRequest(carried, text); s.flush?.() }
5046
5140
  // The browser's `hostPath` is display metadata, not host authority. Rebuild
@@ -5236,7 +5330,7 @@ channel
5236
5330
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
5237
5331
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
5238
5332
  // lanes keep their worktree cwd + the ability to mark done.
5239
- openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, models: rec.models, effort: rec.effort, provider: rec.runtime === 'claude' ? rec.provider || undefined : undefined, resume: resumable ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, spawnDepth: rec.spawnDepth, cascadeRole: rec.cascadeRole, hop: rec.hop, sideParent: rec.sideParent, sideTask: rec.sideTask, scheduleRunId: rec.scheduleRunId, scheduleDeadlineAt: rec.scheduleDeadlineAt, scheduleOutcomeRecorded: rec.scheduleOutcomeRecorded, scheduleAdmissionLease: rec.scheduleAdmissionLease, pendingSideContexts: rec.pendingSideContexts, pendingWorkerCompletions: rec.pendingWorkerCompletions, workerCompletionsInFlight: rec.workerCompletionsInFlight, sliceType: rec.sliceType, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, flowReviewSnapshots: rec.flowReviewSnapshots, flowReviewRound: rec.flowReviewRound, dispatchBaseSha: rec.dispatchBaseSha, revertTarget: rec.revertTarget, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, receivedTurnCids: rec.receivedTurnCids, carryRecap: wasInterrupted ? recoveryRecap : rec.carryRecap,
5333
+ openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, models: rec.models, effort: rec.effort, provider: rec.runtime === 'claude' ? rec.provider || undefined : undefined, resume: resumable ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, spawnDepth: rec.spawnDepth, cascadeRole: rec.cascadeRole, hop: rec.hop, sideParent: rec.sideParent, sideTask: rec.sideTask, scheduleRunId: rec.scheduleRunId, scheduleDeadlineAt: rec.scheduleDeadlineAt, scheduleOutcomeRecorded: rec.scheduleOutcomeRecorded, scheduleAdmissionLease: rec.scheduleAdmissionLease, pendingSideContexts: rec.pendingSideContexts, pendingWorkerCompletions: rec.pendingWorkerCompletions, workerCompletionsInFlight: rec.workerCompletionsInFlight, sliceType: rec.sliceType, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowTaskContract: rec.flowTaskContract, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, flowReviewSnapshots: rec.flowReviewSnapshots, flowReviewRound: rec.flowReviewRound, flowReviewConclusion: rec.flowReviewConclusion, dispatchBaseSha: rec.dispatchBaseSha, revertTarget: rec.revertTarget, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, receivedTurnCids: rec.receivedTurnCids, carryRecap: wasInterrupted ? recoveryRecap : rec.carryRecap, carryCheckpoint: rec.carryCheckpoint,
5240
5334
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
5241
5335
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
5242
5336
  // (mid-turn needs auto-resume; flow needs its lane live).
@@ -5459,6 +5553,13 @@ flowChannel
5459
5553
  // Step 4 — a `review` slice gets the adversarial reviewer prompt (try-to-break),
5460
5554
  // every other slice gets the builder prompt.
5461
5555
  const isReview = t.slice_type === 'review'
5556
+ try {
5557
+ validateTaskContractSeal(t, { legacy: t.contract == null })
5558
+ } catch (error) {
5559
+ process.stderr.write(`\n ${A.yel}◆ flow dispatch held — ${t.task_key || 'task'} failed its sealed acceptance contract: ${error?.message || error}${A.rst}\n`)
5560
+ bcast('flow-dispatch-held', { term: 'flow', flowId: payload.flowId, taskKey: t.task_key || null, reason: 'sealed acceptance contract mismatch' }, flowChannel)
5561
+ continue
5562
+ }
5462
5563
  if (!validReviewTargetShape(t, flowRuntime)) {
5463
5564
  process.stderr.write(`\n ${A.yel}◆ flow dispatch held — ${flowRuntime === 'hermes' ? 'Hermes' : 'Codex'} review ${t.task_key} must target exactly one dependency.${A.rst}\n`)
5464
5565
  continue
@@ -5508,10 +5609,11 @@ flowChannel
5508
5609
  ? (isReview ? FLOW_CODEX_REVIEWER_PROMPT : FLOW_CODEX_LANE_PROMPT)
5509
5610
  : (isReview ? FLOW_REVIEWER_PROMPT : FLOW_LANE_PROMPT)
5510
5611
  const laneRolePrompt = buildLanePrompt({ base: laneBase })
5612
+ const sealedReviewAcceptance = t.contract?.acceptancePack?.task?.acceptance
5511
5613
  // Model tiers (2026-07-03-flow-lane-model-tiers): pick the lane's brain by slice_type
5512
5614
  // (scaffold→sonnet, feature/fix/review→opus; env can blanket-override or `inherit` to
5513
5615
  // restore today's exact behavior). undefined → no model key passed (openStructured default).
5514
- openStructured({ id: laneId, runtime: flowRuntime, cwd: dir, model: flowLaneModelFor({ sliceType: t.slice_type, runtime: flowRuntime, catalog: flowCatalog }), mode: structuredModeForSlice(flowRuntime, { mode: 'bypassPermissions', flowRole: isReview ? 'reviewer' : 'builder' }), rolePrompt: laneRolePrompt, flowSessionId: payload.flowId, flowTaskKey: t.task_key, flowRole: isReview ? 'reviewer' : 'builder', flowReviewTargets: isReview ? (t.deps || []) : [], flowReviewSnapshots, dispatchBaseSha, revertTarget: redispatch?.revertTarget || null, spawnedBy: `flow:${payload.flowId}`, resume: redispatch?.resumeSessionId || undefined, reviewSliceRoots })
5616
+ openStructured({ id: laneId, runtime: flowRuntime, cwd: dir, model: flowLaneModelFor({ sliceType: t.slice_type, runtime: flowRuntime, catalog: flowCatalog }), mode: structuredModeForSlice(flowRuntime, { mode: 'bypassPermissions', flowRole: isReview ? 'reviewer' : 'builder' }), rolePrompt: laneRolePrompt, flowSessionId: payload.flowId, flowTaskKey: t.task_key, flowTaskContract: t.contract || null, flowRole: isReview ? 'reviewer' : 'builder', flowReviewTargets: isReview ? (t.deps || []) : [], flowReviewSnapshots, dispatchBaseSha, revertTarget: redispatch?.revertTarget || null, spawnedBy: `flow:${payload.flowId}`, resume: redispatch?.resumeSessionId || undefined, reviewSliceRoots })
5515
5617
  const le = sessions.get(laneId)
5516
5618
  if (le) {
5517
5619
  // S4 — stamp the surviving revert target on the resumed lane so a later reviewer still
@@ -5534,7 +5636,10 @@ flowChannel
5534
5636
  ? `Reviewed slice worktree(s) — check out + RUN each yourself:\n` +
5535
5637
  t.deps.map((dep) => { const ws = worktreeSpec({ flowId: payload.flowId, taskKey: dep }); return ` - ${dep}: dir ${ws.dir} (branch ${ws.branch})` }).join('\n') + '\n'
5536
5638
  : '') +
5537
- `ACCEPTANCE to INDEPENDENTLY verify: ${t.acceptance || t.title}\n` +
5639
+ `ACCEPTANCE to INDEPENDENTLY verify: ${sealedReviewAcceptance || t.acceptance || t.title}\n` +
5640
+ `INHERITED NON-GOALS (must remain untouched): ${(t.contract?.nonGoals || []).join(' | ') || '(legacy task: none recorded)'}\n` +
5641
+ `INHERITED BASELINE GATE: ${t.contract?.baseline?.gate || '(legacy task: none recorded)'}\n` +
5642
+ `BUILDER BASELINE EVIDENCE (verify; never invent another baseline): ${t.contract?.baseline?.evidence || '(missing — reject unless this is a legacy task)'}\n` +
5538
5643
  `\nProject (context): ${payload.flowPrompt || ''}\n\n` +
5539
5644
  (flowRuntime === 'codex' || flowRuntime === 'hermes'
5540
5645
  ? `Review it without mutating the builder worktree. For write-producing install/build/test commands, copy its source into scratch under your own current worktree first. Submit the structured verdict with the ThinkPool submit_flow_review MCP tool; never write FLOW_REVIEW.json or call mark_flow_done.`
@@ -5543,6 +5648,9 @@ flowChannel
5543
5648
  `TITLE: ${t.title}\n` +
5544
5649
  `SCOPE (files you OWN — edit ONLY these): ${t.scope || '(none stated)'}\n` +
5545
5650
  `ACCEPTANCE (done = this runs + proves it): ${t.acceptance || '(meet the title)'}\n` +
5651
+ `NON-GOALS (must remain untouched): ${(t.contract?.nonGoals || []).join(' | ') || '(legacy task: none recorded)'}\n` +
5652
+ `BASELINE GATE (before edits, observe this real failure/absence): ${t.contract?.baseline?.gate || '(legacy task: none recorded)'}\n` +
5653
+ `BASELINE EVIDENCE: run/observe the pre-edit gate, then retain one real bounded command/behavior receipt for mark_flow_done; never fabricate a RED sentence.\n` +
5546
5654
  (t.deps && t.deps.length ? `DEPENDS ON (already built): ${t.deps.join(', ')}\n` : '') +
5547
5655
  // S1 (context-offload) — inject the BOUNDED cross-wave context (loadDigest
5548
5656
  // enforces CEILING) instead of accumulating full lane transcripts. Filter to
@@ -594,7 +594,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
594
594
  // that Write to the done logic and feed the result back; the file is never written.
595
595
  if (onLaneDone && (toolName === 'Write' || toolName === 'Edit') && /(?:^|[/\\])FLOW_DONE(\.[a-zA-Z0-9]+)?$/.test(toolInput?.file_path || '')) {
596
596
  let res = { ok: false, message: 'done signal failed' }
597
- try { res = (await onLaneDone()) || res } catch (e) { res = { ok: false, message: `done signal failed: ${e?.message || e}` } }
597
+ try { res = (await onLaneDone(toolInput?.content ?? toolInput?.new_string ?? '')) || res } catch (e) { res = { ok: false, message: `done signal failed: ${e?.message || e}` } }
598
598
  return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: res.message } }
599
599
  }
600
600
  // S5 (slice 1b) — REVIEW-LANE WRITE-BLOCK. A review lane is ADVERSARIAL: it reads +
@@ -64,6 +64,7 @@ export const CODE_EVENT_REGISTRY = Object.freeze({
64
64
  'needs-resolved': E,
65
65
  continuation: E,
66
66
  'turn-done': E,
67
+ 'flow-review-receipt': E,
67
68
  })
68
69
 
69
70
  export const CODE_EVENT_KINDS = Object.freeze(Object.keys(CODE_EVENT_REGISTRY))
package/event-id.mjs CHANGED
@@ -546,4 +546,4 @@ export function codexUsageReportLine (sessionModel, snapshot) {
546
546
  * (`from './event-id.mjs'`) working untouched, including bridge/recap.test.mjs.
547
547
  *
548
548
  * recap.mjs must stay in bridge/package.json `files` — event-id.mjs imports it. */
549
- export { CURRENT_PERSON_REQUEST_MARKER, RECAP_CAP, appendCurrentPersonRequest, buildRecapFromLog } from './recap.mjs'
549
+ export { CHECKPOINT_CAP, CURRENT_PERSON_REQUEST_MARKER, RECAP_CAP, appendCurrentPersonRequest, buildCheckpointFromLog, buildRecapFromLog, recapHasCheckpoint, resolveCheckpointCarry } from './recap.mjs'