thinkpool-pair 0.7.361 → 0.7.363

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,9 @@ 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'
101
+ import { collectFlowScopeEvidence, normalizeFlowScopeEvidence } from './flow-scope-evidence.mjs'
102
+ import { formatPastWorkSearch, searchPastWork } from './past-work-search.mjs'
100
103
  import { reviewGateDecision } from './flow-review-gate.mjs'
101
104
  import { IMMUTABLE_REVIEW_READ_TOOL_EXTRAS, UNSANDBOXED_REVIEW_CHECK_TOOL_EXTRAS, readReviewFile, runReviewCheck } from './review-check.mjs'
102
105
  import { pairAdjudicationPrompt, reviewReflectionDecision, REVIEW_DEFAULTS } from './flow-review-reflect.mjs'
@@ -124,12 +127,35 @@ const flowRedispatch = new Map()
124
127
  // wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
125
128
  // broadcasts; without persistent state the cap can never bite.
126
129
  const flowBudgets = new Map()
130
+ const changedFilesBetween = (cwd, baseSha, candidateSha) => {
131
+ if (!cwd || !baseSha || !candidateSha) return null
132
+ try {
133
+ return execFileSync('git', ['-C', cwd, 'diff', '--name-only', '--diff-filter=ACMRD', `${baseSha}..${candidateSha}`], {
134
+ encoding: 'utf8',
135
+ timeout: 5000,
136
+ stdio: ['ignore', 'pipe', 'ignore'],
137
+ }).split('\n').map((line) => line.trim()).filter(Boolean)
138
+ } catch {
139
+ return null
140
+ }
141
+ }
142
+ const worktreeChanges = (cwd) => {
143
+ try {
144
+ return execFileSync('git', ['-C', cwd, 'status', '--porcelain=v1', '--untracked-files=all'], {
145
+ encoding: 'utf8',
146
+ timeout: 5000,
147
+ stdio: ['ignore', 'pipe', 'ignore'],
148
+ }).split('\n').map((line) => line.trim()).filter(Boolean)
149
+ } catch {
150
+ return null
151
+ }
152
+ }
127
153
  import { formatPeek, PEEK, readTerminalTurnBudgetDecision, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, CROSSROOM_BUS, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneBusyOf, laneStatusOf, settleLaneBusy, settleLaneControl, nativeClaudeFallbackHint, buildDispatchPreview, authorizeDirectDispatch } from './cross-terminal.mjs'
128
154
  import { createStandalonePairResponder, standalonePairIdentity } from './direct-pair-room.mjs'
129
155
  import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
130
156
  import { supersedeDispatchLease } from './dispatch-lease.mjs'
131
157
  import { realtimeRecoveryDecision, turnInFlight } from './update-gate.mjs'
132
- 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'
158
+ import { saveSession, flushSession, deleteSession, loadAll, loadArchived, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage, acknowledgePendingScheduledOutcome, commitRecordedScheduledOutcome, deletePendingScheduledOutcome, loadPendingScheduledOutcome, loadPendingScheduledOutcomes, savePendingScheduledOutcome } from './session-store.mjs'
133
159
  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
160
  import { createLatestReplayPump, requestedReplayIds } from './replay-transport.mjs'
135
161
  import { classifyCodeEvent, unknownCodeEventNotice } from './code-event-contract.mjs'
@@ -966,11 +992,13 @@ const MIN_TURN_MS = Number(process.env.TP_NOTIFY_MIN_TURN_MS) > 0
966
992
  ? Number(process.env.TP_NOTIFY_MIN_TURN_MS) : DEFAULT_MIN_TURN_MS
967
993
  function persistAgentEvent (payload) {
968
994
  if (AGENT_NOTIFY_OFF || !codeAuthToken || !myServeUid || !room) return
995
+ const { eventId, ...eventPayload } = payload || {}
996
+ const stableId = typeof eventId === 'string' && /^[A-Za-z0-9_-]{1,120}$/.test(eventId) ? eventId : randomUUID()
969
997
  const row = {
970
- id: `ae:${randomUUID()}`,
998
+ id: `ae:${stableId}`,
971
999
  session_code: room,
972
1000
  author_id: myServeUid,
973
- payload: { __agent: true, ts: Date.now(), ...payload },
1001
+ payload: { __agent: true, ts: Date.now(), ...eventPayload },
974
1002
  }
975
1003
  fetch(`${SUPABASE_URL}/rest/v1/code_messages`, {
976
1004
  method: 'POST',
@@ -2533,7 +2561,7 @@ function worktreeSnapshot(cwd) {
2533
2561
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
2534
2562
  // persist to the host file; tool calls round-trip through the perm card; the
2535
2563
  // 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 }) {
2564
+ 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
2565
  if (sessions.has(id)) return
2538
2566
  runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
2539
2567
  // Fail closed before exposing a native lane if its bridge semantic contract
@@ -2606,6 +2634,9 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2606
2634
  flowReviewTargets: Array.isArray(flowReviewTargets) && flowReviewTargets.length ? flowReviewTargets.filter(Boolean) : (flowReviewTarget ? [flowReviewTarget] : []),
2607
2635
  flowReviewSnapshots: Array.isArray(flowReviewSnapshots) ? flowReviewSnapshots.filter((item) => item?.taskKey && item?.sha && item?.cwd) : [],
2608
2636
  flowReviewRound: Number.isInteger(flowReviewRound) && flowReviewRound >= 0 ? flowReviewRound : 0,
2637
+ flowReviewConclusion: flowReviewConclusion?.taskKey && ['pass', 'reject', 'surface'].includes(flowReviewConclusion?.action) && isFlowReviewReceipt(flowReviewConclusion?.receipt)
2638
+ ? { taskKey: flowReviewConclusion.taskKey, action: flowReviewConclusion.action, round: flowReviewConclusion.round, receipt: flowReviewConclusion.receipt }
2639
+ : null,
2609
2640
  dispatchBaseSha: dispatchBaseSha || null,
2610
2641
  revertTarget: revertTarget || null,
2611
2642
  // Stable creation order — persisted so a bridge restart restores tabs in the SAME
@@ -2676,6 +2707,25 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2676
2707
  if (!reviewPass && entry.dispatchBaseSha && commitSha === entry.dispatchBaseSha) {
2677
2708
  return `Slice "${entry.flowTaskKey}" is not done: HEAD is still the dispatch base (${commitSha.slice(0, 8)}). Commit the verified implementation first.`
2678
2709
  }
2710
+ if (!reviewPass) {
2711
+ const dirty = worktreeChanges(entry.cwd || process.cwd())
2712
+ if (dirty == null) return `Slice "${entry.flowTaskKey}" is not done: the bridge could not verify a clean worktree.`
2713
+ if (dirty.length) return `Slice "${entry.flowTaskKey}" is not done: commit or remove every remaining worktree change before completion (${dirty.slice(0, 4).join(', ')}).`
2714
+ }
2715
+ let scopeEvidence = null
2716
+ if (!reviewPass && entry.flowTaskContract?.scopePaths?.length) {
2717
+ const changed = changedFilesBetween(entry.cwd || process.cwd(), entry.dispatchBaseSha, commitSha)
2718
+ if (!changed) return `Slice "${entry.flowTaskKey}" is not done: the bridge could not derive its Git scope footprint.`
2719
+ scopeEvidence = collectFlowScopeEvidence({
2720
+ log: entry.log,
2721
+ repoRoot: entry.cwd || process.cwd(),
2722
+ declared: entry.flowTaskContract.scopePaths,
2723
+ changed,
2724
+ })
2725
+ if (!scopeEvidence.held) {
2726
+ return `Slice "${entry.flowTaskKey}" is not done: scope drift detected in ${scopeEvidence.unexpected.join(', ')}. The approved scope must be amended before these files can ship.`
2727
+ }
2728
+ }
2679
2729
  let previewUrl = null
2680
2730
  try { const pv = await startPreview({ dir: entry.cwd || process.cwd(), id: `lane:${entry.flowSessionId}:${id}` }); previewUrl = pv.url } catch { /* preview best-effort */ }
2681
2731
  // S1 (context-offload) — digest THIS closed slice into the durable store so the next
@@ -2706,8 +2756,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2706
2756
  process.stderr.write(`\n ${A.dim}◆ flow digest skip ${entry.flowTaskKey} — ${e?.message || e}${A.rst}\n`)
2707
2757
  }
2708
2758
  const completedContract = requiresBaselineReceipt
2709
- ? { ...entry.flowTaskContract, baseline: { ...entry.flowTaskContract.baseline, evidence: baselineReceipt } }
2710
- : entry.flowTaskContract || null
2759
+ ? { ...entry.flowTaskContract, baseline: { ...entry.flowTaskContract.baseline, evidence: baselineReceipt }, ...(scopeEvidence ? { scopeEvidence } : {}) }
2760
+ : entry.flowTaskContract
2761
+ ? { ...entry.flowTaskContract, ...(scopeEvidence ? { scopeEvidence } : {}) }
2762
+ : null
2711
2763
  bcast('flow-task-done', { term: id, flowId: entry.flowSessionId, taskKey: entry.flowTaskKey, laneId: id, commitSha, previewUrl, contract: completedContract, ...(reviewPass ? { reviewAction: 'pass' } : {}) }, flowChannel)
2712
2764
  process.stderr.write(`\n ${A.cyan}◆ flow slice done — ${entry.flowTaskKey} (${commitSha ? commitSha.slice(0, 8) : 'no commit'})${previewUrl ? ` · preview ${previewUrl}` : ''}${A.rst}\n`)
2713
2765
  entry.flowDone = true // FL-B3 — retire immediately so it drops from the ≤8 lane cap
@@ -2719,7 +2771,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2719
2771
  // review gate was dead code: reviewers had no working emit path, so a failing slice was
2720
2772
  // never reverted). On FAIL → broadcast flow-revert for the reviewed slice (the client flips
2721
2773
  // it to pending → the next wave rebuilds it). Either way the review lane itself is done.
2722
- const onReviewVerdict = async (raw) => {
2774
+ const processReviewVerdict = async (raw) => {
2723
2775
  if (!entry.flowSessionId || !entry.flowTaskKey || entry.flowRole !== 'reviewer') return { ok: false, message: 'Not a Flow review lane.' }
2724
2776
  let v, target = null
2725
2777
  try {
@@ -2737,6 +2789,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2737
2789
  ? `Review verdict REJECTED: ${e?.message || e}. Re-call submit_flow_review with a valid authorized taskKey.`
2738
2790
  : `Review verdict REJECTED: ${e?.message || e}. Re-Write FLOW_REVIEW.json with {"pass":<boolean>,"reasons":["<specific finding>"],"taskKey":"<the slice you reviewed>"}.` }
2739
2791
  }
2792
+ if (entry.flowReviewConclusion) return { ok: true, message: `Review already concluded for ${entry.flowReviewConclusion.taskKey}; receipt ${entry.flowReviewConclusion.receipt.cid || 'recorded'}.` }
2740
2793
  // E1 A1/A2 — the BOUNDED reviewer loop, live side. Each FLOW_REVIEW.json write is ONE
2741
2794
  // hunt round; the governor decides continue-vs-stop from the round count + the lane's
2742
2795
  // REAL budget (flowBudgets ledger). A bare pass:true (happy path held, not yet exhausted)
@@ -2778,15 +2831,53 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2778
2831
  } else {
2779
2832
  process.stderr.write(`\n ${A.cyan}◆ review ${decision.action.toUpperCase()} (round ${round}) — ${target || entry.flowTaskKey}${A.rst}\n`)
2780
2833
  }
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}`,
2834
+ // Durable room-visible verdict. Bind a conclusive pass to the exact immutable
2835
+ // builder snapshot plus the acceptance digest inherited at approval time. A
2836
+ // missing/malformed proof can never look like a verified pass: it becomes a
2837
+ // held receipt and assembly stays blocked for pair adjudication.
2838
+ const reviewedTaskKey = target || entry.flowTaskKey
2839
+ const targetSnapshot = entry.flowReviewSnapshots.find((item) => item.taskKey === reviewedTaskKey) || null
2840
+ const acceptanceDigest = entry.flowTaskContract?.digest || null
2841
+ let terminalAction = decision.action
2842
+ let receiptReasons = v.reasons.length ? v.reasons : [decision.reason]
2843
+ let visibleReview
2844
+ try {
2845
+ visibleReview = createFlowReviewReceipt({
2846
+ taskKey: reviewedTaskKey,
2847
+ outcome: terminalAction === 'surface' ? 'held' : terminalAction,
2848
+ rounds: round,
2849
+ candidateSha: targetSnapshot?.sha,
2850
+ acceptanceDigest,
2851
+ scope: targetSnapshot?.scopeEvidence,
2852
+ reasons: receiptReasons,
2853
+ })
2854
+ } catch (error) {
2855
+ if (terminalAction !== 'pass') {
2856
+ return { ok: false, message: `Review verdict REJECTED: ${error?.message || error}.` }
2857
+ }
2858
+ terminalAction = 'surface'
2859
+ receiptReasons = [...receiptReasons, 'Exact candidate or sealed acceptance proof is missing; pass was held.']
2860
+ visibleReview = createFlowReviewReceipt({
2861
+ taskKey: reviewedTaskKey,
2862
+ outcome: 'held',
2863
+ rounds: round,
2864
+ candidateSha: targetSnapshot?.sha,
2865
+ acceptanceDigest,
2866
+ scope: targetSnapshot?.scopeEvidence,
2867
+ reasons: receiptReasons,
2868
+ })
2869
+ }
2870
+ visibleReview.cid = randomUUID()
2871
+ entry.flowReviewConclusion = {
2872
+ taskKey: reviewedTaskKey,
2873
+ action: terminalAction,
2874
+ round,
2875
+ receipt: visibleReview,
2789
2876
  }
2877
+ // Persist the conclusion before emitting side effects. A retry or restart
2878
+ // can reconstruct a missing transcript row from this safe receipt, but can
2879
+ // never conclude the same review twice.
2880
+ entry.flush?.()
2790
2881
  pushLog(entry, visibleReview)
2791
2882
  bcast('code-event', { term: id, evt: visibleReview })
2792
2883
  entry.flush?.()
@@ -2796,45 +2887,53 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2796
2887
  // broadcast, does NOT change the revert/gate flow.
2797
2888
  bcast('flow-review-verdict', {
2798
2889
  term: id, flowId: entry.flowSessionId,
2799
- taskKey: target || entry.flowTaskKey,
2800
- pass: v.pass,
2801
- reasons: v.reasons,
2802
- action: decision.action,
2890
+ taskKey: reviewedTaskKey,
2891
+ pass: terminalAction === 'pass',
2892
+ reasons: receiptReasons,
2893
+ action: terminalAction,
2803
2894
  rounds: round,
2804
- surfaceToPair: decision.surfaceToPair,
2895
+ surfaceToPair: terminalAction === 'surface' || decision.surfaceToPair,
2805
2896
  prompt: pairAdjudicationPrompt({
2806
- action: decision.action,
2807
- reason: decision.reason,
2808
- taskKey: target || entry.flowTaskKey,
2809
- findings: v.reasons,
2897
+ action: terminalAction,
2898
+ reason: terminalAction === decision.action ? decision.reason : receiptReasons.at(-1),
2899
+ taskKey: reviewedTaskKey,
2900
+ findings: receiptReasons,
2810
2901
  }),
2811
2902
  }, flowChannel)
2812
2903
  let doneMsg = ''
2813
- if (decision.action === 'pass') {
2904
+ if (terminalAction === 'pass') {
2814
2905
  doneMsg = await markFlowDone({ reviewPass: true })
2815
2906
  } 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}` })
2907
+ if (terminalAction === 'surface') {
2908
+ bcast('flow-review-held', { term: id, flowId: entry.flowSessionId, reviewTaskKey: entry.flowTaskKey, taskKey: target, reasons: receiptReasons }, flowChannel)
2909
+ persistAgentEvent({ eventId: visibleReview.cid, kind: 'needs-input', term: id, termName: termNames[id] || null, summary: `Flow review held ${reviewedTaskKey}: ${receiptReasons.join('; ') || decision.reason}` })
2819
2910
  }
2820
2911
  // A reject must rebuild the target and then run a fresh reviewer. Retire that
2821
2912
  // reviewer now. A surfaced inconclusive review is different: keep its lane
2822
2913
  // alive with the durable control row above so either room member can open the
2823
2914
  // held review, read the exact findings, and continue/adjudicate after reload.
2824
2915
  // Neither outcome emits task-done or permits assembly.
2825
- if (decision.action === 'reject') {
2916
+ if (terminalAction === 'reject') {
2826
2917
  entry.flowDone = true
2827
2918
  entry.flush?.()
2828
2919
  setTimeout(() => { try { endStructured(id) } catch { /* already gone */ } }, 2500)
2829
2920
  }
2830
2921
  }
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}`
2922
+ const label = terminalAction === 'reject'
2923
+ ? `REJECT (round ${round}) — reverting ${target || '(no target)'} (${receiptReasons.join('; ').slice(0, 140)})`
2924
+ : terminalAction === 'surface'
2925
+ ? `SURFACED to the pair after ${round} round${round === 1 ? '' : 's'} — ${receiptReasons.at(-1) || decision.reason}`
2835
2926
  : `PASS (round ${round})`
2836
2927
  return { ok: true, message: `Review verdict recorded: ${label}.${doneMsg ? ` ${doneMsg}` : ''}` }
2837
2928
  }
2929
+ const runFlowReviewVerdict = createFlowReviewSingleFlight()
2930
+ const onReviewVerdict = async (raw) => {
2931
+ if (entry.flowReviewConclusion) return { ok: true, message: `Review already concluded for ${entry.flowReviewConclusion.taskKey}; receipt ${entry.flowReviewConclusion.receipt.cid || 'recorded'}.` }
2932
+ // One terminal verdict is one transaction. In particular, reject awaits the
2933
+ // host revert before it can persist its conclusion; without this single-flight
2934
+ // fence two simultaneous submissions can both cross that await and revert/emit.
2935
+ return runFlowReviewVerdict(() => processReviewVerdict(raw))
2936
+ }
2838
2937
  // Identity for the durable archive — pushLog appends every new transcript event to
2839
2938
  // <room>/<id>.events.jsonl keyed off these. Seed the archive once from the restored
2840
2939
  // (≤2000) log so the retained window is immediately pageable; no-op if it already
@@ -2891,13 +2990,19 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2891
2990
  // restart. Without this, sessionData omitted it → on restart the resumed session
2892
2991
  // re-launched on the host default (Opus) regardless of the last switch, and the
2893
2992
  // 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) })
2993
+ 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
2994
  const persist = () => saveSession(room, id, sessionData())
2896
2995
  // Synchronous flush of this session's record. Used on open (so a brand-new session
2897
2996
  // has a file under its id BEFORE its first event — surviving a restart inside the
2898
2997
  // 1.5s saveSession debounce window) and on shutdown (so events since the last
2899
2998
  // debounced write aren't lost). Contract #2: restart resumes, no lost messages.
2900
2999
  entry.flush = () => flushSession(room, id, sessionData())
3000
+ if (entry.flowReviewConclusion?.receipt && !entry.log.some((event) => event?.cid === entry.flowReviewConclusion.receipt.cid)) {
3001
+ const restoredReceipt = { ...entry.flowReviewConclusion.receipt }
3002
+ pushLog(entry, restoredReceipt)
3003
+ bcast('code-event', { term: id, evt: restoredReceipt })
3004
+ entry.flush()
3005
+ }
2901
3006
  if (entry.scheduleOutcomeRecorded && entry.scheduleAdmissionLease) {
2902
3007
  void releaseScheduledAdmissionForEntry(entry).then((released) => {
2903
3008
  if (released) entry.flush?.()
@@ -3091,6 +3196,44 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3091
3196
  return { content: [{ type: 'text', text }] }
3092
3197
  },
3093
3198
  ),
3199
+ ...(!entry.flowRole ? [tool(
3200
+ 'search_past_work',
3201
+ 'Search bounded saved terminal work from THIS ThinkPool room. Returns privacy-filtered excerpts for prior decisions, failed attempts, commands, checks, and Flow receipts. Every hit cites the room, terminal, date, and event sequence. It never searches another room, exposes prompts or host paths, or changes state.',
3202
+ {
3203
+ query: z.string().min(2).max(160).describe('specific terms from the prior decision, failure, command, check, or feature'),
3204
+ limit: z.number().int().min(1).max(8).optional().describe('maximum cited hits; default 8'),
3205
+ },
3206
+ async (args) => {
3207
+ if (entry.pastWorkSearchTurnRev !== entry._turnRev) {
3208
+ entry.pastWorkSearchTurnRev = entry._turnRev
3209
+ entry.pastWorkSearchCount = 0
3210
+ }
3211
+ if ((entry.pastWorkSearchCount || 0) >= 3) {
3212
+ return { content: [{ type: 'text', text: 'Past-work search limit reached for this turn (3). Use the cited results already returned or narrow the next room turn.' }] }
3213
+ }
3214
+ entry.pastWorkSearchCount = (entry.pastWorkSearchCount || 0) + 1
3215
+ const records = new Map()
3216
+ for (const record of loadArchived(room, 40)) {
3217
+ if (!record?.id || !Array.isArray(record.log)) continue
3218
+ records.set(record.id, { id: record.id, name: termNames[record.id] || record.name || 'Archived terminal', savedAt: record.savedAt || record._archivedAt, log: record.log })
3219
+ }
3220
+ for (const [terminalId, terminalEntry] of sessions) {
3221
+ records.set(terminalId, {
3222
+ id: terminalId,
3223
+ name: termNames[terminalId] || `Terminal ${String(terminalId).slice(0, 8)}`,
3224
+ savedAt: terminalEntry.openedAt || Date.now(),
3225
+ log: terminalEntry.log || [],
3226
+ })
3227
+ }
3228
+ let results
3229
+ try {
3230
+ results = searchPastWork([...records.values()], args?.query || '', { room, limit: args?.limit })
3231
+ } catch (error) {
3232
+ return { content: [{ type: 'text', text: `Past-work search rejected: ${error?.message || error}` }] }
3233
+ }
3234
+ return { content: [{ type: 'text', text: formatPastWorkSearch(results, args?.query || '') }] }
3235
+ },
3236
+ )] : []),
3094
3237
  // Tier 1 cross-ROOM peek — list_sessions / read_session. Read-only reach into the
3095
3238
  // account's OTHER rooms (this session's sibling SESSIONS, not just sibling terminals),
3096
3239
  // routed through the account supervisor over IPC (pairRequest). Auto-allowed by the
@@ -3471,7 +3614,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3471
3614
  // (slices whose deps just got satisfied).
3472
3615
  ...(entry.flowRole === 'builder' ? [tool(
3473
3616
  'mark_flow_done',
3474
- "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.",
3617
+ "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. The bridge derives the edited-file footprint from Git, compares it with the sealed scopePaths, and refuses unexpected touches. 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.",
3475
3618
  { baselineEvidence: z.string().max(1400).optional().describe('real single-line pre-edit baseline command/behavior receipt; required for new gate-first tasks') },
3476
3619
  async (args) => ({ content: [{ type: 'text', text: await markFlowDone({ baselineEvidence: args?.baselineEvidence || '' }) }] }),
3477
3620
  )] : []),
@@ -3483,14 +3626,14 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3483
3626
  // reject malformed plans back to the conductor so it re-emits.
3484
3627
  ...(entry.flowRole === 'conductor' ? [tool(
3485
3628
  'submit_flow_plan',
3486
- '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.',
3629
+ '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":"human-readable ownership","scopePaths":["repo/file.js","repo/directory/**"],"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, machine-readable repo-relative scopePaths, 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.',
3487
3630
  { plan: z.string().describe('the task-graph as a JSON string (the {summary, tasks:[…]} object)') },
3488
3631
  async (args) => {
3489
3632
  const okText = (t) => ({ content: [{ type: 'text', text: t }] })
3490
3633
  if (!entry.flowSessionId || entry.flowRole !== 'conductor') return okText('Not a Flow conductor — there is no flow to submit a plan for.')
3491
3634
  let norm
3492
3635
  try { norm = validatePlanForRuntime(normalizePlanOutput(args?.plan || ''), entry.runtime) }
3493
- 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.`) }
3636
+ 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, repo-relative scopePaths, bounded nonGoals, and a safe baseline gate that says what fails/is absent; each review targets exactly one builder contract.`) }
3494
3637
  bcast('flow-plan', { term: id, flowId: entry.flowSessionId, plan: JSON.stringify(norm) }, flowChannel)
3495
3638
  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`)
3496
3639
  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).`)
@@ -3753,7 +3896,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3753
3896
  provider: entry.provider, log: entry.log, commands: entry.commands, mode: entry.mode,
3754
3897
  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
3898
  flowTaskKey: entry.flowTaskKey, flowTaskContract: entry.flowTaskContract, flowRole: entry.flowRole, flowReviewTarget: entry.flowReviewTarget,
3756
- flowReviewTargets: entry.flowReviewTargets, flowReviewSnapshots: entry.flowReviewSnapshots, flowReviewRound: entry.flowReviewRound,
3899
+ flowReviewTargets: entry.flowReviewTargets, flowReviewSnapshots: entry.flowReviewSnapshots, flowReviewRound: entry.flowReviewRound, flowReviewConclusion: entry.flowReviewConclusion,
3757
3900
  dispatchBaseSha: entry.dispatchBaseSha, revertTarget: entry.revertTarget, cwd: entry.cwd,
3758
3901
  managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt,
3759
3902
  reviewSliceRoots: entry.reviewSliceRoots, openedAt: entry.openedAt,
@@ -4280,7 +4423,7 @@ function respawnStructured(id, provider) {
4280
4423
  // openStructured seed from the TARGET provider's configured model, which is the
4281
4424
  // only model this lane was ever asked for. A same-env model change never reaches
4282
4425
  // 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
4426
+ 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
4427
  // Context-carry (2026-07-08): a provider switch is not an SDK resume — the new backend
4285
4428
  // starts blank mid-conversation. Synthesize a plain-text recap from the VISIBLE log NOW
4286
4429
  // (before teardown) and hand it to the fresh session as its first turn so the agent
@@ -4299,7 +4442,7 @@ function respawnStructured(id, provider) {
4299
4442
  // sessionData() (provider included) synchronously on open, so a bridge restart
4300
4443
  // restores the lane on its CURRENT provider, not the original — and its next
4301
4444
  // 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 })
4445
+ 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
4446
  return true
4304
4447
  }
4305
4448
 
@@ -5273,7 +5416,7 @@ channel
5273
5416
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
5274
5417
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
5275
5418
  // 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,
5419
+ 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
5420
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
5278
5421
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
5279
5422
  // (mid-turn needs auto-resume; flow needs its lane live).
@@ -5496,6 +5639,13 @@ flowChannel
5496
5639
  // Step 4 — a `review` slice gets the adversarial reviewer prompt (try-to-break),
5497
5640
  // every other slice gets the builder prompt.
5498
5641
  const isReview = t.slice_type === 'review'
5642
+ try {
5643
+ validateTaskContractSeal(t, { legacy: t.contract == null })
5644
+ } catch (error) {
5645
+ process.stderr.write(`\n ${A.yel}◆ flow dispatch held — ${t.task_key || 'task'} failed its sealed acceptance contract: ${error?.message || error}${A.rst}\n`)
5646
+ bcast('flow-dispatch-held', { term: 'flow', flowId: payload.flowId, taskKey: t.task_key || null, reason: 'sealed acceptance contract mismatch' }, flowChannel)
5647
+ continue
5648
+ }
5499
5649
  if (!validReviewTargetShape(t, flowRuntime)) {
5500
5650
  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
5651
  continue
@@ -5529,7 +5679,26 @@ flowChannel
5529
5679
  const cwd = worktreeSpec({ flowId: payload.flowId, taskKey: dep }).dir
5530
5680
  let sha = null
5531
5681
  try { sha = execFileSync('git', ['-C', cwd, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }).trim() } catch { /* target is not safely reviewable */ }
5532
- return { taskKey: dep, cwd, sha }
5682
+ let scopeEvidence = null
5683
+ if (sha && t.contract?.scopePaths?.length) {
5684
+ let baseSha = null
5685
+ try {
5686
+ baseSha = execFileSync('git', ['-C', cwd, 'merge-base', sha, 'origin/main'], {
5687
+ encoding: 'utf8',
5688
+ timeout: 3000,
5689
+ stdio: ['ignore', 'pipe', 'ignore'],
5690
+ }).trim()
5691
+ } catch { /* missing immutable base holds the review below */ }
5692
+ const changed = changedFilesBetween(cwd, baseSha, sha)
5693
+ if (changed) {
5694
+ const mechanical = collectFlowScopeEvidence({ log: [], repoRoot: cwd, declared: t.contract.scopePaths, changed })
5695
+ const observed = normalizeFlowScopeEvidence(t.contract.scopeEvidence)
5696
+ scopeEvidence = observed
5697
+ ? { ...mechanical, read: observed.read, verified: observed.verified }
5698
+ : mechanical
5699
+ }
5700
+ }
5701
+ return { taskKey: dep, cwd, sha, ...(scopeEvidence ? { scopeEvidence } : {}) }
5533
5702
  }).filter((item) => item.sha) : []
5534
5703
  // Lanes build autonomously in their own worktree — bypassPermissions so they
5535
5704
  // don't stall on a card for every write/bash (matches the user's expectation that
@@ -5545,6 +5714,7 @@ flowChannel
5545
5714
  ? (isReview ? FLOW_CODEX_REVIEWER_PROMPT : FLOW_CODEX_LANE_PROMPT)
5546
5715
  : (isReview ? FLOW_REVIEWER_PROMPT : FLOW_LANE_PROMPT)
5547
5716
  const laneRolePrompt = buildLanePrompt({ base: laneBase })
5717
+ const sealedReviewAcceptance = t.contract?.acceptancePack?.task?.acceptance
5548
5718
  // Model tiers (2026-07-03-flow-lane-model-tiers): pick the lane's brain by slice_type
5549
5719
  // (scaffold→sonnet, feature/fix/review→opus; env can blanket-override or `inherit` to
5550
5720
  // restore today's exact behavior). undefined → no model key passed (openStructured default).
@@ -5571,7 +5741,7 @@ flowChannel
5571
5741
  ? `Reviewed slice worktree(s) — check out + RUN each yourself:\n` +
5572
5742
  t.deps.map((dep) => { const ws = worktreeSpec({ flowId: payload.flowId, taskKey: dep }); return ` - ${dep}: dir ${ws.dir} (branch ${ws.branch})` }).join('\n') + '\n'
5573
5743
  : '') +
5574
- `ACCEPTANCE to INDEPENDENTLY verify: ${t.acceptance || t.title}\n` +
5744
+ `ACCEPTANCE to INDEPENDENTLY verify: ${sealedReviewAcceptance || t.acceptance || t.title}\n` +
5575
5745
  `INHERITED NON-GOALS (must remain untouched): ${(t.contract?.nonGoals || []).join(' | ') || '(legacy task: none recorded)'}\n` +
5576
5746
  `INHERITED BASELINE GATE: ${t.contract?.baseline?.gate || '(legacy task: none recorded)'}\n` +
5577
5747
  `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,122 @@
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
+ import { normalizeFlowScopeEvidence } from './flow-scope-evidence.mjs'
7
+
8
+ export const FLOW_REVIEW_RECEIPT_KIND = 'flow-review-receipt'
9
+
10
+ const MAX_TASK_KEY = 120
11
+ const MAX_LINE = 180
12
+ const MAX_LINES = 4
13
+ const OUTCOMES = new Set(['pass', 'reject', 'held'])
14
+ const DIGEST = /^[a-f0-9]{64}$/
15
+ 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
16
+ 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
17
+ const HOST_PATH = /(?:^~[\\/]|^\.?[\\/]|(?:^|\s)\/[\w.-]+|[A-Za-z]:\\|\\\\|(?:^|[\\/])(?:Users|home|var|tmp|private|workspace)(?:[\\/]|$)|\.thinkpool[\\/])/i
18
+ const PATH_TRAVERSAL = /(?:^|[^a-z0-9.])\.\.(?:[\\/]|$)/i
19
+
20
+ function cleanText(value, max) {
21
+ if (typeof value !== 'string' || value.length === 0 || value.length > max) return null
22
+ if (/\r|\n/.test(value) || SECRET_VALUE.test(value) || SECRET_OR_PRIVATE.test(value) || HOST_PATH.test(value) || PATH_TRAVERSAL.test(value)) return null
23
+ const text = value.replace(/\s+/g, ' ').trim()
24
+ return text && text.length <= max ? text : null
25
+ }
26
+
27
+ function cleanLines(value) {
28
+ if (!Array.isArray(value)) return []
29
+ const out = []
30
+ for (const line of value) {
31
+ const safe = cleanText(line, MAX_LINE)
32
+ if (safe && !out.includes(safe)) out.push(safe)
33
+ if (out.length === MAX_LINES) break
34
+ }
35
+ return out
36
+ }
37
+
38
+ function cleanTaskKey(value) {
39
+ const taskKey = cleanText(value, MAX_TASK_KEY)
40
+ if (!taskKey || !/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(taskKey) || taskKey.includes('..')) return null
41
+ return taskKey
42
+ }
43
+
44
+ function cleanSha(value) {
45
+ if (typeof value !== 'string') return null
46
+ const sha = value.trim().toLowerCase()
47
+ return /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(sha) ? sha : null
48
+ }
49
+
50
+ function cleanRounds(value) {
51
+ return Number.isInteger(value) && value >= 1 && value <= 99 ? value : null
52
+ }
53
+
54
+ function cleanDigest(value) {
55
+ if (typeof value !== 'string') return null
56
+ const digest = value.trim().toLowerCase()
57
+ return DIGEST.test(digest) ? digest : null
58
+ }
59
+
60
+ // Build the only wire shape the renderer accepts. Optional fields are omitted
61
+ // rather than represented as null, making absence honest (notably deployment).
62
+ export function createFlowReviewReceipt(input = {}) {
63
+ const taskKey = cleanTaskKey(input.taskKey)
64
+ const outcome = typeof input.outcome === 'string' ? input.outcome.toLowerCase() : ''
65
+ const rounds = cleanRounds(input.rounds)
66
+ if (!taskKey || !OUTCOMES.has(outcome) || rounds == null) {
67
+ throw new TypeError('Invalid flow review receipt identity')
68
+ }
69
+
70
+ const receipt = {
71
+ kind: FLOW_REVIEW_RECEIPT_KIND,
72
+ taskKey,
73
+ outcome,
74
+ rounds,
75
+ checks: cleanLines(input.checks),
76
+ reasons: cleanLines(input.reasons),
77
+ }
78
+ const candidateSha = cleanSha(input.candidateSha)
79
+ const acceptanceDigest = cleanDigest(input.acceptanceDigest)
80
+ const scope = normalizeFlowScopeEvidence(input.scope)
81
+ // Presence, not truthiness, is intentional: deployments are never inferred
82
+ // from a pass/reject outcome or from a candidate SHA.
83
+ const deployment = Object.hasOwn(input, 'deployment') ? cleanText(input.deployment, MAX_LINE) : null
84
+ if (candidateSha) receipt.candidateSha = candidateSha
85
+ if (acceptanceDigest) receipt.acceptanceDigest = acceptanceDigest
86
+ receipt.verified = Boolean(candidateSha && acceptanceDigest)
87
+ if (outcome === 'pass' && !receipt.verified) {
88
+ throw new TypeError('Passing flow review receipt requires an exact candidate and acceptance digest')
89
+ }
90
+ if (scope) receipt.scope = scope
91
+ if (outcome === 'pass' && !scope?.held) {
92
+ throw new TypeError('Passing flow review receipt requires a held scope footprint')
93
+ }
94
+ if (deployment) receipt.deployment = deployment
95
+ return receipt
96
+ }
97
+
98
+ export function isFlowReviewReceipt(value) {
99
+ try {
100
+ const receipt = createFlowReviewReceipt(value)
101
+ return receipt.kind === value?.kind
102
+ } catch {
103
+ return false
104
+ }
105
+ }
106
+
107
+ // A review lane may receive the same terminal submission twice (tool retry,
108
+ // double click, or concurrent provider delivery). Serialize the whole verdict
109
+ // transaction—not just receipt emission—so an awaited revert cannot run twice.
110
+ export function createFlowReviewSingleFlight() {
111
+ let pending = null
112
+ return async function runFlowReviewVerdict(operation) {
113
+ if (pending) return pending
114
+ const current = (async () => operation())()
115
+ pending = current
116
+ try {
117
+ return await current
118
+ } finally {
119
+ if (pending === current) pending = null
120
+ }
121
+ }
122
+ }
@@ -0,0 +1,117 @@
1
+ import path from 'node:path'
2
+
3
+ const MAX_SCOPE_PATHS = 32
4
+ const MAX_PATH = 180
5
+ const MAX_OBSERVED = 32
6
+ const MAX_CHECKS = 8
7
+ const MAX_CHECK = 220
8
+ const PRIVATE_PATH = /(?:^|\/)(?:\.env(?:\.|$)|\.git(?:\/|$)|\.thinkpool(?:\/|$)|node_modules(?:\/|$))/
9
+ const SECRET = /(?:sk[_-](?:proj[_-])?[a-z0-9_-]{8,}|github_pat_[a-z0-9_]{20,}|gh[pousr]_[a-z0-9_-]{8,}|(?:AKIA|ASIA)[0-9A-Z]{16}|npm_[a-z0-9]{24,}|bearer\s+[a-z0-9._-]{8,}|(?:api[_-]?key|access[_-]?token|auth(?:orization)?|token|secret)\s*[=:]\s*\S+)/i
10
+ const VERIFY_COMMAND = /(?:^|\s)(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|build|lint|check|typecheck|verify)\b|(?:^|\s)node\s+--test\b|(?:vitest|jest|playwright|eslint|tsc)\b/i
11
+
12
+ function uniqueSorted(values, max = MAX_OBSERVED) {
13
+ return [...new Set(values)].sort().slice(0, max)
14
+ }
15
+
16
+ function repoPath(value, { allowPattern = false } = {}) {
17
+ if (typeof value !== 'string') return null
18
+ let text = value.trim().replaceAll('\\', '/')
19
+ if (!text || text.length > MAX_PATH || text.startsWith('/') || /^[A-Za-z]:\//.test(text)) return null
20
+ const directoryPattern = allowPattern && text.endsWith('/**')
21
+ if (text.includes('*') && !directoryPattern) return null
22
+ const bare = directoryPattern ? text.slice(0, -3) : text
23
+ if (!bare || bare.endsWith('/') || bare.split('/').some((part) => !part || part === '.' || part === '..')) return null
24
+ if (PRIVATE_PATH.test(bare)) return null
25
+ const last = bare.split('/').at(-1)
26
+ if (allowPattern && !directoryPattern && !last.includes('.') && !/^(?:Dockerfile|Makefile|LICENSE|README)$/i.test(last)) return null
27
+ text = directoryPattern ? `${bare}/**` : bare
28
+ return text
29
+ }
30
+
31
+ export function normalizeFlowScopePaths(value) {
32
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_SCOPE_PATHS) {
33
+ throw new TypeError(`scope paths must contain 1-${MAX_SCOPE_PATHS} repo-relative entries`)
34
+ }
35
+ const paths = []
36
+ for (const item of value) {
37
+ const safe = repoPath(item, { allowPattern: true })
38
+ if (!safe) throw new TypeError(`invalid scope path: ${JSON.stringify(item)}`)
39
+ if (!paths.includes(safe)) paths.push(safe)
40
+ }
41
+ return paths.sort()
42
+ }
43
+
44
+ export function classifyFlowScope({ declared, changed } = {}) {
45
+ const safeDeclared = normalizeFlowScopePaths(declared)
46
+ const safeChanged = uniqueSorted((Array.isArray(changed) ? changed : []).map((item) => repoPath(item)).filter(Boolean))
47
+ const unexpected = safeChanged.filter((file) => !safeDeclared.some((scope) =>
48
+ scope.endsWith('/**') ? file.startsWith(scope.slice(0, -2)) : file === scope
49
+ ))
50
+ return { declared: safeDeclared, changed: safeChanged, unexpected, held: unexpected.length === 0 }
51
+ }
52
+
53
+ function observedPath(value, repoRoot) {
54
+ if (typeof value !== 'string' || !value.trim()) return null
55
+ const absoluteRoot = path.resolve(repoRoot)
56
+ const absolute = path.isAbsolute(value) ? path.resolve(value) : path.resolve(absoluteRoot, value)
57
+ const relative = path.relative(absoluteRoot, absolute).replaceAll('\\', '/')
58
+ if (!relative || relative.startsWith('../') || path.isAbsolute(relative)) return null
59
+ return repoPath(relative)
60
+ }
61
+
62
+ function safeCheck(value, repoRoot) {
63
+ if (typeof value !== 'string') return null
64
+ let command = value.replaceAll(path.resolve(repoRoot), '.').replace(/\s+/g, ' ').trim()
65
+ if (!command || command.length > MAX_CHECK || SECRET.test(command) || !VERIFY_COMMAND.test(command)) return null
66
+ return command
67
+ }
68
+
69
+ export function collectFlowScopeEvidence({ log = [], repoRoot, declared, changed } = {}) {
70
+ const classified = classifyFlowScope({ declared, changed })
71
+ const reads = []
72
+ const checks = new Map()
73
+ const successful = new Set()
74
+ for (const event of Array.isArray(log) ? log : []) {
75
+ if (event?.kind === 'assistant') {
76
+ for (const block of event.blocks || []) {
77
+ if (block?.type !== 'tool_use') continue
78
+ const name = String(block.name || '').toLowerCase()
79
+ if (['read', 'read_file', 'edit', 'write'].includes(name)) {
80
+ const candidate = block.input?.file_path ?? block.input?.path
81
+ const safe = observedPath(candidate, repoRoot)
82
+ if (safe) reads.push(safe)
83
+ }
84
+ if (name === 'bash' || name === 'exec_command' || name === 'command_execution') {
85
+ const command = safeCheck(block.input?.command ?? block.input?.cmd, repoRoot)
86
+ if (block.id && command) checks.set(block.id, command)
87
+ }
88
+ }
89
+ } else if (event?.kind === 'tool_result' && event.toolUseId && event.isError !== true) {
90
+ successful.add(event.toolUseId)
91
+ }
92
+ }
93
+ return {
94
+ declared: classified.declared,
95
+ read: uniqueSorted(reads),
96
+ changed: classified.changed,
97
+ verified: uniqueSorted([...checks].filter(([id]) => successful.has(id)).map(([, command]) => command), MAX_CHECKS),
98
+ unexpected: classified.unexpected,
99
+ held: classified.held,
100
+ }
101
+ }
102
+
103
+ export function normalizeFlowScopeEvidence(value) {
104
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
105
+ try {
106
+ const classified = classifyFlowScope({ declared: value.declared, changed: value.changed })
107
+ const read = uniqueSorted((Array.isArray(value.read) ? value.read : []).map((item) => repoPath(item)).filter(Boolean))
108
+ const verified = uniqueSorted((Array.isArray(value.verified) ? value.verified : [])
109
+ .map((item) => typeof item === 'string' && item.length <= MAX_CHECK && !SECRET.test(item) && VERIFY_COMMAND.test(item) ? item.trim() : null)
110
+ .filter(Boolean), MAX_CHECKS)
111
+ if (Boolean(value.held) !== classified.held) return null
112
+ if (JSON.stringify(uniqueSorted(Array.isArray(value.unexpected) ? value.unexpected : [])) !== JSON.stringify(classified.unexpected)) return null
113
+ return { declared: classified.declared, read, changed: classified.changed, verified, unexpected: classified.unexpected, held: classified.held }
114
+ } catch {
115
+ return null
116
+ }
117
+ }
@@ -51,9 +51,23 @@ export const FLOW_CONTRACT_LIMITS = Object.freeze({
51
51
  maxNonGoalChars: 240,
52
52
  maxBaselineGateChars: 600,
53
53
  maxBaselineEvidenceChars: 1400,
54
+ maxScopePaths: 32,
55
+ maxScopePathChars: 180,
54
56
  })
55
57
 
56
58
  const BASELINE_NEGATIVE_SIGNAL = /\b(fail(?:s|ed|ing)?|absent|missing|not\s+(?:present|implemented|available|found)|does\s+not|404|empty)\b/i
59
+ const CONTRACT_DIGEST = /^[a-f0-9]{64}$/
60
+ const SHA256_K = [
61
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
62
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
63
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
64
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
65
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
66
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
67
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
68
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
69
+ ]
70
+ const rotateRight = (value, bits) => (value >>> bits) | (value << (32 - bits))
57
71
  const hasDisallowedControl = (value) => {
58
72
  for (const char of value) {
59
73
  const code = char.charCodeAt(0)
@@ -78,25 +92,153 @@ function boundedLine (value, label, max, { required = false } = {}) {
78
92
  return text
79
93
  }
80
94
 
95
+ function normalizeScopePath (value) {
96
+ if (typeof value !== 'string') return null
97
+ const text = value.trim().replaceAll('\\', '/')
98
+ if (!text || text.length > FLOW_CONTRACT_LIMITS.maxScopePathChars || text.startsWith('/') || /^[A-Za-z]:\//.test(text)) return null
99
+ const directoryPattern = text.endsWith('/**')
100
+ if (text.includes('*') && !directoryPattern) return null
101
+ const bare = directoryPattern ? text.slice(0, -3) : text
102
+ if (!bare || bare.endsWith('/') || bare.split('/').some((part) => !part || part === '.' || part === '..')) return null
103
+ if (/(?:^|\/)(?:\.env(?:\.|$)|\.git(?:\/|$)|\.thinkpool(?:\/|$)|node_modules(?:\/|$))/.test(bare)) return null
104
+ const last = bare.split('/').at(-1)
105
+ if (!directoryPattern && !last.includes('.') && !/^(?:Dockerfile|Makefile|LICENSE|README)$/i.test(last)) return null
106
+ return directoryPattern ? `${bare}/**` : bare
107
+ }
108
+
109
+ export function normalizeTaskScopePaths (value, { fallbackScope = '', required = true } = {}) {
110
+ let values = value
111
+ if (values == null && fallbackScope) {
112
+ const inferred = normalizeScopePath(fallbackScope)
113
+ if (inferred) values = [inferred]
114
+ }
115
+ if (values == null) values = []
116
+ if (!Array.isArray(values)) throw new Error('scopePaths must be an array')
117
+ if (values.length > FLOW_CONTRACT_LIMITS.maxScopePaths) throw new Error(`scopePaths exceeds ${FLOW_CONTRACT_LIMITS.maxScopePaths} entries`)
118
+ const out = []
119
+ for (const item of values) {
120
+ const safe = normalizeScopePath(item)
121
+ if (!safe) throw new Error(`invalid scope path: ${JSON.stringify(item)}`)
122
+ if (!out.includes(safe)) out.push(safe)
123
+ }
124
+ if (required && out.length === 0) throw new Error('machine-readable scopePaths are required for builder/fix/scaffold tasks')
125
+ return out.sort()
126
+ }
127
+
81
128
  export function normalizeBaselineEvidence (value) {
82
129
  return boundedLine(value, 'baseline evidence receipt', FLOW_CONTRACT_LIMITS.maxBaselineEvidenceChars, { required: true })
83
130
  }
84
131
 
132
+ // This is deliberately a small synchronous SHA-256 implementation instead of a
133
+ // Node crypto import or WebCrypto: the task graph must stay byte-identical and run
134
+ // in both the bridge's Node runtime and the browser bundle.
135
+ function sha256Hex (message) {
136
+ const source = new TextEncoder().encode(message)
137
+ const totalLength = Math.ceil((source.length + 9) / 64) * 64
138
+ const bytes = new Uint8Array(totalLength)
139
+ bytes.set(source)
140
+ bytes[source.length] = 0x80
141
+ let bitLength = BigInt(source.length) * 8n
142
+ for (let index = totalLength - 1; index >= totalLength - 8; index--) {
143
+ bytes[index] = Number(bitLength & 0xffn)
144
+ bitLength >>= 8n
145
+ }
146
+
147
+ const hash = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]
148
+ const words = new Uint32Array(64)
149
+ for (let offset = 0; offset < bytes.length; offset += 64) {
150
+ for (let index = 0; index < 16; index++) {
151
+ const start = offset + (index * 4)
152
+ words[index] = ((bytes[start] << 24) | (bytes[start + 1] << 16) | (bytes[start + 2] << 8) | bytes[start + 3]) >>> 0
153
+ }
154
+ for (let index = 16; index < 64; index++) {
155
+ const low = rotateRight(words[index - 15], 7) ^ rotateRight(words[index - 15], 18) ^ (words[index - 15] >>> 3)
156
+ const high = rotateRight(words[index - 2], 17) ^ rotateRight(words[index - 2], 19) ^ (words[index - 2] >>> 10)
157
+ words[index] = (words[index - 16] + low + words[index - 7] + high) >>> 0
158
+ }
159
+
160
+ let [a, b, c, d, e, f, g, h] = hash
161
+ for (let index = 0; index < 64; index++) {
162
+ const sigma1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25)
163
+ const choose = (e & f) ^ (~e & g)
164
+ const temp1 = (h + sigma1 + choose + SHA256_K[index] + words[index]) >>> 0
165
+ const sigma0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22)
166
+ const majority = (a & b) ^ (a & c) ^ (b & c)
167
+ const temp2 = (sigma0 + majority) >>> 0
168
+ h = g
169
+ g = f
170
+ f = e
171
+ e = (d + temp1) >>> 0
172
+ d = c
173
+ c = b
174
+ b = a
175
+ a = (temp1 + temp2) >>> 0
176
+ }
177
+ hash[0] = (hash[0] + a) >>> 0
178
+ hash[1] = (hash[1] + b) >>> 0
179
+ hash[2] = (hash[2] + c) >>> 0
180
+ hash[3] = (hash[3] + d) >>> 0
181
+ hash[4] = (hash[4] + e) >>> 0
182
+ hash[5] = (hash[5] + f) >>> 0
183
+ hash[6] = (hash[6] + g) >>> 0
184
+ hash[7] = (hash[7] + h) >>> 0
185
+ }
186
+ return hash.map((word) => word.toString(16).padStart(8, '0')).join('')
187
+ }
188
+
189
+ // The acceptance pack belongs to the builder target. A review task copies that
190
+ // target's digest, so its inherited target is bound without re-hashing reviewer
191
+ // prose or later baseline evidence receipts.
192
+ function acceptancePack ({ key, title, scope, scopePaths, acceptance, sliceType, nonGoals, baselineGate, inheritedReviewerTarget = key, version = 2 }) {
193
+ const pack = {
194
+ version,
195
+ task: { key, title, scope, acceptance, sliceType },
196
+ nonGoals: [...nonGoals].sort(),
197
+ baselineGate,
198
+ inheritedReviewerTarget,
199
+ }
200
+ if (version >= 2) pack.task.scopePaths = normalizeTaskScopePaths(scopePaths, { fallbackScope: scope })
201
+ return pack
202
+ }
203
+
204
+ export function acceptancePackDigest (input) {
205
+ return sha256Hex(JSON.stringify(acceptancePack(input)))
206
+ }
207
+
85
208
  function contractSource (task) {
86
209
  const source = task?.contract && typeof task.contract === 'object' && !Array.isArray(task.contract)
87
210
  ? { ...task.contract }
88
211
  : {}
89
212
  if (source.nonGoals === undefined) source.nonGoals = task?.nonGoals ?? task?.non_goals
90
213
  if (source.baseline === undefined) source.baseline = task?.baseline ?? task?.baselineGate ?? task?.baseline_gate
214
+ if (source.scopePaths === undefined) source.scopePaths = task?.scopePaths ?? task?.scope_paths
91
215
  return source
92
216
  }
93
217
 
218
+ function normalizeProvidedContractDigest (source) {
219
+ if (source.digest === undefined) return null
220
+ if (typeof source.digest !== 'string' || !CONTRACT_DIGEST.test(source.digest)) {
221
+ throw new Error('contract digest must be a lowercase SHA-256 hex string')
222
+ }
223
+ return source.digest
224
+ }
225
+
226
+ function normalizeProvidedAcceptancePack (source) {
227
+ if (source.acceptancePack === undefined) return null
228
+ if (!source.acceptancePack || typeof source.acceptancePack !== 'object' || Array.isArray(source.acceptancePack)) {
229
+ throw new Error('contract acceptance pack must be an object')
230
+ }
231
+ return source.acceptancePack
232
+ }
233
+
94
234
  // Strict for new terminal submissions. `legacy:true` is restore-only: old persisted
95
235
  // plans predate this contract and must remain runnable, but they can never be used to
96
236
  // submit a new incomplete plan.
97
237
  export function normalizeTaskContract (task, { sliceType, legacy = false } = {}) {
98
238
  const source = contractSource(task)
99
- if (sliceType === SLICE_TYPE.review) return null
239
+ const digest = normalizeProvidedContractDigest(source)
240
+ const sealedPack = normalizeProvidedAcceptancePack(source)
241
+ if (sliceType === SLICE_TYPE.review) return legacy ? null : { digest, acceptancePack: sealedPack }
100
242
  const required = !legacy
101
243
  const rawNonGoals = source.nonGoals
102
244
  const values = rawNonGoals == null ? [] : (Array.isArray(rawNonGoals) ? rawNonGoals : [rawNonGoals])
@@ -112,7 +254,100 @@ export function normalizeTaskContract (task, { sliceType, legacy = false } = {})
112
254
  const evidence = baselineObject?.evidence == null || baselineObject?.evidence === ''
113
255
  ? ''
114
256
  : normalizeBaselineEvidence(baselineObject.evidence)
115
- return { nonGoals, baseline: gate ? { gate, evidence } : null }
257
+ const scopePaths = normalizeTaskScopePaths(source.scopePaths, { fallbackScope: task?.scope ?? task?.description ?? '', required })
258
+ return { nonGoals, baseline: gate ? { gate, evidence } : null, scopePaths, digest, acceptancePack: sealedPack }
259
+ }
260
+
261
+ function bindContractDigest (task, { legacy = false } = {}) {
262
+ const sealedPack = acceptancePack({
263
+ key: task.key,
264
+ title: task.title,
265
+ scope: task.scope,
266
+ scopePaths: task.contract.scopePaths,
267
+ acceptance: task.acceptance,
268
+ sliceType: task.sliceType,
269
+ nonGoals: task.contract.nonGoals,
270
+ baselineGate: task.contract.baseline?.gate || '',
271
+ inheritedReviewerTarget: task.key,
272
+ version: legacy ? 1 : 2,
273
+ })
274
+ const digest = sha256Hex(JSON.stringify(sealedPack))
275
+ if (task.contract.digest && task.contract.digest !== digest) throw new Error(`task "${task.key}" contract digest does not match its acceptance pack`)
276
+ if (task.contract.acceptancePack && JSON.stringify(task.contract.acceptancePack) !== JSON.stringify(sealedPack)) {
277
+ throw new Error(`task "${task.key}" sealed acceptance pack does not match its task fields`)
278
+ }
279
+ task.contract.digest = digest
280
+ task.contract.acceptancePack = sealedPack
281
+ }
282
+
283
+ // The browser persists normalized task rows before approval, then later sends a
284
+ // ready row back to the bridge for dispatch. Recompute the seal at that final
285
+ // boundary so changed acceptance/scope/non-goals can never ride beside an old hash.
286
+ export function validateTaskContractSeal (task, { legacy = false } = {}) {
287
+ const contract = task?.contract
288
+ if (!contract?.digest && legacy) return true
289
+ if (!contract || typeof contract !== 'object' || !contract.digest || !contract.acceptancePack) {
290
+ throw new Error('task is missing its sealed acceptance contract')
291
+ }
292
+ const pack = contract.acceptancePack
293
+ const packTask = pack?.task
294
+ if (!packTask || typeof packTask !== 'object' || !Array.isArray(pack.nonGoals)) {
295
+ throw new Error('task sealed acceptance pack is malformed')
296
+ }
297
+ const canonicalPack = acceptancePack({
298
+ key: packTask.key,
299
+ title: packTask.title,
300
+ scope: packTask.scope,
301
+ scopePaths: packTask.scopePaths,
302
+ acceptance: packTask.acceptance,
303
+ sliceType: packTask.sliceType,
304
+ nonGoals: pack.nonGoals,
305
+ baselineGate: pack.baselineGate,
306
+ inheritedReviewerTarget: pack.inheritedReviewerTarget,
307
+ version: pack.version === 1 ? 1 : 2,
308
+ })
309
+ if (JSON.stringify(pack) !== JSON.stringify(canonicalPack)) throw new Error('task sealed acceptance pack is not canonical')
310
+ if (acceptancePackDigest({
311
+ key: packTask.key,
312
+ title: packTask.title,
313
+ scope: packTask.scope,
314
+ scopePaths: packTask.scopePaths,
315
+ acceptance: packTask.acceptance,
316
+ sliceType: packTask.sliceType,
317
+ nonGoals: pack.nonGoals,
318
+ baselineGate: pack.baselineGate,
319
+ inheritedReviewerTarget: pack.inheritedReviewerTarget,
320
+ version: pack.version === 1 ? 1 : 2,
321
+ }) !== contract.digest) throw new Error('task acceptance digest does not match its sealed pack')
322
+
323
+ const sliceType = task.sliceType ?? task.slice_type
324
+ const key = task.key ?? task.task_key
325
+ const deps = Array.isArray(task.deps) ? task.deps : []
326
+ if (sliceType === SLICE_TYPE.review) {
327
+ if (contract.inheritedFrom !== packTask.key || deps.length !== 1 || deps[0] !== packTask.key) {
328
+ throw new Error('review task no longer targets its sealed builder')
329
+ }
330
+ if (pack.inheritedReviewerTarget !== packTask.key || task.acceptance !== packTask.acceptance) {
331
+ throw new Error(`review task "${key}" changed after its builder acceptance pack was sealed`)
332
+ }
333
+ return true
334
+ }
335
+ const livePack = acceptancePack({
336
+ key,
337
+ title: task.title,
338
+ scope: task.scope ?? '',
339
+ scopePaths: contract.scopePaths,
340
+ acceptance: task.acceptance ?? '',
341
+ sliceType,
342
+ nonGoals: contract.nonGoals || [],
343
+ baselineGate: contract.baseline?.gate || '',
344
+ inheritedReviewerTarget: key,
345
+ version: pack.version === 1 ? 1 : 2,
346
+ })
347
+ if (JSON.stringify(livePack) !== JSON.stringify(canonicalPack)) {
348
+ throw new Error(`task "${key}" changed after its acceptance pack was sealed`)
349
+ }
350
+ return true
116
351
  }
117
352
 
118
353
  // A FlowTask slice. Each is RUNNABLE — a lane can build + run + self-correct it in
@@ -244,6 +479,9 @@ export function normalizePlanOutput (raw, { legacy = false } = {}) {
244
479
  })
245
480
  })
246
481
  validateDag(tasks)
482
+ for (const task of tasks) {
483
+ if (task.sliceType !== SLICE_TYPE.review) bindContractDigest(task, { legacy })
484
+ }
247
485
  for (const task of tasks) {
248
486
  if (task.sliceType !== SLICE_TYPE.review) continue
249
487
  // New review tasks verify exactly one builder contract. Legacy persisted reviews
@@ -252,11 +490,21 @@ export function normalizePlanOutput (raw, { legacy = false } = {}) {
252
490
  if (task.deps.length !== 1) throw new Error(`review task "${task.key}" must depend on exactly one builder task to inherit its contract`)
253
491
  const target = tasks.find((candidate) => candidate.key === task.deps[0])
254
492
  if (!target || target.sliceType === SLICE_TYPE.review) throw new Error(`review task "${task.key}" must target a builder/fix/scaffold task`)
493
+ if (task.contract.digest && task.contract.digest !== target.contract.digest) {
494
+ throw new Error(`review task "${task.key}" contract digest does not match target "${target.key}"`)
495
+ }
255
496
  task.contract = {
256
497
  nonGoals: [...(target.contract?.nonGoals || [])],
257
498
  baseline: target.contract?.baseline ? { ...target.contract.baseline } : null,
499
+ scopePaths: [...(target.contract?.scopePaths || [])],
258
500
  inheritedFrom: target.key,
501
+ digest: target.contract.digest,
502
+ acceptancePack: target.contract.acceptancePack,
259
503
  }
504
+ // Reviewer-authored prose is not an authority boundary. Normalize the live
505
+ // review row to the builder's sealed acceptance so dispatch and the prompt
506
+ // cannot silently substitute a weaker criterion.
507
+ task.acceptance = target.contract.acceptancePack.task.acceptance
260
508
  }
261
509
  return { summary, tasks }
262
510
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.361",
3
+ "version": "0.7.363",
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,9 @@
93
93
  "design-edit.mjs",
94
94
  "design-source-contract.mjs",
95
95
  "flow-review.mjs",
96
+ "flow-receipt.mjs",
97
+ "flow-scope-evidence.mjs",
98
+ "past-work-search.mjs",
96
99
  "review-check.mjs",
97
100
  "flow-review-gate.mjs",
98
101
  "flow-review-reflect.mjs",
@@ -0,0 +1,105 @@
1
+ const MAX_QUERY = 160
2
+ const MAX_RESULTS = 8
3
+ const MAX_RECORDS = 48
4
+ const MAX_EVENTS = 800
5
+ const MAX_EXCERPT = 260
6
+ 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}|npm_[a-z0-9]{24,}|bearer\s+[a-z0-9._-]{8,}|(?:api[_-]?key|access[_-]?token|auth(?:orization)?|token|secret)\s*[=:]\s*\S+)/i
7
+ const PRIVATE_WORDS = /(?:system\s+prompt|hidden\s+reasoning|chain[ -]of[ -]thought|\bpassword\b|private[_ -]?key|\.env\b)/i
8
+ const HOST_PATH = /(?:^|[\s=("'`])(?:~\/|\/(?:Users|home|var|tmp|private|workspace)\/|[A-Za-z]:\\|\\\\)[^\s)"'`]*/i
9
+
10
+ function textContent(content) {
11
+ if (typeof content === 'string') return content
12
+ if (!Array.isArray(content)) return ''
13
+ return content.map((item) => typeof item === 'string' ? item : item?.text || '').join(' ')
14
+ }
15
+
16
+ function safeExcerpt(value) {
17
+ if (typeof value !== 'string') return null
18
+ const line = value.replace(/\s+/g, ' ').trim()
19
+ if (!line || SECRET_VALUE.test(line) || PRIVATE_WORDS.test(line) || HOST_PATH.test(line)) return null
20
+ return line.slice(0, MAX_EXCERPT)
21
+ }
22
+
23
+ function eventCandidates(event) {
24
+ if (!event || typeof event !== 'object') return []
25
+ if (event.kind === 'flow-review-receipt') {
26
+ const scope = event.scope
27
+ return [
28
+ `Flow ${event.outcome || 'review'}: ${event.taskKey || ''}`,
29
+ ...(event.checks || []).map((line) => `Check: ${line}`),
30
+ ...(event.reasons || []).map((line) => `Finding: ${line}`),
31
+ ...((scope?.changed || []).map((line) => `Edited: ${line}`)),
32
+ ...((scope?.unexpected || []).map((line) => `Unexpected: ${line}`)),
33
+ ]
34
+ }
35
+ if (event.kind === 'assistant') {
36
+ const out = []
37
+ for (const block of event.blocks || []) {
38
+ if (block?.type === 'text') out.push(block.text)
39
+ if (block?.type === 'tool_use') {
40
+ const name = String(block.name || '')
41
+ if (/^(?:bash|exec_command|command_execution)$/i.test(name)) {
42
+ const command = block.input?.command ?? block.input?.cmd
43
+ if (command) out.push(`Command: ${command}`)
44
+ }
45
+ }
46
+ }
47
+ return out
48
+ }
49
+ if (event.kind === 'you') return [event.text]
50
+ if (event.kind === 'tool_result') return [`Result: ${textContent(event.content)}`]
51
+ if (event.kind === 'result') return [event.resultText]
52
+ return []
53
+ }
54
+
55
+ function queryParts(query) {
56
+ const text = String(query || '').replace(/\s+/g, ' ').trim()
57
+ if (text.length < 2 || text.length > MAX_QUERY) throw new TypeError(`past-work query must contain 2-${MAX_QUERY} characters`)
58
+ if (SECRET_VALUE.test(text) || PRIVATE_WORDS.test(text) || HOST_PATH.test(text)) return null
59
+ return { text, lower: text.toLowerCase(), tokens: text.toLowerCase().split(/[^a-z0-9._/-]+/).filter((token) => token.length > 1) }
60
+ }
61
+
62
+ export function searchPastWork(records, query, { room, limit = MAX_RESULTS } = {}) {
63
+ const parsed = queryParts(query)
64
+ if (!parsed) return []
65
+ const boundedLimit = Math.max(1, Math.min(MAX_RESULTS, Number(limit) || MAX_RESULTS))
66
+ const results = []
67
+ for (const record of (Array.isArray(records) ? records : []).slice(0, MAX_RECORDS)) {
68
+ const terminal = safeExcerpt(record?.name) || 'Unnamed terminal'
69
+ const events = Array.isArray(record?.log) ? record.log.slice(-MAX_EVENTS) : []
70
+ for (const event of events) {
71
+ for (const raw of eventCandidates(event)) {
72
+ const excerpt = safeExcerpt(raw)
73
+ if (!excerpt) continue
74
+ const lower = excerpt.toLowerCase()
75
+ const tokenHits = parsed.tokens.filter((token) => lower.includes(token)).length
76
+ if (!lower.includes(parsed.lower) && (!parsed.tokens.length || tokenHits !== parsed.tokens.length)) continue
77
+ results.push({
78
+ room: String(room || '').trim() || 'current',
79
+ terminal,
80
+ terminalId: String(record?.id || '').slice(0, 80),
81
+ ts: Number(event?.ts) || Number(record?.savedAt) || 0,
82
+ seq: Number.isFinite(Number(event?.seq)) ? Number(event.seq) : null,
83
+ excerpt,
84
+ score: (lower.includes(parsed.lower) ? 10 : 0) + tokenHits + (excerpt.startsWith('Command:') ? 0 : 1),
85
+ })
86
+ }
87
+ }
88
+ }
89
+ return results
90
+ .sort((a, b) => b.score - a.score || b.ts - a.ts)
91
+ .slice(0, boundedLimit)
92
+ .map(({ score, ...result }) => result)
93
+ }
94
+
95
+ export function formatPastWorkSearch(results, query) {
96
+ const safeQuery = safeExcerpt(String(query || '').trim())
97
+ if (!results.length) return safeQuery
98
+ ? `No cited past work matched “${safeQuery}” in this room.`
99
+ : 'No cited past work matched that privacy-filtered query in this room.'
100
+ return results.map((result, index) => {
101
+ const date = result.ts ? new Date(result.ts).toISOString().slice(0, 10) : 'date unavailable'
102
+ const event = result.seq == null ? 'saved transcript' : `event ${result.seq}`
103
+ return `${index + 1}. ${result.excerpt}\n [Room ${result.room} · ${result.terminal} · ${date} · ${event}]`
104
+ }).join('\n')
105
+ }
package/session-store.mjs CHANGED
@@ -426,6 +426,28 @@ export function loadAll(room) {
426
426
  return listRecs(room).sort((a, b) => (a.savedAt || 0) - (b.savedAt || 0))
427
427
  }
428
428
 
429
+ // Closed terminal snapshots stay recoverable under .archive/. Past-work search
430
+ // reads only these already-bounded snapshots, never the unbounded JSONL logs.
431
+ export function loadArchived(room, limit = 40) {
432
+ try {
433
+ return fs.readdirSync(archiveDir(room))
434
+ .map((name) => {
435
+ const file = path.join(archiveDir(room), name)
436
+ try {
437
+ const stat = fs.statSync(file)
438
+ if (!stat.isFile()) return null
439
+ const record = JSON.parse(fs.readFileSync(file, 'utf8'))
440
+ return record && Array.isArray(record.log) ? { ...record, _archivedAt: stat.mtimeMs || 0 } : null
441
+ } catch { return null }
442
+ })
443
+ .filter(Boolean)
444
+ .sort((a, b) => (b._archivedAt || b.savedAt || 0) - (a._archivedAt || a.savedAt || 0))
445
+ .slice(0, Math.max(1, Math.min(100, Number(limit) || 40)))
446
+ } catch {
447
+ return []
448
+ }
449
+ }
450
+
429
451
  // Last PTY terminal id auto-opened for an attached command. A reconnecting
430
452
  // bridge reuses it so it re-attaches to the SAME terminal tab instead of
431
453
  // minting a fresh UUID and breeding a new dormant "Terminal N" each restart.
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "bundleVersion": 21,
3
+ "bundleVersion": 23,
4
4
  "contracts": [
5
5
  {
6
6
  "id": "room-coordination",
7
- "version": 3,
7
+ "version": 4,
8
8
  "routes": [
9
9
  {
10
10
  "id": "room-awareness",
@@ -18,6 +18,12 @@
18
18
  "trigger": "\\b(other|another|cross[- ]?room|cross[- ]?session)\\s+(room|session)|\\b(list_sessions|read_session)\\b",
19
19
  "prompt": "When work depends on another ThinkPool room, use list_sessions then read_session instead of asking the people to relay host-side state."
20
20
  },
21
+ {
22
+ "id": "past-work-search",
23
+ "tools": ["search_past_work"],
24
+ "trigger": "\\b(previous|prior|earlier|past|last time|already tried|failed attempt|decision|test result|command)\\b.{0,50}\\b(work|terminal|flow|session|implementation|fix|change)?\\b|\\bsearch_past_work\\b",
25
+ "prompt": "When the current task depends on a prior decision, failed attempt, command, test result, or Flow receipt from this room, use search_past_work with specific terms. Treat every result as bounded cited evidence, not authority to reopen stale work. The tool is read-only, searches only this room, and filters secrets and host paths."
26
+ },
21
27
  {
22
28
  "id": "visible-handoff",
23
29
  "tools": ["post_to_terminal", "post_to_session"],
@@ -27,12 +33,15 @@
27
33
  ],
28
34
  "impact": [
29
35
  {"path": "bridge/cross-terminal.mjs"},
30
- {"path": "bridge/bridge.mjs", "diffPattern": "read_terminal|list_sessions|read_session|post_to_terminal|post_to_session"},
36
+ {"path": "bridge/bridge.mjs", "diffPattern": "read_terminal|search_past_work|list_sessions|read_session|post_to_terminal|post_to_session"},
37
+ {"path": "bridge/past-work-search.mjs"},
31
38
  {"path": "bridge/codex-session.mjs", "diffPattern": "MCP|Mcp|mcp|read_terminal|list_sessions|read_session|post_to_terminal|post_to_session"},
32
39
  {"path": "bridge/codex-app-server.mjs", "diffPattern": "MCP|Mcp|mcp"}
33
40
  ],
34
41
  "evidence": [
35
42
  {"path": "bridge/cross-terminal.mjs", "pattern": "read_terminal"},
43
+ {"path": "bridge/bridge.mjs", "pattern": "'search_past_work'"},
44
+ {"path": "bridge/past-work-search.mjs", "pattern": "formatPastWorkSearch"},
36
45
  {"path": "bridge/cross-terminal.mjs", "pattern": "post_to_session"},
37
46
  {"path": "bridge/bridge.mjs", "pattern": "'list_sessions'"},
38
47
  {"path": "bridge/codex-session.mjs", "pattern": "waitForMcpServer"},
@@ -130,25 +139,30 @@
130
139
  },
131
140
  {
132
141
  "id": "flow-completion",
133
- "version": 3,
142
+ "version": 5,
134
143
  "routes": [
135
144
  {
136
145
  "id": "flow-completion",
137
146
  "tools": ["submit_flow_plan", "mark_flow_done", "submit_flow_review", "read_review_file", "run_review_check"],
138
147
  "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."
148
+ "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, machine-readable repo-relative scopePaths, 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, derives edited files from Git, and refuses unexpected touches. The exact dependent reviewer inherits the contract and the bridge recomputes the scope footprint from the immutable candidate. A conclusive pass binds one bounded terminal receipt to the exact reviewed candidate SHA, acceptance digest, and held scope; missing or drifted 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
149
  }
141
150
  ],
142
151
  "impact": [
143
152
  {"path": "bridge/bridge.mjs", "diffPattern": "submit_flow_plan|mark_flow_done|submit_flow_review|read_review_file|run_review_check"},
144
153
  {"path": "bridge/flow-review.mjs"},
154
+ {"path": "bridge/flow-receipt.mjs"},
155
+ {"path": "bridge/flow-scope-evidence.mjs"},
145
156
  {"path": "bridge/flow-review-gate.mjs"},
146
157
  {"path": "bridge/flow-task-graph.mjs"}
147
158
  ],
148
159
  "evidence": [
149
160
  {"path": "bridge/bridge.mjs", "pattern": "'submit_flow_plan'"},
150
161
  {"path": "bridge/bridge.mjs", "pattern": "'mark_flow_done'"},
151
- {"path": "bridge/bridge.mjs", "pattern": "'submit_flow_review'"}
162
+ {"path": "bridge/bridge.mjs", "pattern": "'submit_flow_review'"},
163
+ {"path": "bridge/flow-task-graph.mjs", "pattern": "acceptancePackDigest"},
164
+ {"path": "bridge/flow-receipt.mjs", "pattern": "Passing flow review receipt requires a held scope footprint"},
165
+ {"path": "bridge/flow-scope-evidence.mjs", "pattern": "classifyFlowScope"}
152
166
  ]
153
167
  },
154
168
  {