thinkpool-pair 0.7.361 → 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, normalizeBaselineEvidence, 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'
@@ -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',
@@ -2533,7 +2536,7 @@ function worktreeSnapshot(cwd) {
2533
2536
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
2534
2537
  // persist to the host file; tool calls round-trip through the perm card; the
2535
2538
  // rolling log replays to joiners and survives bridge restarts (session-store).
2536
- 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, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, carryCheckpoint, 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 }) {
2537
2540
  if (sessions.has(id)) return
2538
2541
  runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
2539
2542
  // Fail closed before exposing a native lane if its bridge semantic contract
@@ -2606,6 +2609,9 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2606
2609
  flowReviewTargets: Array.isArray(flowReviewTargets) && flowReviewTargets.length ? flowReviewTargets.filter(Boolean) : (flowReviewTarget ? [flowReviewTarget] : []),
2607
2610
  flowReviewSnapshots: Array.isArray(flowReviewSnapshots) ? flowReviewSnapshots.filter((item) => item?.taskKey && item?.sha && item?.cwd) : [],
2608
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,
2609
2615
  dispatchBaseSha: dispatchBaseSha || null,
2610
2616
  revertTarget: revertTarget || null,
2611
2617
  // Stable creation order — persisted so a bridge restart restores tabs in the SAME
@@ -2719,7 +2725,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2719
2725
  // review gate was dead code: reviewers had no working emit path, so a failing slice was
2720
2726
  // never reverted). On FAIL → broadcast flow-revert for the reviewed slice (the client flips
2721
2727
  // it to pending → the next wave rebuilds it). Either way the review lane itself is done.
2722
- const onReviewVerdict = async (raw) => {
2728
+ const processReviewVerdict = async (raw) => {
2723
2729
  if (!entry.flowSessionId || !entry.flowTaskKey || entry.flowRole !== 'reviewer') return { ok: false, message: 'Not a Flow review lane.' }
2724
2730
  let v, target = null
2725
2731
  try {
@@ -2737,6 +2743,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2737
2743
  ? `Review verdict REJECTED: ${e?.message || e}. Re-call submit_flow_review with a valid authorized taskKey.`
2738
2744
  : `Review verdict REJECTED: ${e?.message || e}. Re-Write FLOW_REVIEW.json with {"pass":<boolean>,"reasons":["<specific finding>"],"taskKey":"<the slice you reviewed>"}.` }
2739
2745
  }
2746
+ if (entry.flowReviewConclusion) return { ok: true, message: `Review already concluded for ${entry.flowReviewConclusion.taskKey}; receipt ${entry.flowReviewConclusion.receipt.cid || 'recorded'}.` }
2740
2747
  // E1 A1/A2 — the BOUNDED reviewer loop, live side. Each FLOW_REVIEW.json write is ONE
2741
2748
  // hunt round; the governor decides continue-vs-stop from the round count + the lane's
2742
2749
  // REAL budget (flowBudgets ledger). A bare pass:true (happy path held, not yet exhausted)
@@ -2778,15 +2785,51 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2778
2785
  } else {
2779
2786
  process.stderr.write(`\n ${A.cyan}◆ review ${decision.action.toUpperCase()} (round ${round}) — ${target || entry.flowTaskKey}${A.rst}\n`)
2780
2787
  }
2781
- // Durable room-visible verdict. `persistAgentEvent` below is intentionally
2782
- // filtered from the transcript (push/unread transport only), while this control
2783
- // row is archived with the reviewer lane and broadcast to both room members.
2784
- // In particular, a held review must show its exact findings instead of leaving
2785
- // the red task state unexplained after the ephemeral Flow broadcast is gone.
2786
- const visibleReview = {
2787
- kind: 'control',
2788
- 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,
2789
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?.()
2790
2833
  pushLog(entry, visibleReview)
2791
2834
  bcast('code-event', { term: id, evt: visibleReview })
2792
2835
  entry.flush?.()
@@ -2796,45 +2839,53 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2796
2839
  // broadcast, does NOT change the revert/gate flow.
2797
2840
  bcast('flow-review-verdict', {
2798
2841
  term: id, flowId: entry.flowSessionId,
2799
- taskKey: target || entry.flowTaskKey,
2800
- pass: v.pass,
2801
- reasons: v.reasons,
2802
- action: decision.action,
2842
+ taskKey: reviewedTaskKey,
2843
+ pass: terminalAction === 'pass',
2844
+ reasons: receiptReasons,
2845
+ action: terminalAction,
2803
2846
  rounds: round,
2804
- surfaceToPair: decision.surfaceToPair,
2847
+ surfaceToPair: terminalAction === 'surface' || decision.surfaceToPair,
2805
2848
  prompt: pairAdjudicationPrompt({
2806
- action: decision.action,
2807
- reason: decision.reason,
2808
- taskKey: target || entry.flowTaskKey,
2809
- findings: v.reasons,
2849
+ action: terminalAction,
2850
+ reason: terminalAction === decision.action ? decision.reason : receiptReasons.at(-1),
2851
+ taskKey: reviewedTaskKey,
2852
+ findings: receiptReasons,
2810
2853
  }),
2811
2854
  }, flowChannel)
2812
2855
  let doneMsg = ''
2813
- if (decision.action === 'pass') {
2856
+ if (terminalAction === 'pass') {
2814
2857
  doneMsg = await markFlowDone({ reviewPass: true })
2815
2858
  } else {
2816
- if (decision.action === 'surface') {
2817
- bcast('flow-review-held', { term: id, flowId: entry.flowSessionId, reviewTaskKey: entry.flowTaskKey, taskKey: target, reasons: v.reasons }, flowChannel)
2818
- 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}` })
2819
2862
  }
2820
2863
  // A reject must rebuild the target and then run a fresh reviewer. Retire that
2821
2864
  // reviewer now. A surfaced inconclusive review is different: keep its lane
2822
2865
  // alive with the durable control row above so either room member can open the
2823
2866
  // held review, read the exact findings, and continue/adjudicate after reload.
2824
2867
  // Neither outcome emits task-done or permits assembly.
2825
- if (decision.action === 'reject') {
2868
+ if (terminalAction === 'reject') {
2826
2869
  entry.flowDone = true
2827
2870
  entry.flush?.()
2828
2871
  setTimeout(() => { try { endStructured(id) } catch { /* already gone */ } }, 2500)
2829
2872
  }
2830
2873
  }
2831
- const label = decision.action === 'reject'
2832
- ? `REJECT (round ${round}) — reverting ${target || '(no target)'} (${v.reasons.join('; ').slice(0, 140)})`
2833
- : decision.action === 'surface'
2834
- ? `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}`
2835
2878
  : `PASS (round ${round})`
2836
2879
  return { ok: true, message: `Review verdict recorded: ${label}.${doneMsg ? ` ${doneMsg}` : ''}` }
2837
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
+ }
2838
2889
  // Identity for the durable archive — pushLog appends every new transcript event to
2839
2890
  // <room>/<id>.events.jsonl keyed off these. Seed the archive once from the restored
2840
2891
  // (≤2000) log so the retained window is immediately pageable; no-op if it already
@@ -2891,13 +2942,19 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2891
2942
  // restart. Without this, sessionData omitted it → on restart the resumed session
2892
2943
  // re-launched on the host default (Opus) regardless of the last switch, and the
2893
2944
  // switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
2894
- 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, 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) })
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) })
2895
2946
  const persist = () => saveSession(room, id, sessionData())
2896
2947
  // Synchronous flush of this session's record. Used on open (so a brand-new session
2897
2948
  // has a file under its id BEFORE its first event — surviving a restart inside the
2898
2949
  // 1.5s saveSession debounce window) and on shutdown (so events since the last
2899
2950
  // debounced write aren't lost). Contract #2: restart resumes, no lost messages.
2900
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
+ }
2901
2958
  if (entry.scheduleOutcomeRecorded && entry.scheduleAdmissionLease) {
2902
2959
  void releaseScheduledAdmissionForEntry(entry).then((released) => {
2903
2960
  if (released) entry.flush?.()
@@ -3753,7 +3810,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3753
3810
  provider: entry.provider, log: entry.log, commands: entry.commands, mode: entry.mode,
3754
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,
3755
3812
  flowTaskKey: entry.flowTaskKey, flowTaskContract: entry.flowTaskContract, flowRole: entry.flowRole, flowReviewTarget: entry.flowReviewTarget,
3756
- flowReviewTargets: entry.flowReviewTargets, flowReviewSnapshots: entry.flowReviewSnapshots, flowReviewRound: entry.flowReviewRound,
3813
+ flowReviewTargets: entry.flowReviewTargets, flowReviewSnapshots: entry.flowReviewSnapshots, flowReviewRound: entry.flowReviewRound, flowReviewConclusion: entry.flowReviewConclusion,
3757
3814
  dispatchBaseSha: entry.dispatchBaseSha, revertTarget: entry.revertTarget, cwd: entry.cwd,
3758
3815
  managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt,
3759
3816
  reviewSliceRoots: entry.reviewSliceRoots, openedAt: entry.openedAt,
@@ -4280,7 +4337,7 @@ function respawnStructured(id, provider) {
4280
4337
  // openStructured seed from the TARGET provider's configured model, which is the
4281
4338
  // only model this lane was ever asked for. A same-env model change never reaches
4282
4339
  // here — that path is an in-place setModel (see provider-switch).
4283
- const { runtime, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, sliceType, flowSessionId, flowTaskKey, flowTaskContract, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage, pendingCheckpoint } = 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
4284
4341
  // Context-carry (2026-07-08): a provider switch is not an SDK resume — the new backend
4285
4342
  // starts blank mid-conversation. Synthesize a plain-text recap from the VISIBLE log NOW
4286
4343
  // (before teardown) and hand it to the fresh session as its first turn so the agent
@@ -4299,7 +4356,7 @@ function respawnStructured(id, provider) {
4299
4356
  // sessionData() (provider included) synchronously on open, so a bridge restart
4300
4357
  // restores the lane on its CURRENT provider, not the original — and its next
4301
4358
  // announce carries the new provider badge (additive {id,name} projection).
4302
- 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, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage, carryRecap, carryCheckpoint: pendingCheckpoint })
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 })
4303
4360
  return true
4304
4361
  }
4305
4362
 
@@ -5273,7 +5330,7 @@ channel
5273
5330
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
5274
5331
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
5275
5332
  // lanes keep their worktree cwd + the ability to mark done.
5276
- 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, 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,
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,
5277
5334
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
5278
5335
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
5279
5336
  // (mid-turn needs auto-resume; flow needs its lane live).
@@ -5496,6 +5553,13 @@ flowChannel
5496
5553
  // Step 4 — a `review` slice gets the adversarial reviewer prompt (try-to-break),
5497
5554
  // every other slice gets the builder prompt.
5498
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
+ }
5499
5563
  if (!validReviewTargetShape(t, flowRuntime)) {
5500
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`)
5501
5565
  continue
@@ -5545,6 +5609,7 @@ flowChannel
5545
5609
  ? (isReview ? FLOW_CODEX_REVIEWER_PROMPT : FLOW_CODEX_LANE_PROMPT)
5546
5610
  : (isReview ? FLOW_REVIEWER_PROMPT : FLOW_LANE_PROMPT)
5547
5611
  const laneRolePrompt = buildLanePrompt({ base: laneBase })
5612
+ const sealedReviewAcceptance = t.contract?.acceptancePack?.task?.acceptance
5548
5613
  // Model tiers (2026-07-03-flow-lane-model-tiers): pick the lane's brain by slice_type
5549
5614
  // (scaffold→sonnet, feature/fix/review→opus; env can blanket-override or `inherit` to
5550
5615
  // restore today's exact behavior). undefined → no model key passed (openStructured default).
@@ -5571,7 +5636,7 @@ flowChannel
5571
5636
  ? `Reviewed slice worktree(s) — check out + RUN each yourself:\n` +
5572
5637
  t.deps.map((dep) => { const ws = worktreeSpec({ flowId: payload.flowId, taskKey: dep }); return ` - ${dep}: dir ${ws.dir} (branch ${ws.branch})` }).join('\n') + '\n'
5573
5638
  : '') +
5574
- `ACCEPTANCE to INDEPENDENTLY verify: ${t.acceptance || t.title}\n` +
5639
+ `ACCEPTANCE to INDEPENDENTLY verify: ${sealedReviewAcceptance || t.acceptance || t.title}\n` +
5575
5640
  `INHERITED NON-GOALS (must remain untouched): ${(t.contract?.nonGoals || []).join(' | ') || '(legacy task: none recorded)'}\n` +
5576
5641
  `INHERITED BASELINE GATE: ${t.contract?.baseline?.gate || '(legacy task: none recorded)'}\n` +
5577
5642
  `BUILDER BASELINE EVIDENCE (verify; never invent another baseline): ${t.contract?.baseline?.evidence || '(missing — reject unless this is a legacy task)'}\n` +
@@ -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))
@@ -0,0 +1,115 @@
1
+ // Flow review receipts are deliberately smaller than a review verdict. They cross
2
+ // from a private reviewer lane into a shared transcript, so this module only
3
+ // admits the evidence a pair can act on. Prompts, environment, paths, logs, and
4
+ // hidden reasoning never have a field in the public shape.
5
+
6
+ export const FLOW_REVIEW_RECEIPT_KIND = 'flow-review-receipt'
7
+
8
+ const MAX_TASK_KEY = 120
9
+ const MAX_LINE = 180
10
+ const MAX_LINES = 4
11
+ const OUTCOMES = new Set(['pass', 'reject', 'held'])
12
+ const DIGEST = /^[a-f0-9]{64}$/
13
+ const SECRET_VALUE = /(?:sk[_-](?:proj[_-])?[a-z0-9_-]{8,}|gsk_[a-z0-9_-]{8,}|AIza[a-z0-9_-]{8,}|gh[pousr]_[a-z0-9_-]{8,}|github_pat_[a-z0-9_]{20,}|glpat-[a-z0-9_-]{8,}|x(?:ox[baprsce]|app)-[a-z0-9_-]{8,}|(?:AKIA|ASIA)[0-9A-Z]{16}|sbp_[a-z0-9_-]{20,}|sb_secret_[a-z0-9_-]{8,}|npm_[a-z0-9]{24,}|(?:sk|rk)_(?:live|test)_[a-z0-9]{8,}|whsec_[a-z0-9]{8,}|eyJ[a-z0-9_-]{8,}\.[a-z0-9_-]{8,}\.[a-z0-9_-]{8,}|bearer\s+[a-z0-9._-]{8,}|hooks\.slack\.com\/services\/[a-z0-9/_-]{8,})/i
14
+ const SECRET_OR_PRIVATE = /(?:api[_ -]?key|access[_ -]?token|\btoken\s*[=:]|auth(?:orization)?|bearer\s+|secret|password|private[_ -]?key|system\s+prompt|raw\s+prompt|hidden\s+reasoning|chain[ -]of[ -]thought|\benv(?:ironment)?\b|\.env\b)/i
15
+ const HOST_PATH = /(?:^~[\\/]|^\.?[\\/]|(?:^|\s)\/[\w.-]+|[A-Za-z]:\\|\\\\|(?:^|[\\/])(?:Users|home|var|tmp|private|workspace)(?:[\\/]|$)|\.thinkpool[\\/])/i
16
+ const PATH_TRAVERSAL = /(?:^|[^a-z0-9.])\.\.(?:[\\/]|$)/i
17
+
18
+ function cleanText(value, max) {
19
+ if (typeof value !== 'string' || value.length === 0 || value.length > max) return null
20
+ if (/\r|\n/.test(value) || SECRET_VALUE.test(value) || SECRET_OR_PRIVATE.test(value) || HOST_PATH.test(value) || PATH_TRAVERSAL.test(value)) return null
21
+ const text = value.replace(/\s+/g, ' ').trim()
22
+ return text && text.length <= max ? text : null
23
+ }
24
+
25
+ function cleanLines(value) {
26
+ if (!Array.isArray(value)) return []
27
+ const out = []
28
+ for (const line of value) {
29
+ const safe = cleanText(line, MAX_LINE)
30
+ if (safe && !out.includes(safe)) out.push(safe)
31
+ if (out.length === MAX_LINES) break
32
+ }
33
+ return out
34
+ }
35
+
36
+ function cleanTaskKey(value) {
37
+ const taskKey = cleanText(value, MAX_TASK_KEY)
38
+ if (!taskKey || !/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(taskKey) || taskKey.includes('..')) return null
39
+ return taskKey
40
+ }
41
+
42
+ function cleanSha(value) {
43
+ if (typeof value !== 'string') return null
44
+ const sha = value.trim().toLowerCase()
45
+ return /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(sha) ? sha : null
46
+ }
47
+
48
+ function cleanRounds(value) {
49
+ return Number.isInteger(value) && value >= 1 && value <= 99 ? value : null
50
+ }
51
+
52
+ function cleanDigest(value) {
53
+ if (typeof value !== 'string') return null
54
+ const digest = value.trim().toLowerCase()
55
+ return DIGEST.test(digest) ? digest : null
56
+ }
57
+
58
+ // Build the only wire shape the renderer accepts. Optional fields are omitted
59
+ // rather than represented as null, making absence honest (notably deployment).
60
+ export function createFlowReviewReceipt(input = {}) {
61
+ const taskKey = cleanTaskKey(input.taskKey)
62
+ const outcome = typeof input.outcome === 'string' ? input.outcome.toLowerCase() : ''
63
+ const rounds = cleanRounds(input.rounds)
64
+ if (!taskKey || !OUTCOMES.has(outcome) || rounds == null) {
65
+ throw new TypeError('Invalid flow review receipt identity')
66
+ }
67
+
68
+ const receipt = {
69
+ kind: FLOW_REVIEW_RECEIPT_KIND,
70
+ taskKey,
71
+ outcome,
72
+ rounds,
73
+ checks: cleanLines(input.checks),
74
+ reasons: cleanLines(input.reasons),
75
+ }
76
+ const candidateSha = cleanSha(input.candidateSha)
77
+ const acceptanceDigest = cleanDigest(input.acceptanceDigest)
78
+ // Presence, not truthiness, is intentional: deployments are never inferred
79
+ // from a pass/reject outcome or from a candidate SHA.
80
+ const deployment = Object.hasOwn(input, 'deployment') ? cleanText(input.deployment, MAX_LINE) : null
81
+ if (candidateSha) receipt.candidateSha = candidateSha
82
+ if (acceptanceDigest) receipt.acceptanceDigest = acceptanceDigest
83
+ receipt.verified = Boolean(candidateSha && acceptanceDigest)
84
+ if (outcome === 'pass' && !receipt.verified) {
85
+ throw new TypeError('Passing flow review receipt requires an exact candidate and acceptance digest')
86
+ }
87
+ if (deployment) receipt.deployment = deployment
88
+ return receipt
89
+ }
90
+
91
+ export function isFlowReviewReceipt(value) {
92
+ try {
93
+ const receipt = createFlowReviewReceipt(value)
94
+ return receipt.kind === value?.kind
95
+ } catch {
96
+ return false
97
+ }
98
+ }
99
+
100
+ // A review lane may receive the same terminal submission twice (tool retry,
101
+ // double click, or concurrent provider delivery). Serialize the whole verdict
102
+ // transaction—not just receipt emission—so an awaited revert cannot run twice.
103
+ export function createFlowReviewSingleFlight() {
104
+ let pending = null
105
+ return async function runFlowReviewVerdict(operation) {
106
+ if (pending) return pending
107
+ const current = (async () => operation())()
108
+ pending = current
109
+ try {
110
+ return await current
111
+ } finally {
112
+ if (pending === current) pending = null
113
+ }
114
+ }
115
+ }
@@ -54,6 +54,18 @@ export const FLOW_CONTRACT_LIMITS = Object.freeze({
54
54
  })
55
55
 
56
56
  const BASELINE_NEGATIVE_SIGNAL = /\b(fail(?:s|ed|ing)?|absent|missing|not\s+(?:present|implemented|available|found)|does\s+not|404|empty)\b/i
57
+ const CONTRACT_DIGEST = /^[a-f0-9]{64}$/
58
+ const SHA256_K = [
59
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
60
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
61
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
62
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
63
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
64
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
65
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
66
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
67
+ ]
68
+ const rotateRight = (value, bits) => (value >>> bits) | (value << (32 - bits))
57
69
  const hasDisallowedControl = (value) => {
58
70
  for (const char of value) {
59
71
  const code = char.charCodeAt(0)
@@ -82,6 +94,80 @@ export function normalizeBaselineEvidence (value) {
82
94
  return boundedLine(value, 'baseline evidence receipt', FLOW_CONTRACT_LIMITS.maxBaselineEvidenceChars, { required: true })
83
95
  }
84
96
 
97
+ // This is deliberately a small synchronous SHA-256 implementation instead of a
98
+ // Node crypto import or WebCrypto: the task graph must stay byte-identical and run
99
+ // in both the bridge's Node runtime and the browser bundle.
100
+ function sha256Hex (message) {
101
+ const source = new TextEncoder().encode(message)
102
+ const totalLength = Math.ceil((source.length + 9) / 64) * 64
103
+ const bytes = new Uint8Array(totalLength)
104
+ bytes.set(source)
105
+ bytes[source.length] = 0x80
106
+ let bitLength = BigInt(source.length) * 8n
107
+ for (let index = totalLength - 1; index >= totalLength - 8; index--) {
108
+ bytes[index] = Number(bitLength & 0xffn)
109
+ bitLength >>= 8n
110
+ }
111
+
112
+ const hash = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]
113
+ const words = new Uint32Array(64)
114
+ for (let offset = 0; offset < bytes.length; offset += 64) {
115
+ for (let index = 0; index < 16; index++) {
116
+ const start = offset + (index * 4)
117
+ words[index] = ((bytes[start] << 24) | (bytes[start + 1] << 16) | (bytes[start + 2] << 8) | bytes[start + 3]) >>> 0
118
+ }
119
+ for (let index = 16; index < 64; index++) {
120
+ const low = rotateRight(words[index - 15], 7) ^ rotateRight(words[index - 15], 18) ^ (words[index - 15] >>> 3)
121
+ const high = rotateRight(words[index - 2], 17) ^ rotateRight(words[index - 2], 19) ^ (words[index - 2] >>> 10)
122
+ words[index] = (words[index - 16] + low + words[index - 7] + high) >>> 0
123
+ }
124
+
125
+ let [a, b, c, d, e, f, g, h] = hash
126
+ for (let index = 0; index < 64; index++) {
127
+ const sigma1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25)
128
+ const choose = (e & f) ^ (~e & g)
129
+ const temp1 = (h + sigma1 + choose + SHA256_K[index] + words[index]) >>> 0
130
+ const sigma0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22)
131
+ const majority = (a & b) ^ (a & c) ^ (b & c)
132
+ const temp2 = (sigma0 + majority) >>> 0
133
+ h = g
134
+ g = f
135
+ f = e
136
+ e = (d + temp1) >>> 0
137
+ d = c
138
+ c = b
139
+ b = a
140
+ a = (temp1 + temp2) >>> 0
141
+ }
142
+ hash[0] = (hash[0] + a) >>> 0
143
+ hash[1] = (hash[1] + b) >>> 0
144
+ hash[2] = (hash[2] + c) >>> 0
145
+ hash[3] = (hash[3] + d) >>> 0
146
+ hash[4] = (hash[4] + e) >>> 0
147
+ hash[5] = (hash[5] + f) >>> 0
148
+ hash[6] = (hash[6] + g) >>> 0
149
+ hash[7] = (hash[7] + h) >>> 0
150
+ }
151
+ return hash.map((word) => word.toString(16).padStart(8, '0')).join('')
152
+ }
153
+
154
+ // The acceptance pack belongs to the builder target. A review task copies that
155
+ // target's digest, so its inherited target is bound without re-hashing reviewer
156
+ // prose or later baseline evidence receipts.
157
+ function acceptancePack ({ key, title, scope, acceptance, sliceType, nonGoals, baselineGate, inheritedReviewerTarget = key }) {
158
+ return {
159
+ version: 1,
160
+ task: { key, title, scope, acceptance, sliceType },
161
+ nonGoals: [...nonGoals].sort(),
162
+ baselineGate,
163
+ inheritedReviewerTarget,
164
+ }
165
+ }
166
+
167
+ export function acceptancePackDigest (input) {
168
+ return sha256Hex(JSON.stringify(acceptancePack(input)))
169
+ }
170
+
85
171
  function contractSource (task) {
86
172
  const source = task?.contract && typeof task.contract === 'object' && !Array.isArray(task.contract)
87
173
  ? { ...task.contract }
@@ -91,12 +177,30 @@ function contractSource (task) {
91
177
  return source
92
178
  }
93
179
 
180
+ function normalizeProvidedContractDigest (source) {
181
+ if (source.digest === undefined) return null
182
+ if (typeof source.digest !== 'string' || !CONTRACT_DIGEST.test(source.digest)) {
183
+ throw new Error('contract digest must be a lowercase SHA-256 hex string')
184
+ }
185
+ return source.digest
186
+ }
187
+
188
+ function normalizeProvidedAcceptancePack (source) {
189
+ if (source.acceptancePack === undefined) return null
190
+ if (!source.acceptancePack || typeof source.acceptancePack !== 'object' || Array.isArray(source.acceptancePack)) {
191
+ throw new Error('contract acceptance pack must be an object')
192
+ }
193
+ return source.acceptancePack
194
+ }
195
+
94
196
  // Strict for new terminal submissions. `legacy:true` is restore-only: old persisted
95
197
  // plans predate this contract and must remain runnable, but they can never be used to
96
198
  // submit a new incomplete plan.
97
199
  export function normalizeTaskContract (task, { sliceType, legacy = false } = {}) {
98
200
  const source = contractSource(task)
99
- if (sliceType === SLICE_TYPE.review) return null
201
+ const digest = normalizeProvidedContractDigest(source)
202
+ const sealedPack = normalizeProvidedAcceptancePack(source)
203
+ if (sliceType === SLICE_TYPE.review) return legacy ? null : { digest, acceptancePack: sealedPack }
100
204
  const required = !legacy
101
205
  const rawNonGoals = source.nonGoals
102
206
  const values = rawNonGoals == null ? [] : (Array.isArray(rawNonGoals) ? rawNonGoals : [rawNonGoals])
@@ -112,7 +216,91 @@ export function normalizeTaskContract (task, { sliceType, legacy = false } = {})
112
216
  const evidence = baselineObject?.evidence == null || baselineObject?.evidence === ''
113
217
  ? ''
114
218
  : normalizeBaselineEvidence(baselineObject.evidence)
115
- return { nonGoals, baseline: gate ? { gate, evidence } : null }
219
+ return { nonGoals, baseline: gate ? { gate, evidence } : null, digest, acceptancePack: sealedPack }
220
+ }
221
+
222
+ function bindContractDigest (task) {
223
+ const sealedPack = acceptancePack({
224
+ key: task.key,
225
+ title: task.title,
226
+ scope: task.scope,
227
+ acceptance: task.acceptance,
228
+ sliceType: task.sliceType,
229
+ nonGoals: task.contract.nonGoals,
230
+ baselineGate: task.contract.baseline?.gate || '',
231
+ inheritedReviewerTarget: task.key,
232
+ })
233
+ const digest = sha256Hex(JSON.stringify(sealedPack))
234
+ if (task.contract.digest && task.contract.digest !== digest) throw new Error(`task "${task.key}" contract digest does not match its acceptance pack`)
235
+ if (task.contract.acceptancePack && JSON.stringify(task.contract.acceptancePack) !== JSON.stringify(sealedPack)) {
236
+ throw new Error(`task "${task.key}" sealed acceptance pack does not match its task fields`)
237
+ }
238
+ task.contract.digest = digest
239
+ task.contract.acceptancePack = sealedPack
240
+ }
241
+
242
+ // The browser persists normalized task rows before approval, then later sends a
243
+ // ready row back to the bridge for dispatch. Recompute the seal at that final
244
+ // boundary so changed acceptance/scope/non-goals can never ride beside an old hash.
245
+ export function validateTaskContractSeal (task, { legacy = false } = {}) {
246
+ const contract = task?.contract
247
+ if (!contract?.digest && legacy) return true
248
+ if (!contract || typeof contract !== 'object' || !contract.digest || !contract.acceptancePack) {
249
+ throw new Error('task is missing its sealed acceptance contract')
250
+ }
251
+ const pack = contract.acceptancePack
252
+ const packTask = pack?.task
253
+ if (!packTask || typeof packTask !== 'object' || !Array.isArray(pack.nonGoals)) {
254
+ throw new Error('task sealed acceptance pack is malformed')
255
+ }
256
+ const canonicalPack = acceptancePack({
257
+ key: packTask.key,
258
+ title: packTask.title,
259
+ scope: packTask.scope,
260
+ acceptance: packTask.acceptance,
261
+ sliceType: packTask.sliceType,
262
+ nonGoals: pack.nonGoals,
263
+ baselineGate: pack.baselineGate,
264
+ inheritedReviewerTarget: pack.inheritedReviewerTarget,
265
+ })
266
+ if (JSON.stringify(pack) !== JSON.stringify(canonicalPack)) throw new Error('task sealed acceptance pack is not canonical')
267
+ if (acceptancePackDigest({
268
+ key: packTask.key,
269
+ title: packTask.title,
270
+ scope: packTask.scope,
271
+ acceptance: packTask.acceptance,
272
+ sliceType: packTask.sliceType,
273
+ nonGoals: pack.nonGoals,
274
+ baselineGate: pack.baselineGate,
275
+ inheritedReviewerTarget: pack.inheritedReviewerTarget,
276
+ }) !== contract.digest) throw new Error('task acceptance digest does not match its sealed pack')
277
+
278
+ const sliceType = task.sliceType ?? task.slice_type
279
+ const key = task.key ?? task.task_key
280
+ const deps = Array.isArray(task.deps) ? task.deps : []
281
+ if (sliceType === SLICE_TYPE.review) {
282
+ if (contract.inheritedFrom !== packTask.key || deps.length !== 1 || deps[0] !== packTask.key) {
283
+ throw new Error('review task no longer targets its sealed builder')
284
+ }
285
+ if (pack.inheritedReviewerTarget !== packTask.key || task.acceptance !== packTask.acceptance) {
286
+ throw new Error(`review task "${key}" changed after its builder acceptance pack was sealed`)
287
+ }
288
+ return true
289
+ }
290
+ const livePack = acceptancePack({
291
+ key,
292
+ title: task.title,
293
+ scope: task.scope ?? '',
294
+ acceptance: task.acceptance ?? '',
295
+ sliceType,
296
+ nonGoals: contract.nonGoals || [],
297
+ baselineGate: contract.baseline?.gate || '',
298
+ inheritedReviewerTarget: key,
299
+ })
300
+ if (JSON.stringify(livePack) !== JSON.stringify(canonicalPack)) {
301
+ throw new Error(`task "${key}" changed after its acceptance pack was sealed`)
302
+ }
303
+ return true
116
304
  }
117
305
 
118
306
  // A FlowTask slice. Each is RUNNABLE — a lane can build + run + self-correct it in
@@ -244,6 +432,9 @@ export function normalizePlanOutput (raw, { legacy = false } = {}) {
244
432
  })
245
433
  })
246
434
  validateDag(tasks)
435
+ for (const task of tasks) {
436
+ if (task.sliceType !== SLICE_TYPE.review) bindContractDigest(task)
437
+ }
247
438
  for (const task of tasks) {
248
439
  if (task.sliceType !== SLICE_TYPE.review) continue
249
440
  // New review tasks verify exactly one builder contract. Legacy persisted reviews
@@ -252,11 +443,20 @@ export function normalizePlanOutput (raw, { legacy = false } = {}) {
252
443
  if (task.deps.length !== 1) throw new Error(`review task "${task.key}" must depend on exactly one builder task to inherit its contract`)
253
444
  const target = tasks.find((candidate) => candidate.key === task.deps[0])
254
445
  if (!target || target.sliceType === SLICE_TYPE.review) throw new Error(`review task "${task.key}" must target a builder/fix/scaffold task`)
446
+ if (task.contract.digest && task.contract.digest !== target.contract.digest) {
447
+ throw new Error(`review task "${task.key}" contract digest does not match target "${target.key}"`)
448
+ }
255
449
  task.contract = {
256
450
  nonGoals: [...(target.contract?.nonGoals || [])],
257
451
  baseline: target.contract?.baseline ? { ...target.contract.baseline } : null,
258
452
  inheritedFrom: target.key,
453
+ digest: target.contract.digest,
454
+ acceptancePack: target.contract.acceptancePack,
259
455
  }
456
+ // Reviewer-authored prose is not an authority boundary. Normalize the live
457
+ // review row to the builder's sealed acceptance so dispatch and the prompt
458
+ // cannot silently substitute a weaker criterion.
459
+ task.acceptance = target.contract.acceptancePack.task.acceptance
260
460
  }
261
461
  return { summary, tasks }
262
462
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.361",
3
+ "version": "0.7.362",
4
4
  "description": "Connect Claude Code, Codex, or Hermes on your computer to a Thinkpool Code room.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -93,6 +93,7 @@
93
93
  "design-edit.mjs",
94
94
  "design-source-contract.mjs",
95
95
  "flow-review.mjs",
96
+ "flow-receipt.mjs",
96
97
  "review-check.mjs",
97
98
  "flow-review-gate.mjs",
98
99
  "flow-review-reflect.mjs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "bundleVersion": 21,
3
+ "bundleVersion": 22,
4
4
  "contracts": [
5
5
  {
6
6
  "id": "room-coordination",
@@ -130,25 +130,28 @@
130
130
  },
131
131
  {
132
132
  "id": "flow-completion",
133
- "version": 3,
133
+ "version": 4,
134
134
  "routes": [
135
135
  {
136
136
  "id": "flow-completion",
137
137
  "tools": ["submit_flow_plan", "mark_flow_done", "submit_flow_review", "read_review_file", "run_review_check"],
138
138
  "trigger": "\\b(flow|submit_flow_plan|mark_flow_done|submit_flow_review|read_review_file|run_review_check)\\b",
139
- "prompt": "Managed Flow roles use only their exposed completion and review tools: submit_flow_plan, mark_flow_done, submit_flow_review, read_review_file, and run_review_check. New builder/fix/scaffold tasks must carry a bounded gate-first contract: observable acceptance, explicit non-goals, and a real pre-edit failure/absence gate; completion supplies the observed baseline evidence and the exact dependent reviewer inherits it. When a bounded detail is ambiguous or the user is unsure, choose the least-invasive reversible default, state the assumption, and continue; ask only when the choice would materially expand scope or authority. read_review_file is approval-free pinned-source access. run_review_check executes target-defined code without OS filesystem or network isolation and therefore remains subject to the runtime's normal approval boundary; approvalPolicy=never reviewers must not call it and must report the skipped check."
139
+ "prompt": "Managed Flow roles use only their exposed completion and review tools: submit_flow_plan, mark_flow_done, submit_flow_review, read_review_file, and run_review_check. New builder/fix/scaffold tasks must carry a bounded gate-first contract: observable acceptance, explicit non-goals, and a real pre-edit failure/absence gate. Approval seals that acceptance pack with a stable digest; completion supplies observed baseline evidence without changing the digest, and the exact dependent reviewer inherits it. A conclusive pass binds one bounded terminal receipt to the exact reviewed candidate SHA and acceptance digest; missing proof holds the Flow instead of completing it. When a bounded detail is ambiguous or the user is unsure, choose the least-invasive reversible default, state the assumption, and continue; ask only when the choice would materially expand scope or authority. read_review_file is approval-free pinned-source access. run_review_check executes target-defined code without OS filesystem or network isolation and therefore remains subject to the runtime's normal approval boundary; approvalPolicy=never reviewers must not call it and must report the skipped check."
140
140
  }
141
141
  ],
142
142
  "impact": [
143
143
  {"path": "bridge/bridge.mjs", "diffPattern": "submit_flow_plan|mark_flow_done|submit_flow_review|read_review_file|run_review_check"},
144
144
  {"path": "bridge/flow-review.mjs"},
145
+ {"path": "bridge/flow-receipt.mjs"},
145
146
  {"path": "bridge/flow-review-gate.mjs"},
146
147
  {"path": "bridge/flow-task-graph.mjs"}
147
148
  ],
148
149
  "evidence": [
149
150
  {"path": "bridge/bridge.mjs", "pattern": "'submit_flow_plan'"},
150
151
  {"path": "bridge/bridge.mjs", "pattern": "'mark_flow_done'"},
151
- {"path": "bridge/bridge.mjs", "pattern": "'submit_flow_review'"}
152
+ {"path": "bridge/bridge.mjs", "pattern": "'submit_flow_review'"},
153
+ {"path": "bridge/flow-task-graph.mjs", "pattern": "acceptancePackDigest"},
154
+ {"path": "bridge/flow-receipt.mjs", "pattern": "Passing flow review receipt requires an exact candidate and acceptance digest"}
152
155
  ]
153
156
  },
154
157
  {