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