thinkpool-pair 0.7.362 → 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
@@ -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,12 +127,35 @@ 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'
136
161
  import { classifyCodeEvent, unknownCodeEventNotice } from './code-event-contract.mjs'
@@ -2682,6 +2707,25 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2682
2707
  if (!reviewPass && entry.dispatchBaseSha && commitSha === entry.dispatchBaseSha) {
2683
2708
  return `Slice "${entry.flowTaskKey}" is not done: HEAD is still the dispatch base (${commitSha.slice(0, 8)}). Commit the verified implementation first.`
2684
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
+ }
2685
2729
  let previewUrl = null
2686
2730
  try { const pv = await startPreview({ dir: entry.cwd || process.cwd(), id: `lane:${entry.flowSessionId}:${id}` }); previewUrl = pv.url } catch { /* preview best-effort */ }
2687
2731
  // S1 (context-offload) — digest THIS closed slice into the durable store so the next
@@ -2712,8 +2756,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2712
2756
  process.stderr.write(`\n ${A.dim}◆ flow digest skip ${entry.flowTaskKey} — ${e?.message || e}${A.rst}\n`)
2713
2757
  }
2714
2758
  const completedContract = requiresBaselineReceipt
2715
- ? { ...entry.flowTaskContract, baseline: { ...entry.flowTaskContract.baseline, evidence: baselineReceipt } }
2716
- : entry.flowTaskContract || null
2759
+ ? { ...entry.flowTaskContract, baseline: { ...entry.flowTaskContract.baseline, evidence: baselineReceipt }, ...(scopeEvidence ? { scopeEvidence } : {}) }
2760
+ : entry.flowTaskContract
2761
+ ? { ...entry.flowTaskContract, ...(scopeEvidence ? { scopeEvidence } : {}) }
2762
+ : null
2717
2763
  bcast('flow-task-done', { term: id, flowId: entry.flowSessionId, taskKey: entry.flowTaskKey, laneId: id, commitSha, previewUrl, contract: completedContract, ...(reviewPass ? { reviewAction: 'pass' } : {}) }, flowChannel)
2718
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`)
2719
2765
  entry.flowDone = true // FL-B3 — retire immediately so it drops from the ≤8 lane cap
@@ -2802,6 +2848,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2802
2848
  rounds: round,
2803
2849
  candidateSha: targetSnapshot?.sha,
2804
2850
  acceptanceDigest,
2851
+ scope: targetSnapshot?.scopeEvidence,
2805
2852
  reasons: receiptReasons,
2806
2853
  })
2807
2854
  } catch (error) {
@@ -2816,6 +2863,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2816
2863
  rounds: round,
2817
2864
  candidateSha: targetSnapshot?.sha,
2818
2865
  acceptanceDigest,
2866
+ scope: targetSnapshot?.scopeEvidence,
2819
2867
  reasons: receiptReasons,
2820
2868
  })
2821
2869
  }
@@ -3148,6 +3196,44 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3148
3196
  return { content: [{ type: 'text', text }] }
3149
3197
  },
3150
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
+ )] : []),
3151
3237
  // Tier 1 cross-ROOM peek — list_sessions / read_session. Read-only reach into the
3152
3238
  // account's OTHER rooms (this session's sibling SESSIONS, not just sibling terminals),
3153
3239
  // routed through the account supervisor over IPC (pairRequest). Auto-allowed by the
@@ -3528,7 +3614,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3528
3614
  // (slices whose deps just got satisfied).
3529
3615
  ...(entry.flowRole === 'builder' ? [tool(
3530
3616
  '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.",
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.",
3532
3618
  { baselineEvidence: z.string().max(1400).optional().describe('real single-line pre-edit baseline command/behavior receipt; required for new gate-first tasks') },
3533
3619
  async (args) => ({ content: [{ type: 'text', text: await markFlowDone({ baselineEvidence: args?.baselineEvidence || '' }) }] }),
3534
3620
  )] : []),
@@ -3540,14 +3626,14 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3540
3626
  // reject malformed plans back to the conductor so it re-emits.
3541
3627
  ...(entry.flowRole === 'conductor' ? [tool(
3542
3628
  '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.',
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.',
3544
3630
  { plan: z.string().describe('the task-graph as a JSON string (the {summary, tasks:[…]} object)') },
3545
3631
  async (args) => {
3546
3632
  const okText = (t) => ({ content: [{ type: 'text', text: t }] })
3547
3633
  if (!entry.flowSessionId || entry.flowRole !== 'conductor') return okText('Not a Flow conductor — there is no flow to submit a plan for.')
3548
3634
  let norm
3549
3635
  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.`) }
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.`) }
3551
3637
  bcast('flow-plan', { term: id, flowId: entry.flowSessionId, plan: JSON.stringify(norm) }, flowChannel)
3552
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`)
3553
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).`)
@@ -5593,7 +5679,26 @@ flowChannel
5593
5679
  const cwd = worktreeSpec({ flowId: payload.flowId, taskKey: dep }).dir
5594
5680
  let sha = null
5595
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 */ }
5596
- 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 } : {}) }
5597
5702
  }).filter((item) => item.sha) : []
5598
5703
  // Lanes build autonomously in their own worktree — bypassPermissions so they
5599
5704
  // don't stall on a card for every write/bash (matches the user's expectation that
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.362",
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": {
@@ -94,6 +94,8 @@
94
94
  "design-source-contract.mjs",
95
95
  "flow-review.mjs",
96
96
  "flow-receipt.mjs",
97
+ "flow-scope-evidence.mjs",
98
+ "past-work-search.mjs",
97
99
  "review-check.mjs",
98
100
  "flow-review-gate.mjs",
99
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": 22,
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,19 +139,20 @@
130
139
  },
131
140
  {
132
141
  "id": "flow-completion",
133
- "version": 4,
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. 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."
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"},
145
154
  {"path": "bridge/flow-receipt.mjs"},
155
+ {"path": "bridge/flow-scope-evidence.mjs"},
146
156
  {"path": "bridge/flow-review-gate.mjs"},
147
157
  {"path": "bridge/flow-task-graph.mjs"}
148
158
  ],
@@ -151,7 +161,8 @@
151
161
  {"path": "bridge/bridge.mjs", "pattern": "'mark_flow_done'"},
152
162
  {"path": "bridge/bridge.mjs", "pattern": "'submit_flow_review'"},
153
163
  {"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"}
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"}
155
166
  ]
156
167
  },
157
168
  {