thinkpool-pair 0.7.362 → 0.7.364

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
@@ -98,6 +98,8 @@ function stopFlowPreviews (flowId, laneId = null) {
98
98
  }
99
99
  import { FLOW_REVIEWER_PROMPT, FLOW_CODEX_REVIEWER_PROMPT, revertLane, parseReviewVerdict, reviewVerdictToReflection } from './flow-review.mjs'
100
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'
101
103
  import { reviewGateDecision } from './flow-review-gate.mjs'
102
104
  import { IMMUTABLE_REVIEW_READ_TOOL_EXTRAS, UNSANDBOXED_REVIEW_CHECK_TOOL_EXTRAS, readReviewFile, runReviewCheck } from './review-check.mjs'
103
105
  import { pairAdjudicationPrompt, reviewReflectionDecision, REVIEW_DEFAULTS } from './flow-review-reflect.mjs'
@@ -125,14 +127,39 @@ const flowRedispatch = new Map()
125
127
  // wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
126
128
  // broadcasts; without persistent state the cap can never bite.
127
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
+ }
128
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'
129
154
  import { createStandalonePairResponder, standalonePairIdentity } from './direct-pair-room.mjs'
130
155
  import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
131
156
  import { supersedeDispatchLease } from './dispatch-lease.mjs'
132
157
  import { realtimeRecoveryDecision, turnInFlight } from './update-gate.mjs'
133
- import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage, acknowledgePendingScheduledOutcome, commitRecordedScheduledOutcome, deletePendingScheduledOutcome, loadPendingScheduledOutcome, loadPendingScheduledOutcomes, savePendingScheduledOutcome } from './session-store.mjs'
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'
134
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'
135
160
  import { createLatestReplayPump, requestedReplayIds } from './replay-transport.mjs'
161
+ import { createEventDeliveryQueue } from './event-delivery-queue.mjs'
162
+ import { formatMcpFlight, normalizeMcpFlight, recordMcpFlightEvent } from './mcp-flight-recorder.mjs'
136
163
  import { classifyCodeEvent, unknownCodeEventNotice } from './code-event-contract.mjs'
137
164
  import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
138
165
  import { finishScheduledRunWithRetry, pollDueScheduledRuns, sanitizeScheduledOutcomeIntent, scheduledOutcomeReconciliationComplete, scheduledRunDeadlineRequired, scheduledRunSpawnAdmission, scheduledRunsCapability, scheduledRunsEnabled } from './scheduled-runs.mjs'
@@ -1318,7 +1345,21 @@ const bcastAwait = async (event, payload, ch = channel) => {
1318
1345
  }
1319
1346
  } catch { return null /* offline — replay covers it */ }
1320
1347
  }
1321
- const bcast = (event, payload, ch = channel) => { void bcastAwait(event, payload, ch) }
1348
+ // Transcript frames are causally ordered. In particular, a small `result` must
1349
+ // never overtake a larger assistant/tool frame when the bridge falls back to
1350
+ // HTTP delivery or a socket is recovering. Retry the event at the head of this
1351
+ // stream until Realtime acknowledges it; the local JSONL archive is the crash
1352
+ // recovery backstop.
1353
+ const codeEventDelivery = createEventDeliveryQueue({
1354
+ send: ({ event, payload, ch }) => bcastAwait(event, payload, ch),
1355
+ })
1356
+ const bcast = (event, payload, ch = channel) => {
1357
+ if (event === 'code-event' && ch === channel) {
1358
+ codeEventDelivery.enqueue({ event, payload, ch })
1359
+ return
1360
+ }
1361
+ void bcastAwait(event, payload, ch)
1362
+ }
1322
1363
  const replayPump = createLatestReplayPump({
1323
1364
  send: ({ event, payload }) => bcastAwait(event, payload),
1324
1365
  })
@@ -2376,6 +2417,9 @@ function pushLog(entry, evt) {
2376
2417
  // stampEvent is idempotent, so the callers that already stamp are unaffected.
2377
2418
  stampStructuredTurn(entry, evt)
2378
2419
  stampEvent(evt)
2420
+ // Observe the normalized runtime event, never the SDK/provider wire payload.
2421
+ // The recorder retains only tool identity, timing, outcome, and argument count.
2422
+ entry.mcpFlight = recordMcpFlightEvent(entry.mcpFlight, evt, { now: evt.ts })
2379
2423
  entry.lastActionAt = evt.ts
2380
2424
  entry.lastEvent = evt
2381
2425
  // Only TRANSCRIPT events consume a seq (seqable) — replay-chrome kinds the web
@@ -2536,7 +2580,7 @@ function worktreeSnapshot(cwd) {
2536
2580
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
2537
2581
  // persist to the host file; tool calls round-trip through the perm card; the
2538
2582
  // rolling log replays to joiners and survives bridge restarts (session-store).
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 }) {
2583
+ 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, mcpFlight, scheduleRunId, scheduleDeadlineAt, scheduleOutcomeRecorded, scheduleAdmissionLease }) {
2540
2584
  if (sessions.has(id)) return
2541
2585
  runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
2542
2586
  // Fail closed before exposing a native lane if its bridge semantic contract
@@ -2589,7 +2633,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2589
2633
  const restoredScheduleOutcome = restoredScheduleOutcomeCandidate?.runId === scheduleRunId
2590
2634
  ? restoredScheduleOutcomeCandidate
2591
2635
  : null
2592
- const entry = { cmd: runtime, runtime, kind: 'structured', log: restoredLog, pending: new Map(), receivedTurnCids: new Set(Array.isArray(receivedTurnCids) ? receivedTurnCids.filter(Boolean).slice(-1000) : []), session: null, recovered: false, commands: commandCatalogForRuntime(runtime, commands), mode, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes' ? (Array.isArray(models) && models.length ? models : hostHermesModels) : undefined,
2636
+ const entry = { cmd: runtime, runtime, kind: 'structured', log: restoredLog, pending: new Map(), receivedTurnCids: new Set(Array.isArray(receivedTurnCids) ? receivedTurnCids.filter(Boolean).slice(-1000) : []), mcpFlight: normalizeMcpFlight(mcpFlight), session: null, recovered: false, commands: commandCatalogForRuntime(runtime, commands), mode, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes' ? (Array.isArray(models) && models.length ? models : hostHermesModels) : undefined,
2593
2637
  // model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
2594
2638
  // chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
2595
2639
  // registered provider the SDK id is impersonated (see the onEvent guard below), so
@@ -2682,6 +2726,25 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2682
2726
  if (!reviewPass && entry.dispatchBaseSha && commitSha === entry.dispatchBaseSha) {
2683
2727
  return `Slice "${entry.flowTaskKey}" is not done: HEAD is still the dispatch base (${commitSha.slice(0, 8)}). Commit the verified implementation first.`
2684
2728
  }
2729
+ if (!reviewPass) {
2730
+ const dirty = worktreeChanges(entry.cwd || process.cwd())
2731
+ if (dirty == null) return `Slice "${entry.flowTaskKey}" is not done: the bridge could not verify a clean worktree.`
2732
+ if (dirty.length) return `Slice "${entry.flowTaskKey}" is not done: commit or remove every remaining worktree change before completion (${dirty.slice(0, 4).join(', ')}).`
2733
+ }
2734
+ let scopeEvidence = null
2735
+ if (!reviewPass && entry.flowTaskContract?.scopePaths?.length) {
2736
+ const changed = changedFilesBetween(entry.cwd || process.cwd(), entry.dispatchBaseSha, commitSha)
2737
+ if (!changed) return `Slice "${entry.flowTaskKey}" is not done: the bridge could not derive its Git scope footprint.`
2738
+ scopeEvidence = collectFlowScopeEvidence({
2739
+ log: entry.log,
2740
+ repoRoot: entry.cwd || process.cwd(),
2741
+ declared: entry.flowTaskContract.scopePaths,
2742
+ changed,
2743
+ })
2744
+ if (!scopeEvidence.held) {
2745
+ 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.`
2746
+ }
2747
+ }
2685
2748
  let previewUrl = null
2686
2749
  try { const pv = await startPreview({ dir: entry.cwd || process.cwd(), id: `lane:${entry.flowSessionId}:${id}` }); previewUrl = pv.url } catch { /* preview best-effort */ }
2687
2750
  // S1 (context-offload) — digest THIS closed slice into the durable store so the next
@@ -2712,8 +2775,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2712
2775
  process.stderr.write(`\n ${A.dim}◆ flow digest skip ${entry.flowTaskKey} — ${e?.message || e}${A.rst}\n`)
2713
2776
  }
2714
2777
  const completedContract = requiresBaselineReceipt
2715
- ? { ...entry.flowTaskContract, baseline: { ...entry.flowTaskContract.baseline, evidence: baselineReceipt } }
2716
- : entry.flowTaskContract || null
2778
+ ? { ...entry.flowTaskContract, baseline: { ...entry.flowTaskContract.baseline, evidence: baselineReceipt }, ...(scopeEvidence ? { scopeEvidence } : {}) }
2779
+ : entry.flowTaskContract
2780
+ ? { ...entry.flowTaskContract, ...(scopeEvidence ? { scopeEvidence } : {}) }
2781
+ : null
2717
2782
  bcast('flow-task-done', { term: id, flowId: entry.flowSessionId, taskKey: entry.flowTaskKey, laneId: id, commitSha, previewUrl, contract: completedContract, ...(reviewPass ? { reviewAction: 'pass' } : {}) }, flowChannel)
2718
2783
  process.stderr.write(`\n ${A.cyan}◆ flow slice done — ${entry.flowTaskKey} (${commitSha ? commitSha.slice(0, 8) : 'no commit'})${previewUrl ? ` · preview ${previewUrl}` : ''}${A.rst}\n`)
2719
2784
  entry.flowDone = true // FL-B3 — retire immediately so it drops from the ≤8 lane cap
@@ -2802,6 +2867,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2802
2867
  rounds: round,
2803
2868
  candidateSha: targetSnapshot?.sha,
2804
2869
  acceptanceDigest,
2870
+ scope: targetSnapshot?.scopeEvidence,
2805
2871
  reasons: receiptReasons,
2806
2872
  })
2807
2873
  } catch (error) {
@@ -2816,6 +2882,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2816
2882
  rounds: round,
2817
2883
  candidateSha: targetSnapshot?.sha,
2818
2884
  acceptanceDigest,
2885
+ scope: targetSnapshot?.scopeEvidence,
2819
2886
  reasons: receiptReasons,
2820
2887
  })
2821
2888
  }
@@ -2942,7 +3009,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2942
3009
  // restart. Without this, sessionData omitted it → on restart the resumed session
2943
3010
  // re-launched on the host default (Opus) regardless of the last switch, and the
2944
3011
  // switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
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) })
3012
+ 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), mcpFlight: entry.mcpFlight })
2946
3013
  const persist = () => saveSession(room, id, sessionData())
2947
3014
  // Synchronous flush of this session's record. Used on open (so a brand-new session
2948
3015
  // has a file under its id BEFORE its first event — surviving a restart inside the
@@ -3081,6 +3148,12 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3081
3148
  name: 'thinkpool',
3082
3149
  version: '1.0.0',
3083
3150
  tools: [
3151
+ ...(!entry.flowRole && entry.sliceType !== 'review' ? [tool(
3152
+ 'read_mcp_flight_recorder',
3153
+ 'Read the privacy-filtered MCP flight recorder for THIS terminal. Use it to diagnose a tool path or audit recent MCP activity. It reports only the MCP tool name, start time, duration, and outcome; it never records or returns arguments, result bodies, prompts, secrets, or host paths.',
3154
+ { limit: z.number().int().min(1).max(100).optional().describe('maximum recent calls to return (default 40)') },
3155
+ async (args) => ({ content: [{ type: 'text', text: formatMcpFlight(entry.mcpFlight, { limit: args?.limit }) }] }),
3156
+ )] : []),
3084
3157
  ...(runtime === 'hermes' && hermesRoleFor({ flowRole: entry.flowRole, sliceType: entry.sliceType }) === 'ordinary' ? [tool(
3085
3158
  'request_user_input',
3086
3159
  'Ask the people in this ThinkPool room one to three multiple-choice questions and wait for their answer. Use this for choices that genuinely block useful progress. Each question needs two or three mutually exclusive options; a free-text answer remains available in the room card.',
@@ -3148,6 +3221,44 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3148
3221
  return { content: [{ type: 'text', text }] }
3149
3222
  },
3150
3223
  ),
3224
+ ...(!entry.flowRole ? [tool(
3225
+ 'search_past_work',
3226
+ '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.',
3227
+ {
3228
+ query: z.string().min(2).max(160).describe('specific terms from the prior decision, failure, command, check, or feature'),
3229
+ limit: z.number().int().min(1).max(8).optional().describe('maximum cited hits; default 8'),
3230
+ },
3231
+ async (args) => {
3232
+ if (entry.pastWorkSearchTurnRev !== entry._turnRev) {
3233
+ entry.pastWorkSearchTurnRev = entry._turnRev
3234
+ entry.pastWorkSearchCount = 0
3235
+ }
3236
+ if ((entry.pastWorkSearchCount || 0) >= 3) {
3237
+ 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.' }] }
3238
+ }
3239
+ entry.pastWorkSearchCount = (entry.pastWorkSearchCount || 0) + 1
3240
+ const records = new Map()
3241
+ for (const record of loadArchived(room, 40)) {
3242
+ if (!record?.id || !Array.isArray(record.log)) continue
3243
+ records.set(record.id, { id: record.id, name: termNames[record.id] || record.name || 'Archived terminal', savedAt: record.savedAt || record._archivedAt, log: record.log })
3244
+ }
3245
+ for (const [terminalId, terminalEntry] of sessions) {
3246
+ records.set(terminalId, {
3247
+ id: terminalId,
3248
+ name: termNames[terminalId] || `Terminal ${String(terminalId).slice(0, 8)}`,
3249
+ savedAt: terminalEntry.openedAt || Date.now(),
3250
+ log: terminalEntry.log || [],
3251
+ })
3252
+ }
3253
+ let results
3254
+ try {
3255
+ results = searchPastWork([...records.values()], args?.query || '', { room, limit: args?.limit })
3256
+ } catch (error) {
3257
+ return { content: [{ type: 'text', text: `Past-work search rejected: ${error?.message || error}` }] }
3258
+ }
3259
+ return { content: [{ type: 'text', text: formatPastWorkSearch(results, args?.query || '') }] }
3260
+ },
3261
+ )] : []),
3151
3262
  // Tier 1 cross-ROOM peek — list_sessions / read_session. Read-only reach into the
3152
3263
  // account's OTHER rooms (this session's sibling SESSIONS, not just sibling terminals),
3153
3264
  // routed through the account supervisor over IPC (pairRequest). Auto-allowed by the
@@ -3528,7 +3639,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3528
3639
  // (slices whose deps just got satisfied).
3529
3640
  ...(entry.flowRole === 'builder' ? [tool(
3530
3641
  'mark_flow_done',
3531
- "ThinkPool Flow ONLY — call this ONCE when your slice is built, RUNS, and meets its acceptance criteria. For a gate-first task, pass `baselineEvidence`: the real, bounded pre-edit command/behavior receipt proving the assigned failure/absence. Never fabricate a RED sentence. Records your latest commit as the slice's atomic-revert target and signals the room that this slice is done (which unblocks slices that depended on you). Do not call before the slice actually runs + meets acceptance.",
3642
+ "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.",
3532
3643
  { baselineEvidence: z.string().max(1400).optional().describe('real single-line pre-edit baseline command/behavior receipt; required for new gate-first tasks') },
3533
3644
  async (args) => ({ content: [{ type: 'text', text: await markFlowDone({ baselineEvidence: args?.baselineEvidence || '' }) }] }),
3534
3645
  )] : []),
@@ -3540,14 +3651,14 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3540
3651
  // reject malformed plans back to the conductor so it re-emits.
3541
3652
  ...(entry.flowRole === 'conductor' ? [tool(
3542
3653
  'submit_flow_plan',
3543
- 'ThinkPool Flow CONDUCTOR ONLY — submit your decomposition for human approval. Call this ONCE when your task-graph is ready. Pass it as `plan`: a JSON string {"summary":"<one line; assumptions: …>","tasks":[{"key":"<kebab>","title":"…","scope":"…","acceptance":"observable proof","nonGoals":["bounded exclusion"],"baseline":{"gate":"what fails/is absent before edits","evidence":"optional observed receipt"},"deps":["<key>"],"sliceType":"feature|scaffold|review|fix"}]}. Each builder/fix/scaffold needs acceptance, nonGoals, and a safe bounded baseline gate; review tasks inherit one builder contract. When uncertain, choose and record a safe reversible default; ask only when a choice materially expands scope or authority. The room validates and persists the graph, then shows approval. Do NOT use ExitPlanMode or write a plan file.',
3654
+ '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.',
3544
3655
  { plan: z.string().describe('the task-graph as a JSON string (the {summary, tasks:[…]} object)') },
3545
3656
  async (args) => {
3546
3657
  const okText = (t) => ({ content: [{ type: 'text', text: t }] })
3547
3658
  if (!entry.flowSessionId || entry.flowRole !== 'conductor') return okText('Not a Flow conductor — there is no flow to submit a plan for.')
3548
3659
  let norm
3549
3660
  try { norm = validatePlanForRuntime(normalizePlanOutput(args?.plan || ''), entry.runtime) }
3550
- catch (e) { return okText(`Plan REJECTED: ${e?.message || e}. Re-call submit_flow_plan with valid JSON: a non-empty acyclic tasks array; each builder/fix/scaffold has observable acceptance, bounded nonGoals, and a safe baseline gate that says what fails/is absent; each review targets exactly one builder contract.`) }
3661
+ 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.`) }
3551
3662
  bcast('flow-plan', { term: id, flowId: entry.flowSessionId, plan: JSON.stringify(norm) }, flowChannel)
3552
3663
  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`)
3553
3664
  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).`)
@@ -3814,7 +3925,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3814
3925
  dispatchBaseSha: entry.dispatchBaseSha, revertTarget: entry.revertTarget, cwd: entry.cwd,
3815
3926
  managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt,
3816
3927
  reviewSliceRoots: entry.reviewSliceRoots, openedAt: entry.openedAt,
3817
- lastUsage: entry.lastUsage, carryRecap, carryCheckpoint: entry.pendingCheckpoint,
3928
+ lastUsage: entry.lastUsage, mcpFlight: entry.mcpFlight, carryRecap, carryCheckpoint: entry.pendingCheckpoint,
3818
3929
  })
3819
3930
  return sessions.get(id) || null
3820
3931
  },
@@ -4337,7 +4448,7 @@ function respawnStructured(id, provider) {
4337
4448
  // openStructured seed from the TARGET provider's configured model, which is the
4338
4449
  // only model this lane was ever asked for. A same-env model change never reaches
4339
4450
  // here — that path is an in-place setModel (see provider-switch).
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
4451
+ 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, mcpFlight, pendingCheckpoint } = s
4341
4452
  // Context-carry (2026-07-08): a provider switch is not an SDK resume — the new backend
4342
4453
  // starts blank mid-conversation. Synthesize a plain-text recap from the VISIBLE log NOW
4343
4454
  // (before teardown) and hand it to the fresh session as its first turn so the agent
@@ -4356,7 +4467,7 @@ function respawnStructured(id, provider) {
4356
4467
  // sessionData() (provider included) synchronously on open, so a bridge restart
4357
4468
  // restores the lane on its CURRENT provider, not the original — and its next
4358
4469
  // announce carries the new provider badge (additive {id,name} projection).
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 })
4470
+ 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, mcpFlight, carryRecap, carryCheckpoint: pendingCheckpoint })
4360
4471
  return true
4361
4472
  }
4362
4473
 
@@ -5330,7 +5441,7 @@ channel
5330
5441
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
5331
5442
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
5332
5443
  // lanes keep their worktree cwd + the ability to mark done.
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,
5444
+ 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, mcpFlight: rec.mcpFlight, carryRecap: wasInterrupted ? recoveryRecap : rec.carryRecap, carryCheckpoint: rec.carryCheckpoint,
5334
5445
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
5335
5446
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
5336
5447
  // (mid-turn needs auto-resume; flow needs its lane live).
@@ -5593,7 +5704,26 @@ flowChannel
5593
5704
  const cwd = worktreeSpec({ flowId: payload.flowId, taskKey: dep }).dir
5594
5705
  let sha = null
5595
5706
  try { sha = execFileSync('git', ['-C', cwd, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }).trim() } catch { /* target is not safely reviewable */ }
5596
- return { taskKey: dep, cwd, sha }
5707
+ let scopeEvidence = null
5708
+ if (sha && t.contract?.scopePaths?.length) {
5709
+ let baseSha = null
5710
+ try {
5711
+ baseSha = execFileSync('git', ['-C', cwd, 'merge-base', sha, 'origin/main'], {
5712
+ encoding: 'utf8',
5713
+ timeout: 3000,
5714
+ stdio: ['ignore', 'pipe', 'ignore'],
5715
+ }).trim()
5716
+ } catch { /* missing immutable base holds the review below */ }
5717
+ const changed = changedFilesBetween(cwd, baseSha, sha)
5718
+ if (changed) {
5719
+ const mechanical = collectFlowScopeEvidence({ log: [], repoRoot: cwd, declared: t.contract.scopePaths, changed })
5720
+ const observed = normalizeFlowScopeEvidence(t.contract.scopeEvidence)
5721
+ scopeEvidence = observed
5722
+ ? { ...mechanical, read: observed.read, verified: observed.verified }
5723
+ : mechanical
5724
+ }
5725
+ }
5726
+ return { taskKey: dep, cwd, sha, ...(scopeEvidence ? { scopeEvidence } : {}) }
5597
5727
  }).filter((item) => item.sha) : []
5598
5728
  // Lanes build autonomously in their own worktree — bypassPermissions so they
5599
5729
  // don't stall on a card for every write/bash (matches the user's expectation that
@@ -6070,6 +6200,7 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
6070
6200
  async function shutdown(code = 0, farewell = true) {
6071
6201
  if (shuttingDown) return
6072
6202
  shuttingDown = true
6203
+ codeEventDelivery.close()
6073
6204
  // Hard exit backstop, armed FIRST and independent of every await below — a wedged
6074
6205
  // realtime socket (the watchdog path) or a hung untrack must NEVER leave the
6075
6206
  // process alive with orphaned PTY children + stale "live" presence.
@@ -0,0 +1,60 @@
1
+ // Ordered, acknowledged delivery for bridge-produced transcript events.
2
+ //
3
+ // The bridge's local JSONL archive is durable, but a live `code-event` still
4
+ // has to cross Realtime before either viewer can write its shared tx: snapshot.
5
+ // Never let a failed (or merely late) assistant frame allow its terminal result
6
+ // to overtake it. Keep the head queued until Realtime acknowledges it, then move
7
+ // on in order. A fresh browser replay remains the crash-recovery backstop.
8
+
9
+ const defaultWait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
10
+
11
+ export const acceptedRealtimeDelivery = (result) =>
12
+ result === 'ok' || result?.success === true
13
+
14
+ export function createEventDeliveryQueue ({ send, wait = defaultWait, retryDelay = (attempt) => Math.min(10_000, 250 * (2 ** Math.min(attempt, 6))) } = {}) {
15
+ if (typeof send !== 'function') throw new TypeError('event delivery queue requires send(frame)')
16
+ const frames = []
17
+ let running = false
18
+ let closed = false
19
+ let idle = Promise.resolve()
20
+ let settleIdle = null
21
+
22
+ const run = async () => {
23
+ if (running || closed) return
24
+ running = true
25
+ try {
26
+ while (!closed && frames.length) {
27
+ const head = frames[0]
28
+ let result = null
29
+ try { result = await send(head.frame) } catch { result = null }
30
+ if (acceptedRealtimeDelivery(result)) {
31
+ frames.shift()
32
+ continue
33
+ }
34
+ head.attempt += 1
35
+ await wait(retryDelay(head.attempt))
36
+ }
37
+ } finally {
38
+ running = false
39
+ if (!frames.length && settleIdle) { settleIdle(); settleIdle = null }
40
+ if (!closed && frames.length) void run()
41
+ }
42
+ }
43
+
44
+ return {
45
+ enqueue (frame) {
46
+ if (closed) return false
47
+ if (!frames.length && !running) idle = new Promise((resolve) => { settleIdle = resolve })
48
+ frames.push({ frame, attempt: 0 })
49
+ void run()
50
+ return true
51
+ },
52
+ pending: () => frames.length,
53
+ whenIdle: () => idle,
54
+ close () {
55
+ closed = true
56
+ frames.length = 0
57
+ if (settleIdle) { settleIdle(); settleIdle = null }
58
+ },
59
+ }
60
+ }
package/flow-receipt.mjs CHANGED
@@ -3,6 +3,8 @@
3
3
  // admits the evidence a pair can act on. Prompts, environment, paths, logs, and
4
4
  // hidden reasoning never have a field in the public shape.
5
5
 
6
+ import { normalizeFlowScopeEvidence } from './flow-scope-evidence.mjs'
7
+
6
8
  export const FLOW_REVIEW_RECEIPT_KIND = 'flow-review-receipt'
7
9
 
8
10
  const MAX_TASK_KEY = 120
@@ -75,6 +77,7 @@ export function createFlowReviewReceipt(input = {}) {
75
77
  }
76
78
  const candidateSha = cleanSha(input.candidateSha)
77
79
  const acceptanceDigest = cleanDigest(input.acceptanceDigest)
80
+ const scope = normalizeFlowScopeEvidence(input.scope)
78
81
  // Presence, not truthiness, is intentional: deployments are never inferred
79
82
  // from a pass/reject outcome or from a candidate SHA.
80
83
  const deployment = Object.hasOwn(input, 'deployment') ? cleanText(input.deployment, MAX_LINE) : null
@@ -84,6 +87,10 @@ export function createFlowReviewReceipt(input = {}) {
84
87
  if (outcome === 'pass' && !receipt.verified) {
85
88
  throw new TypeError('Passing flow review receipt requires an exact candidate and acceptance digest')
86
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
+ }
87
94
  if (deployment) receipt.deployment = deployment
88
95
  return receipt
89
96
  }
@@ -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,6 +51,8 @@ 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
@@ -90,6 +92,39 @@ function boundedLine (value, label, max, { required = false } = {}) {
90
92
  return text
91
93
  }
92
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
+
93
128
  export function normalizeBaselineEvidence (value) {
94
129
  return boundedLine(value, 'baseline evidence receipt', FLOW_CONTRACT_LIMITS.maxBaselineEvidenceChars, { required: true })
95
130
  }
@@ -154,14 +189,16 @@ function sha256Hex (message) {
154
189
  // The acceptance pack belongs to the builder target. A review task copies that
155
190
  // target's digest, so its inherited target is bound without re-hashing reviewer
156
191
  // prose or later baseline evidence receipts.
157
- function acceptancePack ({ key, title, scope, acceptance, sliceType, nonGoals, baselineGate, inheritedReviewerTarget = key }) {
158
- return {
159
- version: 1,
192
+ function acceptancePack ({ key, title, scope, scopePaths, acceptance, sliceType, nonGoals, baselineGate, inheritedReviewerTarget = key, version = 2 }) {
193
+ const pack = {
194
+ version,
160
195
  task: { key, title, scope, acceptance, sliceType },
161
196
  nonGoals: [...nonGoals].sort(),
162
197
  baselineGate,
163
198
  inheritedReviewerTarget,
164
199
  }
200
+ if (version >= 2) pack.task.scopePaths = normalizeTaskScopePaths(scopePaths, { fallbackScope: scope })
201
+ return pack
165
202
  }
166
203
 
167
204
  export function acceptancePackDigest (input) {
@@ -174,6 +211,7 @@ function contractSource (task) {
174
211
  : {}
175
212
  if (source.nonGoals === undefined) source.nonGoals = task?.nonGoals ?? task?.non_goals
176
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
177
215
  return source
178
216
  }
179
217
 
@@ -216,19 +254,22 @@ export function normalizeTaskContract (task, { sliceType, legacy = false } = {})
216
254
  const evidence = baselineObject?.evidence == null || baselineObject?.evidence === ''
217
255
  ? ''
218
256
  : normalizeBaselineEvidence(baselineObject.evidence)
219
- return { nonGoals, baseline: gate ? { gate, evidence } : null, digest, acceptancePack: sealedPack }
257
+ const scopePaths = normalizeTaskScopePaths(source.scopePaths, { fallbackScope: task?.scope ?? task?.description ?? '', required })
258
+ return { nonGoals, baseline: gate ? { gate, evidence } : null, scopePaths, digest, acceptancePack: sealedPack }
220
259
  }
221
260
 
222
- function bindContractDigest (task) {
261
+ function bindContractDigest (task, { legacy = false } = {}) {
223
262
  const sealedPack = acceptancePack({
224
263
  key: task.key,
225
264
  title: task.title,
226
265
  scope: task.scope,
266
+ scopePaths: task.contract.scopePaths,
227
267
  acceptance: task.acceptance,
228
268
  sliceType: task.sliceType,
229
269
  nonGoals: task.contract.nonGoals,
230
270
  baselineGate: task.contract.baseline?.gate || '',
231
271
  inheritedReviewerTarget: task.key,
272
+ version: legacy ? 1 : 2,
232
273
  })
233
274
  const digest = sha256Hex(JSON.stringify(sealedPack))
234
275
  if (task.contract.digest && task.contract.digest !== digest) throw new Error(`task "${task.key}" contract digest does not match its acceptance pack`)
@@ -257,22 +298,26 @@ export function validateTaskContractSeal (task, { legacy = false } = {}) {
257
298
  key: packTask.key,
258
299
  title: packTask.title,
259
300
  scope: packTask.scope,
301
+ scopePaths: packTask.scopePaths,
260
302
  acceptance: packTask.acceptance,
261
303
  sliceType: packTask.sliceType,
262
304
  nonGoals: pack.nonGoals,
263
305
  baselineGate: pack.baselineGate,
264
306
  inheritedReviewerTarget: pack.inheritedReviewerTarget,
307
+ version: pack.version === 1 ? 1 : 2,
265
308
  })
266
309
  if (JSON.stringify(pack) !== JSON.stringify(canonicalPack)) throw new Error('task sealed acceptance pack is not canonical')
267
310
  if (acceptancePackDigest({
268
311
  key: packTask.key,
269
312
  title: packTask.title,
270
313
  scope: packTask.scope,
314
+ scopePaths: packTask.scopePaths,
271
315
  acceptance: packTask.acceptance,
272
316
  sliceType: packTask.sliceType,
273
317
  nonGoals: pack.nonGoals,
274
318
  baselineGate: pack.baselineGate,
275
319
  inheritedReviewerTarget: pack.inheritedReviewerTarget,
320
+ version: pack.version === 1 ? 1 : 2,
276
321
  }) !== contract.digest) throw new Error('task acceptance digest does not match its sealed pack')
277
322
 
278
323
  const sliceType = task.sliceType ?? task.slice_type
@@ -291,11 +336,13 @@ export function validateTaskContractSeal (task, { legacy = false } = {}) {
291
336
  key,
292
337
  title: task.title,
293
338
  scope: task.scope ?? '',
339
+ scopePaths: contract.scopePaths,
294
340
  acceptance: task.acceptance ?? '',
295
341
  sliceType,
296
342
  nonGoals: contract.nonGoals || [],
297
343
  baselineGate: contract.baseline?.gate || '',
298
344
  inheritedReviewerTarget: key,
345
+ version: pack.version === 1 ? 1 : 2,
299
346
  })
300
347
  if (JSON.stringify(livePack) !== JSON.stringify(canonicalPack)) {
301
348
  throw new Error(`task "${key}" changed after its acceptance pack was sealed`)
@@ -433,7 +480,7 @@ export function normalizePlanOutput (raw, { legacy = false } = {}) {
433
480
  })
434
481
  validateDag(tasks)
435
482
  for (const task of tasks) {
436
- if (task.sliceType !== SLICE_TYPE.review) bindContractDigest(task)
483
+ if (task.sliceType !== SLICE_TYPE.review) bindContractDigest(task, { legacy })
437
484
  }
438
485
  for (const task of tasks) {
439
486
  if (task.sliceType !== SLICE_TYPE.review) continue
@@ -449,6 +496,7 @@ export function normalizePlanOutput (raw, { legacy = false } = {}) {
449
496
  task.contract = {
450
497
  nonGoals: [...(target.contract?.nonGoals || [])],
451
498
  baseline: target.contract?.baseline ? { ...target.contract.baseline } : null,
499
+ scopePaths: [...(target.contract?.scopePaths || [])],
452
500
  inheritedFrom: target.key,
453
501
  digest: target.contract.digest,
454
502
  acceptancePack: target.contract.acceptancePack,
package/hermes-policy.mjs CHANGED
@@ -4,6 +4,7 @@ import { PEER_MCP_TOOLS, PEER_READ_MCP_TOOLS } from './cross-terminal.mjs'
4
4
 
5
5
  export const HERMES_POLICY_VERSION = 1
6
6
  export const HERMES_QUESTION_MCP_TOOL = 'request_user_input'
7
+ export const MCP_FLIGHT_RECORDER_TOOL = 'read_mcp_flight_recorder'
7
8
 
8
9
  export const CODING_TOOLS = Object.freeze([
9
10
  'web_search', 'web_extract', 'terminal', 'process', 'read_file', 'write_file',
@@ -23,7 +24,7 @@ export const ESSENTIAL_CODING_TOOLS = Object.freeze([
23
24
  'terminal', 'process', 'read_file', 'write_file', 'patch', 'search_files',
24
25
  ])
25
26
  const ROLE_REQUIRED = Object.freeze({
26
- ordinary: [...PEER_MCP_TOOLS, HERMES_QUESTION_MCP_TOOL, 'spawn_terminal', 'close_terminal'],
27
+ ordinary: [...PEER_MCP_TOOLS, HERMES_QUESTION_MCP_TOOL, MCP_FLIGHT_RECORDER_TOOL, 'spawn_terminal', 'close_terminal'],
27
28
  plan: [],
28
29
  conductor: ['submit_flow_plan'],
29
30
  builder: ['mark_flow_done'],
@@ -46,7 +47,7 @@ export function hermesPolicyForRole(role, { mcpTools } = {}) {
46
47
  // Ordinary worker leaves deliberately lack spawn/close. Main-lane proof is
47
48
  // enforced by requiredMcpTools at dispatch; keep this schema usable for a
48
49
  // non-delegating ordinary child without widening it.
49
- const minimum = role === 'ordinary' ? [...PEER_MCP_TOOLS, HERMES_QUESTION_MCP_TOOL] : required
50
+ const minimum = role === 'ordinary' ? [...PEER_MCP_TOOLS, HERMES_QUESTION_MCP_TOOL, MCP_FLIGHT_RECORDER_TOOL] : required
50
51
  for (const tool of minimum) if (!supplied.includes(tool)) throw new Error(`Hermes ${role} policy is missing required ThinkPool tool ${tool}`)
51
52
  const restricted = role === 'plan' || role === 'conductor' || role === 'reviewer' || role === 'manual-review'
52
53
  const builtinTools = restricted ? READ_ONLY_TOOLS : CODING_TOOLS
@@ -64,7 +65,7 @@ export function hermesPolicyForRole(role, { mcpTools } = {}) {
64
65
  }
65
66
 
66
67
  export function hermesRequiredMcpTools(role, { canSpawnWorkers = false } = {}) {
67
- if (role === 'ordinary') return [...PEER_MCP_TOOLS, HERMES_QUESTION_MCP_TOOL, ...(canSpawnWorkers ? ['spawn_terminal', 'close_terminal'] : [])]
68
+ if (role === 'ordinary') return [...PEER_MCP_TOOLS, HERMES_QUESTION_MCP_TOOL, MCP_FLIGHT_RECORDER_TOOL, ...(canSpawnWorkers ? ['spawn_terminal', 'close_terminal'] : [])]
68
69
  return [...ROLE_REQUIRED[role]]
69
70
  }
70
71
 
@@ -0,0 +1,79 @@
1
+ // Privacy-first MCP flight recorder. It retains enough operational evidence to
2
+ // diagnose a tool path (tool identity, lifecycle, timing, and outcome), but it
3
+ // intentionally never retains arguments, result bodies, prompts, or host paths.
4
+ // The recorder is runtime-agnostic because every structured runtime normalizes
5
+ // MCP activity into the common assistant/tool_result event contract.
6
+
7
+ export const MCP_FLIGHT_MAX = 200
8
+
9
+ const mcpToolUse = (event) => (Array.isArray(event?.blocks) ? event.blocks : [])
10
+ .filter((block) => block?.type === 'tool_use' && typeof block?.name === 'string' && block.name.startsWith('mcp__'))
11
+
12
+ const safeToolName = (name) => String(name || '')
13
+ .replace(/[^a-zA-Z0-9_-]/g, '_')
14
+ .slice(0, 160)
15
+
16
+ const safeId = (id) => typeof id === 'string' || typeof id === 'number'
17
+ ? String(id).slice(0, 200)
18
+ : null
19
+
20
+ export function normalizeMcpFlight (value, { max = MCP_FLIGHT_MAX } = {}) {
21
+ const cap = Math.max(1, Math.min(MCP_FLIGHT_MAX, Number(max) || MCP_FLIGHT_MAX))
22
+ if (!Array.isArray(value)) return []
23
+ return value.slice(-cap).flatMap((item) => {
24
+ if (!item || typeof item !== 'object') return []
25
+ const toolUseId = safeId(item.toolUseId)
26
+ const tool = safeToolName(item.tool)
27
+ const startedAt = Number(item.startedAt)
28
+ if (!toolUseId || !tool || !Number.isFinite(startedAt)) return []
29
+ return [{
30
+ toolUseId,
31
+ tool,
32
+ startedAt,
33
+ finishedAt: Number.isFinite(Number(item.finishedAt)) ? Number(item.finishedAt) : null,
34
+ durationMs: Number.isFinite(Number(item.durationMs)) ? Math.max(0, Number(item.durationMs)) : null,
35
+ outcome: item.outcome === 'error' ? 'error' : item.outcome === 'ok' ? 'ok' : 'pending',
36
+ // Deliberately a count only. Never persist or return argument names/values:
37
+ // even a key can reveal a secret type or private filesystem layout.
38
+ argumentCount: Math.max(0, Math.min(1000, Number(item.argumentCount) || 0)),
39
+ }]
40
+ })
41
+ }
42
+
43
+ const argumentCount = (input) => input && typeof input === 'object' && !Array.isArray(input)
44
+ ? Object.keys(input).length
45
+ : input == null ? 0 : 1
46
+
47
+ /** Record an already-normalized room event, returning a bounded safe trace. */
48
+ export function recordMcpFlightEvent (flight, event, { now = Date.now(), max = MCP_FLIGHT_MAX } = {}) {
49
+ const next = normalizeMcpFlight(flight, { max })
50
+ for (const block of mcpToolUse(event)) {
51
+ const toolUseId = safeId(block.id)
52
+ const tool = safeToolName(block.name)
53
+ if (!toolUseId || !tool) continue
54
+ next.push({ toolUseId, tool, startedAt: now, finishedAt: null, durationMs: null, outcome: 'pending', argumentCount: argumentCount(block.input) })
55
+ }
56
+ if (event?.kind === 'tool_result') {
57
+ const toolUseId = safeId(event.toolUseId)
58
+ const item = toolUseId ? [...next].reverse().find((entry) => entry.toolUseId === toolUseId && entry.outcome === 'pending') : null
59
+ if (item) {
60
+ item.finishedAt = now
61
+ item.durationMs = Number.isFinite(Number(event.durationMs)) ? Math.max(0, Number(event.durationMs)) : Math.max(0, now - item.startedAt)
62
+ item.outcome = event.isError ? 'error' : 'ok'
63
+ }
64
+ }
65
+ return normalizeMcpFlight(next, { max })
66
+ }
67
+
68
+ export function formatMcpFlight (flight, { limit = 40 } = {}) {
69
+ const safe = normalizeMcpFlight(flight)
70
+ const boundedLimit = Math.max(1, Math.min(100, Number(limit) || 40))
71
+ const rows = safe.slice(-boundedLimit)
72
+ if (!rows.length) return 'MCP flight recorder: no MCP tool calls recorded in this terminal yet. It records tool name, timing, and outcome only; arguments and results are never retained.'
73
+ const lines = rows.map((row) => {
74
+ const elapsed = row.durationMs == null ? 'running' : `${row.durationMs}ms`
75
+ return `${new Date(row.startedAt).toISOString()} ${row.outcome.toUpperCase().padEnd(7)} ${row.tool} ${elapsed} args omitted (${row.argumentCount})`
76
+ })
77
+ return `MCP flight recorder — ${rows.length}/${safe.length} recent call${safe.length === 1 ? '' : 's'}\n` +
78
+ 'Privacy: arguments, result bodies, prompts, secrets, and host paths are never recorded.\n\n' + lines.join('\n')
79
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.362",
3
+ "version": "0.7.364",
4
4
  "description": "Connect Claude Code, Codex, or Hermes on your computer to a Thinkpool Code room.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -68,6 +68,8 @@
68
68
  "event-id.mjs",
69
69
  "event-bounds.mjs",
70
70
  "replay-transport.mjs",
71
+ "event-delivery-queue.mjs",
72
+ "mcp-flight-recorder.mjs",
71
73
  "plan-meters.mjs",
72
74
  "recap.mjs",
73
75
  "transcript-sanitize.mjs",
@@ -94,6 +96,8 @@
94
96
  "design-source-contract.mjs",
95
97
  "flow-review.mjs",
96
98
  "flow-receipt.mjs",
99
+ "flow-scope-evidence.mjs",
100
+ "past-work-search.mjs",
97
101
  "review-check.mjs",
98
102
  "flow-review-gate.mjs",
99
103
  "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": 22,
3
+ "bundleVersion": 24,
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,18 @@
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
+ },
27
+ {
28
+ "id": "mcp-flight-recorder",
29
+ "tools": ["read_mcp_flight_recorder"],
30
+ "trigger": "\\b(mcp|tool)\\b.{0,50}\\b(trace|flight|record|audit|debug|fail|failed|failure)\\b|\\bread_mcp_flight_recorder\\b",
31
+ "prompt": "When diagnosing or auditing recent MCP tool activity in this terminal, use read_mcp_flight_recorder. It is read-only and privacy-filtered: it reports only tool name, timing, outcome, and argument count—never arguments, result bodies, prompts, secrets, or host paths."
32
+ },
21
33
  {
22
34
  "id": "visible-handoff",
23
35
  "tools": ["post_to_terminal", "post_to_session"],
@@ -27,12 +39,18 @@
27
39
  ],
28
40
  "impact": [
29
41
  {"path": "bridge/cross-terminal.mjs"},
30
- {"path": "bridge/bridge.mjs", "diffPattern": "read_terminal|list_sessions|read_session|post_to_terminal|post_to_session"},
42
+ {"path": "bridge/bridge.mjs", "diffPattern": "read_terminal|search_past_work|read_mcp_flight_recorder|list_sessions|read_session|post_to_terminal|post_to_session"},
43
+ {"path": "bridge/mcp-flight-recorder.mjs"},
44
+ {"path": "bridge/past-work-search.mjs"},
31
45
  {"path": "bridge/codex-session.mjs", "diffPattern": "MCP|Mcp|mcp|read_terminal|list_sessions|read_session|post_to_terminal|post_to_session"},
32
46
  {"path": "bridge/codex-app-server.mjs", "diffPattern": "MCP|Mcp|mcp"}
33
47
  ],
34
48
  "evidence": [
35
49
  {"path": "bridge/cross-terminal.mjs", "pattern": "read_terminal"},
50
+ {"path": "bridge/bridge.mjs", "pattern": "'search_past_work'"},
51
+ {"path": "bridge/bridge.mjs", "pattern": "'read_mcp_flight_recorder'"},
52
+ {"path": "bridge/mcp-flight-recorder.mjs", "pattern": "recordMcpFlightEvent"},
53
+ {"path": "bridge/past-work-search.mjs", "pattern": "formatPastWorkSearch"},
36
54
  {"path": "bridge/cross-terminal.mjs", "pattern": "post_to_session"},
37
55
  {"path": "bridge/bridge.mjs", "pattern": "'list_sessions'"},
38
56
  {"path": "bridge/codex-session.mjs", "pattern": "waitForMcpServer"},
@@ -130,19 +148,20 @@
130
148
  },
131
149
  {
132
150
  "id": "flow-completion",
133
- "version": 4,
151
+ "version": 5,
134
152
  "routes": [
135
153
  {
136
154
  "id": "flow-completion",
137
155
  "tools": ["submit_flow_plan", "mark_flow_done", "submit_flow_review", "read_review_file", "run_review_check"],
138
156
  "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. 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."
157
+ "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
158
  }
141
159
  ],
142
160
  "impact": [
143
161
  {"path": "bridge/bridge.mjs", "diffPattern": "submit_flow_plan|mark_flow_done|submit_flow_review|read_review_file|run_review_check"},
144
162
  {"path": "bridge/flow-review.mjs"},
145
163
  {"path": "bridge/flow-receipt.mjs"},
164
+ {"path": "bridge/flow-scope-evidence.mjs"},
146
165
  {"path": "bridge/flow-review-gate.mjs"},
147
166
  {"path": "bridge/flow-task-graph.mjs"}
148
167
  ],
@@ -151,7 +170,8 @@
151
170
  {"path": "bridge/bridge.mjs", "pattern": "'mark_flow_done'"},
152
171
  {"path": "bridge/bridge.mjs", "pattern": "'submit_flow_review'"},
153
172
  {"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"}
173
+ {"path": "bridge/flow-receipt.mjs", "pattern": "Passing flow review receipt requires a held scope footprint"},
174
+ {"path": "bridge/flow-scope-evidence.mjs", "pattern": "classifyFlowScope"}
155
175
  ]
156
176
  },
157
177
  {