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 +146 -15
- package/event-delivery-queue.mjs +60 -0
- package/flow-receipt.mjs +7 -0
- package/flow-scope-evidence.mjs +117 -0
- package/flow-task-graph.mjs +54 -6
- package/hermes-policy.mjs +4 -3
- package/mcp-flight-recorder.mjs +79 -0
- package/package.json +5 -1
- package/past-work-search.mjs +105 -0
- package/session-store.mjs +22 -0
- package/thinkpool-capabilities.json +26 -6
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
|
-
|
|
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
|
|
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":"
|
|
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
|
-
|
|
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
|
+
}
|
package/flow-task-graph.mjs
CHANGED
|
@@ -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
|
-
|
|
159
|
-
version
|
|
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
|
-
|
|
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.
|
|
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":
|
|
3
|
+
"bundleVersion": 24,
|
|
4
4
|
"contracts": [
|
|
5
5
|
{
|
|
6
6
|
"id": "room-coordination",
|
|
7
|
-
"version":
|
|
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":
|
|
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
|
|
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
|
|
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
|
{
|