thinkpool-pair 0.7.361 → 0.7.362
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 +102 -37
- package/code-event-contract.mjs +1 -0
- package/flow-receipt.mjs +115 -0
- package/flow-task-graph.mjs +202 -2
- package/package.json +2 -1
- package/thinkpool-capabilities.json +7 -4
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,7 @@ 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'
|
|
100
101
|
import { reviewGateDecision } from './flow-review-gate.mjs'
|
|
101
102
|
import { IMMUTABLE_REVIEW_READ_TOOL_EXTRAS, UNSANDBOXED_REVIEW_CHECK_TOOL_EXTRAS, readReviewFile, runReviewCheck } from './review-check.mjs'
|
|
102
103
|
import { pairAdjudicationPrompt, reviewReflectionDecision, REVIEW_DEFAULTS } from './flow-review-reflect.mjs'
|
|
@@ -966,11 +967,13 @@ const MIN_TURN_MS = Number(process.env.TP_NOTIFY_MIN_TURN_MS) > 0
|
|
|
966
967
|
? Number(process.env.TP_NOTIFY_MIN_TURN_MS) : DEFAULT_MIN_TURN_MS
|
|
967
968
|
function persistAgentEvent (payload) {
|
|
968
969
|
if (AGENT_NOTIFY_OFF || !codeAuthToken || !myServeUid || !room) return
|
|
970
|
+
const { eventId, ...eventPayload } = payload || {}
|
|
971
|
+
const stableId = typeof eventId === 'string' && /^[A-Za-z0-9_-]{1,120}$/.test(eventId) ? eventId : randomUUID()
|
|
969
972
|
const row = {
|
|
970
|
-
id: `ae:${
|
|
973
|
+
id: `ae:${stableId}`,
|
|
971
974
|
session_code: room,
|
|
972
975
|
author_id: myServeUid,
|
|
973
|
-
payload: { __agent: true, ts: Date.now(), ...
|
|
976
|
+
payload: { __agent: true, ts: Date.now(), ...eventPayload },
|
|
974
977
|
}
|
|
975
978
|
fetch(`${SUPABASE_URL}/rest/v1/code_messages`, {
|
|
976
979
|
method: 'POST',
|
|
@@ -2533,7 +2536,7 @@ function worktreeSnapshot(cwd) {
|
|
|
2533
2536
|
// relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
|
|
2534
2537
|
// persist to the host file; tool calls round-trip through the perm card; the
|
|
2535
2538
|
// 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 }) {
|
|
2539
|
+
function openStructured({ id, runtime = 'claude', model, models, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, rolePrompt, sliceType, flowSessionId, flowTaskKey, flowTaskContract, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, flowReviewConclusion, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, carryCheckpoint, lastUsage, receivedTurnCids, scheduleRunId, scheduleDeadlineAt, scheduleOutcomeRecorded, scheduleAdmissionLease }) {
|
|
2537
2540
|
if (sessions.has(id)) return
|
|
2538
2541
|
runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
|
|
2539
2542
|
// Fail closed before exposing a native lane if its bridge semantic contract
|
|
@@ -2606,6 +2609,9 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2606
2609
|
flowReviewTargets: Array.isArray(flowReviewTargets) && flowReviewTargets.length ? flowReviewTargets.filter(Boolean) : (flowReviewTarget ? [flowReviewTarget] : []),
|
|
2607
2610
|
flowReviewSnapshots: Array.isArray(flowReviewSnapshots) ? flowReviewSnapshots.filter((item) => item?.taskKey && item?.sha && item?.cwd) : [],
|
|
2608
2611
|
flowReviewRound: Number.isInteger(flowReviewRound) && flowReviewRound >= 0 ? flowReviewRound : 0,
|
|
2612
|
+
flowReviewConclusion: flowReviewConclusion?.taskKey && ['pass', 'reject', 'surface'].includes(flowReviewConclusion?.action) && isFlowReviewReceipt(flowReviewConclusion?.receipt)
|
|
2613
|
+
? { taskKey: flowReviewConclusion.taskKey, action: flowReviewConclusion.action, round: flowReviewConclusion.round, receipt: flowReviewConclusion.receipt }
|
|
2614
|
+
: null,
|
|
2609
2615
|
dispatchBaseSha: dispatchBaseSha || null,
|
|
2610
2616
|
revertTarget: revertTarget || null,
|
|
2611
2617
|
// Stable creation order — persisted so a bridge restart restores tabs in the SAME
|
|
@@ -2719,7 +2725,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2719
2725
|
// review gate was dead code: reviewers had no working emit path, so a failing slice was
|
|
2720
2726
|
// never reverted). On FAIL → broadcast flow-revert for the reviewed slice (the client flips
|
|
2721
2727
|
// it to pending → the next wave rebuilds it). Either way the review lane itself is done.
|
|
2722
|
-
const
|
|
2728
|
+
const processReviewVerdict = async (raw) => {
|
|
2723
2729
|
if (!entry.flowSessionId || !entry.flowTaskKey || entry.flowRole !== 'reviewer') return { ok: false, message: 'Not a Flow review lane.' }
|
|
2724
2730
|
let v, target = null
|
|
2725
2731
|
try {
|
|
@@ -2737,6 +2743,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2737
2743
|
? `Review verdict REJECTED: ${e?.message || e}. Re-call submit_flow_review with a valid authorized taskKey.`
|
|
2738
2744
|
: `Review verdict REJECTED: ${e?.message || e}. Re-Write FLOW_REVIEW.json with {"pass":<boolean>,"reasons":["<specific finding>"],"taskKey":"<the slice you reviewed>"}.` }
|
|
2739
2745
|
}
|
|
2746
|
+
if (entry.flowReviewConclusion) return { ok: true, message: `Review already concluded for ${entry.flowReviewConclusion.taskKey}; receipt ${entry.flowReviewConclusion.receipt.cid || 'recorded'}.` }
|
|
2740
2747
|
// E1 A1/A2 — the BOUNDED reviewer loop, live side. Each FLOW_REVIEW.json write is ONE
|
|
2741
2748
|
// hunt round; the governor decides continue-vs-stop from the round count + the lane's
|
|
2742
2749
|
// REAL budget (flowBudgets ledger). A bare pass:true (happy path held, not yet exhausted)
|
|
@@ -2778,15 +2785,51 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2778
2785
|
} else {
|
|
2779
2786
|
process.stderr.write(`\n ${A.cyan}◆ review ${decision.action.toUpperCase()} (round ${round}) — ${target || entry.flowTaskKey}${A.rst}\n`)
|
|
2780
2787
|
}
|
|
2781
|
-
// Durable room-visible verdict.
|
|
2782
|
-
//
|
|
2783
|
-
//
|
|
2784
|
-
//
|
|
2785
|
-
|
|
2786
|
-
const
|
|
2787
|
-
|
|
2788
|
-
|
|
2788
|
+
// Durable room-visible verdict. Bind a conclusive pass to the exact immutable
|
|
2789
|
+
// builder snapshot plus the acceptance digest inherited at approval time. A
|
|
2790
|
+
// missing/malformed proof can never look like a verified pass: it becomes a
|
|
2791
|
+
// held receipt and assembly stays blocked for pair adjudication.
|
|
2792
|
+
const reviewedTaskKey = target || entry.flowTaskKey
|
|
2793
|
+
const targetSnapshot = entry.flowReviewSnapshots.find((item) => item.taskKey === reviewedTaskKey) || null
|
|
2794
|
+
const acceptanceDigest = entry.flowTaskContract?.digest || null
|
|
2795
|
+
let terminalAction = decision.action
|
|
2796
|
+
let receiptReasons = v.reasons.length ? v.reasons : [decision.reason]
|
|
2797
|
+
let visibleReview
|
|
2798
|
+
try {
|
|
2799
|
+
visibleReview = createFlowReviewReceipt({
|
|
2800
|
+
taskKey: reviewedTaskKey,
|
|
2801
|
+
outcome: terminalAction === 'surface' ? 'held' : terminalAction,
|
|
2802
|
+
rounds: round,
|
|
2803
|
+
candidateSha: targetSnapshot?.sha,
|
|
2804
|
+
acceptanceDigest,
|
|
2805
|
+
reasons: receiptReasons,
|
|
2806
|
+
})
|
|
2807
|
+
} catch (error) {
|
|
2808
|
+
if (terminalAction !== 'pass') {
|
|
2809
|
+
return { ok: false, message: `Review verdict REJECTED: ${error?.message || error}.` }
|
|
2810
|
+
}
|
|
2811
|
+
terminalAction = 'surface'
|
|
2812
|
+
receiptReasons = [...receiptReasons, 'Exact candidate or sealed acceptance proof is missing; pass was held.']
|
|
2813
|
+
visibleReview = createFlowReviewReceipt({
|
|
2814
|
+
taskKey: reviewedTaskKey,
|
|
2815
|
+
outcome: 'held',
|
|
2816
|
+
rounds: round,
|
|
2817
|
+
candidateSha: targetSnapshot?.sha,
|
|
2818
|
+
acceptanceDigest,
|
|
2819
|
+
reasons: receiptReasons,
|
|
2820
|
+
})
|
|
2821
|
+
}
|
|
2822
|
+
visibleReview.cid = randomUUID()
|
|
2823
|
+
entry.flowReviewConclusion = {
|
|
2824
|
+
taskKey: reviewedTaskKey,
|
|
2825
|
+
action: terminalAction,
|
|
2826
|
+
round,
|
|
2827
|
+
receipt: visibleReview,
|
|
2789
2828
|
}
|
|
2829
|
+
// Persist the conclusion before emitting side effects. A retry or restart
|
|
2830
|
+
// can reconstruct a missing transcript row from this safe receipt, but can
|
|
2831
|
+
// never conclude the same review twice.
|
|
2832
|
+
entry.flush?.()
|
|
2790
2833
|
pushLog(entry, visibleReview)
|
|
2791
2834
|
bcast('code-event', { term: id, evt: visibleReview })
|
|
2792
2835
|
entry.flush?.()
|
|
@@ -2796,45 +2839,53 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2796
2839
|
// broadcast, does NOT change the revert/gate flow.
|
|
2797
2840
|
bcast('flow-review-verdict', {
|
|
2798
2841
|
term: id, flowId: entry.flowSessionId,
|
|
2799
|
-
taskKey:
|
|
2800
|
-
pass:
|
|
2801
|
-
reasons:
|
|
2802
|
-
action:
|
|
2842
|
+
taskKey: reviewedTaskKey,
|
|
2843
|
+
pass: terminalAction === 'pass',
|
|
2844
|
+
reasons: receiptReasons,
|
|
2845
|
+
action: terminalAction,
|
|
2803
2846
|
rounds: round,
|
|
2804
|
-
surfaceToPair: decision.surfaceToPair,
|
|
2847
|
+
surfaceToPair: terminalAction === 'surface' || decision.surfaceToPair,
|
|
2805
2848
|
prompt: pairAdjudicationPrompt({
|
|
2806
|
-
action:
|
|
2807
|
-
reason: decision.reason,
|
|
2808
|
-
taskKey:
|
|
2809
|
-
findings:
|
|
2849
|
+
action: terminalAction,
|
|
2850
|
+
reason: terminalAction === decision.action ? decision.reason : receiptReasons.at(-1),
|
|
2851
|
+
taskKey: reviewedTaskKey,
|
|
2852
|
+
findings: receiptReasons,
|
|
2810
2853
|
}),
|
|
2811
2854
|
}, flowChannel)
|
|
2812
2855
|
let doneMsg = ''
|
|
2813
|
-
if (
|
|
2856
|
+
if (terminalAction === 'pass') {
|
|
2814
2857
|
doneMsg = await markFlowDone({ reviewPass: true })
|
|
2815
2858
|
} 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 ${
|
|
2859
|
+
if (terminalAction === 'surface') {
|
|
2860
|
+
bcast('flow-review-held', { term: id, flowId: entry.flowSessionId, reviewTaskKey: entry.flowTaskKey, taskKey: target, reasons: receiptReasons }, flowChannel)
|
|
2861
|
+
persistAgentEvent({ eventId: visibleReview.cid, kind: 'needs-input', term: id, termName: termNames[id] || null, summary: `Flow review held ${reviewedTaskKey}: ${receiptReasons.join('; ') || decision.reason}` })
|
|
2819
2862
|
}
|
|
2820
2863
|
// A reject must rebuild the target and then run a fresh reviewer. Retire that
|
|
2821
2864
|
// reviewer now. A surfaced inconclusive review is different: keep its lane
|
|
2822
2865
|
// alive with the durable control row above so either room member can open the
|
|
2823
2866
|
// held review, read the exact findings, and continue/adjudicate after reload.
|
|
2824
2867
|
// Neither outcome emits task-done or permits assembly.
|
|
2825
|
-
if (
|
|
2868
|
+
if (terminalAction === 'reject') {
|
|
2826
2869
|
entry.flowDone = true
|
|
2827
2870
|
entry.flush?.()
|
|
2828
2871
|
setTimeout(() => { try { endStructured(id) } catch { /* already gone */ } }, 2500)
|
|
2829
2872
|
}
|
|
2830
2873
|
}
|
|
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}`
|
|
2874
|
+
const label = terminalAction === 'reject'
|
|
2875
|
+
? `REJECT (round ${round}) — reverting ${target || '(no target)'} (${receiptReasons.join('; ').slice(0, 140)})`
|
|
2876
|
+
: terminalAction === 'surface'
|
|
2877
|
+
? `SURFACED to the pair after ${round} round${round === 1 ? '' : 's'} — ${receiptReasons.at(-1) || decision.reason}`
|
|
2835
2878
|
: `PASS (round ${round})`
|
|
2836
2879
|
return { ok: true, message: `Review verdict recorded: ${label}.${doneMsg ? ` ${doneMsg}` : ''}` }
|
|
2837
2880
|
}
|
|
2881
|
+
const runFlowReviewVerdict = createFlowReviewSingleFlight()
|
|
2882
|
+
const onReviewVerdict = async (raw) => {
|
|
2883
|
+
if (entry.flowReviewConclusion) return { ok: true, message: `Review already concluded for ${entry.flowReviewConclusion.taskKey}; receipt ${entry.flowReviewConclusion.receipt.cid || 'recorded'}.` }
|
|
2884
|
+
// One terminal verdict is one transaction. In particular, reject awaits the
|
|
2885
|
+
// host revert before it can persist its conclusion; without this single-flight
|
|
2886
|
+
// fence two simultaneous submissions can both cross that await and revert/emit.
|
|
2887
|
+
return runFlowReviewVerdict(() => processReviewVerdict(raw))
|
|
2888
|
+
}
|
|
2838
2889
|
// Identity for the durable archive — pushLog appends every new transcript event to
|
|
2839
2890
|
// <room>/<id>.events.jsonl keyed off these. Seed the archive once from the restored
|
|
2840
2891
|
// (≤2000) log so the retained window is immediately pageable; no-op if it already
|
|
@@ -2891,13 +2942,19 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2891
2942
|
// restart. Without this, sessionData omitted it → on restart the resumed session
|
|
2892
2943
|
// re-launched on the host default (Opus) regardless of the last switch, and the
|
|
2893
2944
|
// 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) })
|
|
2945
|
+
const sessionData = () => ({ sessionId: entry.session?.sessionId || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, models: entry.runtime === 'hermes' ? (entry.models || []) : undefined, provider: entry.provider || null, spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth || 0, cascadeRole: entry.cascadeRole || null, hop: entry.hop || 0, sideParent: entry.sideParent, sideTask: entry.sideTask, scheduleRunId: entry.scheduleRunId || null, scheduleDeadlineAt: entry.scheduleDeadlineAt || null, scheduleOutcomeRecorded: entry.scheduleOutcomeRecorded === true, scheduleAdmissionLease: entry.scheduleAdmissionLease || null, pendingSideContexts: entry.pendingSideContexts, pendingWorkerCompletions: entry.pendingWorkerCompletions, workerCompletionsInFlight: entry.workerCompletionsInFlight, sliceType: entry.sliceType, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowTaskContract: entry.flowTaskContract, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], flowReviewSnapshots: entry.flowReviewSnapshots || [], flowReviewRound: entry.flowReviewRound || 0, flowReviewConclusion: entry.flowReviewConclusion, dispatchBaseSha: entry.dispatchBaseSha || null, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null, carryRecap: entry.pendingRecap || null, carryCheckpoint: entry.pendingCheckpoint || null, receivedTurnCids: [...entry.receivedTurnCids].slice(-1000) })
|
|
2895
2946
|
const persist = () => saveSession(room, id, sessionData())
|
|
2896
2947
|
// Synchronous flush of this session's record. Used on open (so a brand-new session
|
|
2897
2948
|
// has a file under its id BEFORE its first event — surviving a restart inside the
|
|
2898
2949
|
// 1.5s saveSession debounce window) and on shutdown (so events since the last
|
|
2899
2950
|
// debounced write aren't lost). Contract #2: restart resumes, no lost messages.
|
|
2900
2951
|
entry.flush = () => flushSession(room, id, sessionData())
|
|
2952
|
+
if (entry.flowReviewConclusion?.receipt && !entry.log.some((event) => event?.cid === entry.flowReviewConclusion.receipt.cid)) {
|
|
2953
|
+
const restoredReceipt = { ...entry.flowReviewConclusion.receipt }
|
|
2954
|
+
pushLog(entry, restoredReceipt)
|
|
2955
|
+
bcast('code-event', { term: id, evt: restoredReceipt })
|
|
2956
|
+
entry.flush()
|
|
2957
|
+
}
|
|
2901
2958
|
if (entry.scheduleOutcomeRecorded && entry.scheduleAdmissionLease) {
|
|
2902
2959
|
void releaseScheduledAdmissionForEntry(entry).then((released) => {
|
|
2903
2960
|
if (released) entry.flush?.()
|
|
@@ -3753,7 +3810,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
3753
3810
|
provider: entry.provider, log: entry.log, commands: entry.commands, mode: entry.mode,
|
|
3754
3811
|
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
3812
|
flowTaskKey: entry.flowTaskKey, flowTaskContract: entry.flowTaskContract, flowRole: entry.flowRole, flowReviewTarget: entry.flowReviewTarget,
|
|
3756
|
-
flowReviewTargets: entry.flowReviewTargets, flowReviewSnapshots: entry.flowReviewSnapshots, flowReviewRound: entry.flowReviewRound,
|
|
3813
|
+
flowReviewTargets: entry.flowReviewTargets, flowReviewSnapshots: entry.flowReviewSnapshots, flowReviewRound: entry.flowReviewRound, flowReviewConclusion: entry.flowReviewConclusion,
|
|
3757
3814
|
dispatchBaseSha: entry.dispatchBaseSha, revertTarget: entry.revertTarget, cwd: entry.cwd,
|
|
3758
3815
|
managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt,
|
|
3759
3816
|
reviewSliceRoots: entry.reviewSliceRoots, openedAt: entry.openedAt,
|
|
@@ -4280,7 +4337,7 @@ function respawnStructured(id, provider) {
|
|
|
4280
4337
|
// openStructured seed from the TARGET provider's configured model, which is the
|
|
4281
4338
|
// only model this lane was ever asked for. A same-env model change never reaches
|
|
4282
4339
|
// 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
|
|
4340
|
+
const { runtime, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, sliceType, flowSessionId, flowTaskKey, flowTaskContract, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, flowReviewConclusion, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage, pendingCheckpoint } = s
|
|
4284
4341
|
// Context-carry (2026-07-08): a provider switch is not an SDK resume — the new backend
|
|
4285
4342
|
// starts blank mid-conversation. Synthesize a plain-text recap from the VISIBLE log NOW
|
|
4286
4343
|
// (before teardown) and hand it to the fresh session as its first turn so the agent
|
|
@@ -4299,7 +4356,7 @@ function respawnStructured(id, provider) {
|
|
|
4299
4356
|
// sessionData() (provider included) synchronously on open, so a bridge restart
|
|
4300
4357
|
// restores the lane on its CURRENT provider, not the original — and its next
|
|
4301
4358
|
// 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 })
|
|
4359
|
+
openStructured({ id, runtime, provider, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, sliceType, flowSessionId, flowTaskKey, flowTaskContract, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, flowReviewConclusion, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage, carryRecap, carryCheckpoint: pendingCheckpoint })
|
|
4303
4360
|
return true
|
|
4304
4361
|
}
|
|
4305
4362
|
|
|
@@ -5273,7 +5330,7 @@ channel
|
|
|
5273
5330
|
// FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
|
|
5274
5331
|
// bridge restart: the conductor keeps its subagent-block + plan interception, and
|
|
5275
5332
|
// 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,
|
|
5333
|
+
openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, models: rec.models, effort: rec.effort, provider: rec.runtime === 'claude' ? rec.provider || undefined : undefined, resume: resumable ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, spawnDepth: rec.spawnDepth, cascadeRole: rec.cascadeRole, hop: rec.hop, sideParent: rec.sideParent, sideTask: rec.sideTask, scheduleRunId: rec.scheduleRunId, scheduleDeadlineAt: rec.scheduleDeadlineAt, scheduleOutcomeRecorded: rec.scheduleOutcomeRecorded, scheduleAdmissionLease: rec.scheduleAdmissionLease, pendingSideContexts: rec.pendingSideContexts, pendingWorkerCompletions: rec.pendingWorkerCompletions, workerCompletionsInFlight: rec.workerCompletionsInFlight, sliceType: rec.sliceType, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowTaskContract: rec.flowTaskContract, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, flowReviewSnapshots: rec.flowReviewSnapshots, flowReviewRound: rec.flowReviewRound, flowReviewConclusion: rec.flowReviewConclusion, dispatchBaseSha: rec.dispatchBaseSha, revertTarget: rec.revertTarget, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, receivedTurnCids: rec.receivedTurnCids, carryRecap: wasInterrupted ? recoveryRecap : rec.carryRecap, carryCheckpoint: rec.carryCheckpoint,
|
|
5277
5334
|
// Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
|
|
5278
5335
|
// shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
|
|
5279
5336
|
// (mid-turn needs auto-resume; flow needs its lane live).
|
|
@@ -5496,6 +5553,13 @@ flowChannel
|
|
|
5496
5553
|
// Step 4 — a `review` slice gets the adversarial reviewer prompt (try-to-break),
|
|
5497
5554
|
// every other slice gets the builder prompt.
|
|
5498
5555
|
const isReview = t.slice_type === 'review'
|
|
5556
|
+
try {
|
|
5557
|
+
validateTaskContractSeal(t, { legacy: t.contract == null })
|
|
5558
|
+
} catch (error) {
|
|
5559
|
+
process.stderr.write(`\n ${A.yel}◆ flow dispatch held — ${t.task_key || 'task'} failed its sealed acceptance contract: ${error?.message || error}${A.rst}\n`)
|
|
5560
|
+
bcast('flow-dispatch-held', { term: 'flow', flowId: payload.flowId, taskKey: t.task_key || null, reason: 'sealed acceptance contract mismatch' }, flowChannel)
|
|
5561
|
+
continue
|
|
5562
|
+
}
|
|
5499
5563
|
if (!validReviewTargetShape(t, flowRuntime)) {
|
|
5500
5564
|
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
5565
|
continue
|
|
@@ -5545,6 +5609,7 @@ flowChannel
|
|
|
5545
5609
|
? (isReview ? FLOW_CODEX_REVIEWER_PROMPT : FLOW_CODEX_LANE_PROMPT)
|
|
5546
5610
|
: (isReview ? FLOW_REVIEWER_PROMPT : FLOW_LANE_PROMPT)
|
|
5547
5611
|
const laneRolePrompt = buildLanePrompt({ base: laneBase })
|
|
5612
|
+
const sealedReviewAcceptance = t.contract?.acceptancePack?.task?.acceptance
|
|
5548
5613
|
// Model tiers (2026-07-03-flow-lane-model-tiers): pick the lane's brain by slice_type
|
|
5549
5614
|
// (scaffold→sonnet, feature/fix/review→opus; env can blanket-override or `inherit` to
|
|
5550
5615
|
// restore today's exact behavior). undefined → no model key passed (openStructured default).
|
|
@@ -5571,7 +5636,7 @@ flowChannel
|
|
|
5571
5636
|
? `Reviewed slice worktree(s) — check out + RUN each yourself:\n` +
|
|
5572
5637
|
t.deps.map((dep) => { const ws = worktreeSpec({ flowId: payload.flowId, taskKey: dep }); return ` - ${dep}: dir ${ws.dir} (branch ${ws.branch})` }).join('\n') + '\n'
|
|
5573
5638
|
: '') +
|
|
5574
|
-
`ACCEPTANCE to INDEPENDENTLY verify: ${t.acceptance || t.title}\n` +
|
|
5639
|
+
`ACCEPTANCE to INDEPENDENTLY verify: ${sealedReviewAcceptance || t.acceptance || t.title}\n` +
|
|
5575
5640
|
`INHERITED NON-GOALS (must remain untouched): ${(t.contract?.nonGoals || []).join(' | ') || '(legacy task: none recorded)'}\n` +
|
|
5576
5641
|
`INHERITED BASELINE GATE: ${t.contract?.baseline?.gate || '(legacy task: none recorded)'}\n` +
|
|
5577
5642
|
`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,115 @@
|
|
|
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
|
+
export const FLOW_REVIEW_RECEIPT_KIND = 'flow-review-receipt'
|
|
7
|
+
|
|
8
|
+
const MAX_TASK_KEY = 120
|
|
9
|
+
const MAX_LINE = 180
|
|
10
|
+
const MAX_LINES = 4
|
|
11
|
+
const OUTCOMES = new Set(['pass', 'reject', 'held'])
|
|
12
|
+
const DIGEST = /^[a-f0-9]{64}$/
|
|
13
|
+
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
|
|
14
|
+
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
|
|
15
|
+
const HOST_PATH = /(?:^~[\\/]|^\.?[\\/]|(?:^|\s)\/[\w.-]+|[A-Za-z]:\\|\\\\|(?:^|[\\/])(?:Users|home|var|tmp|private|workspace)(?:[\\/]|$)|\.thinkpool[\\/])/i
|
|
16
|
+
const PATH_TRAVERSAL = /(?:^|[^a-z0-9.])\.\.(?:[\\/]|$)/i
|
|
17
|
+
|
|
18
|
+
function cleanText(value, max) {
|
|
19
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > max) return null
|
|
20
|
+
if (/\r|\n/.test(value) || SECRET_VALUE.test(value) || SECRET_OR_PRIVATE.test(value) || HOST_PATH.test(value) || PATH_TRAVERSAL.test(value)) return null
|
|
21
|
+
const text = value.replace(/\s+/g, ' ').trim()
|
|
22
|
+
return text && text.length <= max ? text : null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function cleanLines(value) {
|
|
26
|
+
if (!Array.isArray(value)) return []
|
|
27
|
+
const out = []
|
|
28
|
+
for (const line of value) {
|
|
29
|
+
const safe = cleanText(line, MAX_LINE)
|
|
30
|
+
if (safe && !out.includes(safe)) out.push(safe)
|
|
31
|
+
if (out.length === MAX_LINES) break
|
|
32
|
+
}
|
|
33
|
+
return out
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function cleanTaskKey(value) {
|
|
37
|
+
const taskKey = cleanText(value, MAX_TASK_KEY)
|
|
38
|
+
if (!taskKey || !/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(taskKey) || taskKey.includes('..')) return null
|
|
39
|
+
return taskKey
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function cleanSha(value) {
|
|
43
|
+
if (typeof value !== 'string') return null
|
|
44
|
+
const sha = value.trim().toLowerCase()
|
|
45
|
+
return /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(sha) ? sha : null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function cleanRounds(value) {
|
|
49
|
+
return Number.isInteger(value) && value >= 1 && value <= 99 ? value : null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function cleanDigest(value) {
|
|
53
|
+
if (typeof value !== 'string') return null
|
|
54
|
+
const digest = value.trim().toLowerCase()
|
|
55
|
+
return DIGEST.test(digest) ? digest : null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Build the only wire shape the renderer accepts. Optional fields are omitted
|
|
59
|
+
// rather than represented as null, making absence honest (notably deployment).
|
|
60
|
+
export function createFlowReviewReceipt(input = {}) {
|
|
61
|
+
const taskKey = cleanTaskKey(input.taskKey)
|
|
62
|
+
const outcome = typeof input.outcome === 'string' ? input.outcome.toLowerCase() : ''
|
|
63
|
+
const rounds = cleanRounds(input.rounds)
|
|
64
|
+
if (!taskKey || !OUTCOMES.has(outcome) || rounds == null) {
|
|
65
|
+
throw new TypeError('Invalid flow review receipt identity')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const receipt = {
|
|
69
|
+
kind: FLOW_REVIEW_RECEIPT_KIND,
|
|
70
|
+
taskKey,
|
|
71
|
+
outcome,
|
|
72
|
+
rounds,
|
|
73
|
+
checks: cleanLines(input.checks),
|
|
74
|
+
reasons: cleanLines(input.reasons),
|
|
75
|
+
}
|
|
76
|
+
const candidateSha = cleanSha(input.candidateSha)
|
|
77
|
+
const acceptanceDigest = cleanDigest(input.acceptanceDigest)
|
|
78
|
+
// Presence, not truthiness, is intentional: deployments are never inferred
|
|
79
|
+
// from a pass/reject outcome or from a candidate SHA.
|
|
80
|
+
const deployment = Object.hasOwn(input, 'deployment') ? cleanText(input.deployment, MAX_LINE) : null
|
|
81
|
+
if (candidateSha) receipt.candidateSha = candidateSha
|
|
82
|
+
if (acceptanceDigest) receipt.acceptanceDigest = acceptanceDigest
|
|
83
|
+
receipt.verified = Boolean(candidateSha && acceptanceDigest)
|
|
84
|
+
if (outcome === 'pass' && !receipt.verified) {
|
|
85
|
+
throw new TypeError('Passing flow review receipt requires an exact candidate and acceptance digest')
|
|
86
|
+
}
|
|
87
|
+
if (deployment) receipt.deployment = deployment
|
|
88
|
+
return receipt
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function isFlowReviewReceipt(value) {
|
|
92
|
+
try {
|
|
93
|
+
const receipt = createFlowReviewReceipt(value)
|
|
94
|
+
return receipt.kind === value?.kind
|
|
95
|
+
} catch {
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A review lane may receive the same terminal submission twice (tool retry,
|
|
101
|
+
// double click, or concurrent provider delivery). Serialize the whole verdict
|
|
102
|
+
// transaction—not just receipt emission—so an awaited revert cannot run twice.
|
|
103
|
+
export function createFlowReviewSingleFlight() {
|
|
104
|
+
let pending = null
|
|
105
|
+
return async function runFlowReviewVerdict(operation) {
|
|
106
|
+
if (pending) return pending
|
|
107
|
+
const current = (async () => operation())()
|
|
108
|
+
pending = current
|
|
109
|
+
try {
|
|
110
|
+
return await current
|
|
111
|
+
} finally {
|
|
112
|
+
if (pending === current) pending = null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
package/flow-task-graph.mjs
CHANGED
|
@@ -54,6 +54,18 @@ export const FLOW_CONTRACT_LIMITS = Object.freeze({
|
|
|
54
54
|
})
|
|
55
55
|
|
|
56
56
|
const BASELINE_NEGATIVE_SIGNAL = /\b(fail(?:s|ed|ing)?|absent|missing|not\s+(?:present|implemented|available|found)|does\s+not|404|empty)\b/i
|
|
57
|
+
const CONTRACT_DIGEST = /^[a-f0-9]{64}$/
|
|
58
|
+
const SHA256_K = [
|
|
59
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
60
|
+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
61
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
62
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
63
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
64
|
+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
65
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
66
|
+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
67
|
+
]
|
|
68
|
+
const rotateRight = (value, bits) => (value >>> bits) | (value << (32 - bits))
|
|
57
69
|
const hasDisallowedControl = (value) => {
|
|
58
70
|
for (const char of value) {
|
|
59
71
|
const code = char.charCodeAt(0)
|
|
@@ -82,6 +94,80 @@ export function normalizeBaselineEvidence (value) {
|
|
|
82
94
|
return boundedLine(value, 'baseline evidence receipt', FLOW_CONTRACT_LIMITS.maxBaselineEvidenceChars, { required: true })
|
|
83
95
|
}
|
|
84
96
|
|
|
97
|
+
// This is deliberately a small synchronous SHA-256 implementation instead of a
|
|
98
|
+
// Node crypto import or WebCrypto: the task graph must stay byte-identical and run
|
|
99
|
+
// in both the bridge's Node runtime and the browser bundle.
|
|
100
|
+
function sha256Hex (message) {
|
|
101
|
+
const source = new TextEncoder().encode(message)
|
|
102
|
+
const totalLength = Math.ceil((source.length + 9) / 64) * 64
|
|
103
|
+
const bytes = new Uint8Array(totalLength)
|
|
104
|
+
bytes.set(source)
|
|
105
|
+
bytes[source.length] = 0x80
|
|
106
|
+
let bitLength = BigInt(source.length) * 8n
|
|
107
|
+
for (let index = totalLength - 1; index >= totalLength - 8; index--) {
|
|
108
|
+
bytes[index] = Number(bitLength & 0xffn)
|
|
109
|
+
bitLength >>= 8n
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const hash = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]
|
|
113
|
+
const words = new Uint32Array(64)
|
|
114
|
+
for (let offset = 0; offset < bytes.length; offset += 64) {
|
|
115
|
+
for (let index = 0; index < 16; index++) {
|
|
116
|
+
const start = offset + (index * 4)
|
|
117
|
+
words[index] = ((bytes[start] << 24) | (bytes[start + 1] << 16) | (bytes[start + 2] << 8) | bytes[start + 3]) >>> 0
|
|
118
|
+
}
|
|
119
|
+
for (let index = 16; index < 64; index++) {
|
|
120
|
+
const low = rotateRight(words[index - 15], 7) ^ rotateRight(words[index - 15], 18) ^ (words[index - 15] >>> 3)
|
|
121
|
+
const high = rotateRight(words[index - 2], 17) ^ rotateRight(words[index - 2], 19) ^ (words[index - 2] >>> 10)
|
|
122
|
+
words[index] = (words[index - 16] + low + words[index - 7] + high) >>> 0
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let [a, b, c, d, e, f, g, h] = hash
|
|
126
|
+
for (let index = 0; index < 64; index++) {
|
|
127
|
+
const sigma1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25)
|
|
128
|
+
const choose = (e & f) ^ (~e & g)
|
|
129
|
+
const temp1 = (h + sigma1 + choose + SHA256_K[index] + words[index]) >>> 0
|
|
130
|
+
const sigma0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22)
|
|
131
|
+
const majority = (a & b) ^ (a & c) ^ (b & c)
|
|
132
|
+
const temp2 = (sigma0 + majority) >>> 0
|
|
133
|
+
h = g
|
|
134
|
+
g = f
|
|
135
|
+
f = e
|
|
136
|
+
e = (d + temp1) >>> 0
|
|
137
|
+
d = c
|
|
138
|
+
c = b
|
|
139
|
+
b = a
|
|
140
|
+
a = (temp1 + temp2) >>> 0
|
|
141
|
+
}
|
|
142
|
+
hash[0] = (hash[0] + a) >>> 0
|
|
143
|
+
hash[1] = (hash[1] + b) >>> 0
|
|
144
|
+
hash[2] = (hash[2] + c) >>> 0
|
|
145
|
+
hash[3] = (hash[3] + d) >>> 0
|
|
146
|
+
hash[4] = (hash[4] + e) >>> 0
|
|
147
|
+
hash[5] = (hash[5] + f) >>> 0
|
|
148
|
+
hash[6] = (hash[6] + g) >>> 0
|
|
149
|
+
hash[7] = (hash[7] + h) >>> 0
|
|
150
|
+
}
|
|
151
|
+
return hash.map((word) => word.toString(16).padStart(8, '0')).join('')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The acceptance pack belongs to the builder target. A review task copies that
|
|
155
|
+
// target's digest, so its inherited target is bound without re-hashing reviewer
|
|
156
|
+
// prose or later baseline evidence receipts.
|
|
157
|
+
function acceptancePack ({ key, title, scope, acceptance, sliceType, nonGoals, baselineGate, inheritedReviewerTarget = key }) {
|
|
158
|
+
return {
|
|
159
|
+
version: 1,
|
|
160
|
+
task: { key, title, scope, acceptance, sliceType },
|
|
161
|
+
nonGoals: [...nonGoals].sort(),
|
|
162
|
+
baselineGate,
|
|
163
|
+
inheritedReviewerTarget,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function acceptancePackDigest (input) {
|
|
168
|
+
return sha256Hex(JSON.stringify(acceptancePack(input)))
|
|
169
|
+
}
|
|
170
|
+
|
|
85
171
|
function contractSource (task) {
|
|
86
172
|
const source = task?.contract && typeof task.contract === 'object' && !Array.isArray(task.contract)
|
|
87
173
|
? { ...task.contract }
|
|
@@ -91,12 +177,30 @@ function contractSource (task) {
|
|
|
91
177
|
return source
|
|
92
178
|
}
|
|
93
179
|
|
|
180
|
+
function normalizeProvidedContractDigest (source) {
|
|
181
|
+
if (source.digest === undefined) return null
|
|
182
|
+
if (typeof source.digest !== 'string' || !CONTRACT_DIGEST.test(source.digest)) {
|
|
183
|
+
throw new Error('contract digest must be a lowercase SHA-256 hex string')
|
|
184
|
+
}
|
|
185
|
+
return source.digest
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function normalizeProvidedAcceptancePack (source) {
|
|
189
|
+
if (source.acceptancePack === undefined) return null
|
|
190
|
+
if (!source.acceptancePack || typeof source.acceptancePack !== 'object' || Array.isArray(source.acceptancePack)) {
|
|
191
|
+
throw new Error('contract acceptance pack must be an object')
|
|
192
|
+
}
|
|
193
|
+
return source.acceptancePack
|
|
194
|
+
}
|
|
195
|
+
|
|
94
196
|
// Strict for new terminal submissions. `legacy:true` is restore-only: old persisted
|
|
95
197
|
// plans predate this contract and must remain runnable, but they can never be used to
|
|
96
198
|
// submit a new incomplete plan.
|
|
97
199
|
export function normalizeTaskContract (task, { sliceType, legacy = false } = {}) {
|
|
98
200
|
const source = contractSource(task)
|
|
99
|
-
|
|
201
|
+
const digest = normalizeProvidedContractDigest(source)
|
|
202
|
+
const sealedPack = normalizeProvidedAcceptancePack(source)
|
|
203
|
+
if (sliceType === SLICE_TYPE.review) return legacy ? null : { digest, acceptancePack: sealedPack }
|
|
100
204
|
const required = !legacy
|
|
101
205
|
const rawNonGoals = source.nonGoals
|
|
102
206
|
const values = rawNonGoals == null ? [] : (Array.isArray(rawNonGoals) ? rawNonGoals : [rawNonGoals])
|
|
@@ -112,7 +216,91 @@ export function normalizeTaskContract (task, { sliceType, legacy = false } = {})
|
|
|
112
216
|
const evidence = baselineObject?.evidence == null || baselineObject?.evidence === ''
|
|
113
217
|
? ''
|
|
114
218
|
: normalizeBaselineEvidence(baselineObject.evidence)
|
|
115
|
-
return { nonGoals, baseline: gate ? { gate, evidence } : null }
|
|
219
|
+
return { nonGoals, baseline: gate ? { gate, evidence } : null, digest, acceptancePack: sealedPack }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function bindContractDigest (task) {
|
|
223
|
+
const sealedPack = acceptancePack({
|
|
224
|
+
key: task.key,
|
|
225
|
+
title: task.title,
|
|
226
|
+
scope: task.scope,
|
|
227
|
+
acceptance: task.acceptance,
|
|
228
|
+
sliceType: task.sliceType,
|
|
229
|
+
nonGoals: task.contract.nonGoals,
|
|
230
|
+
baselineGate: task.contract.baseline?.gate || '',
|
|
231
|
+
inheritedReviewerTarget: task.key,
|
|
232
|
+
})
|
|
233
|
+
const digest = sha256Hex(JSON.stringify(sealedPack))
|
|
234
|
+
if (task.contract.digest && task.contract.digest !== digest) throw new Error(`task "${task.key}" contract digest does not match its acceptance pack`)
|
|
235
|
+
if (task.contract.acceptancePack && JSON.stringify(task.contract.acceptancePack) !== JSON.stringify(sealedPack)) {
|
|
236
|
+
throw new Error(`task "${task.key}" sealed acceptance pack does not match its task fields`)
|
|
237
|
+
}
|
|
238
|
+
task.contract.digest = digest
|
|
239
|
+
task.contract.acceptancePack = sealedPack
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// The browser persists normalized task rows before approval, then later sends a
|
|
243
|
+
// ready row back to the bridge for dispatch. Recompute the seal at that final
|
|
244
|
+
// boundary so changed acceptance/scope/non-goals can never ride beside an old hash.
|
|
245
|
+
export function validateTaskContractSeal (task, { legacy = false } = {}) {
|
|
246
|
+
const contract = task?.contract
|
|
247
|
+
if (!contract?.digest && legacy) return true
|
|
248
|
+
if (!contract || typeof contract !== 'object' || !contract.digest || !contract.acceptancePack) {
|
|
249
|
+
throw new Error('task is missing its sealed acceptance contract')
|
|
250
|
+
}
|
|
251
|
+
const pack = contract.acceptancePack
|
|
252
|
+
const packTask = pack?.task
|
|
253
|
+
if (!packTask || typeof packTask !== 'object' || !Array.isArray(pack.nonGoals)) {
|
|
254
|
+
throw new Error('task sealed acceptance pack is malformed')
|
|
255
|
+
}
|
|
256
|
+
const canonicalPack = acceptancePack({
|
|
257
|
+
key: packTask.key,
|
|
258
|
+
title: packTask.title,
|
|
259
|
+
scope: packTask.scope,
|
|
260
|
+
acceptance: packTask.acceptance,
|
|
261
|
+
sliceType: packTask.sliceType,
|
|
262
|
+
nonGoals: pack.nonGoals,
|
|
263
|
+
baselineGate: pack.baselineGate,
|
|
264
|
+
inheritedReviewerTarget: pack.inheritedReviewerTarget,
|
|
265
|
+
})
|
|
266
|
+
if (JSON.stringify(pack) !== JSON.stringify(canonicalPack)) throw new Error('task sealed acceptance pack is not canonical')
|
|
267
|
+
if (acceptancePackDigest({
|
|
268
|
+
key: packTask.key,
|
|
269
|
+
title: packTask.title,
|
|
270
|
+
scope: packTask.scope,
|
|
271
|
+
acceptance: packTask.acceptance,
|
|
272
|
+
sliceType: packTask.sliceType,
|
|
273
|
+
nonGoals: pack.nonGoals,
|
|
274
|
+
baselineGate: pack.baselineGate,
|
|
275
|
+
inheritedReviewerTarget: pack.inheritedReviewerTarget,
|
|
276
|
+
}) !== contract.digest) throw new Error('task acceptance digest does not match its sealed pack')
|
|
277
|
+
|
|
278
|
+
const sliceType = task.sliceType ?? task.slice_type
|
|
279
|
+
const key = task.key ?? task.task_key
|
|
280
|
+
const deps = Array.isArray(task.deps) ? task.deps : []
|
|
281
|
+
if (sliceType === SLICE_TYPE.review) {
|
|
282
|
+
if (contract.inheritedFrom !== packTask.key || deps.length !== 1 || deps[0] !== packTask.key) {
|
|
283
|
+
throw new Error('review task no longer targets its sealed builder')
|
|
284
|
+
}
|
|
285
|
+
if (pack.inheritedReviewerTarget !== packTask.key || task.acceptance !== packTask.acceptance) {
|
|
286
|
+
throw new Error(`review task "${key}" changed after its builder acceptance pack was sealed`)
|
|
287
|
+
}
|
|
288
|
+
return true
|
|
289
|
+
}
|
|
290
|
+
const livePack = acceptancePack({
|
|
291
|
+
key,
|
|
292
|
+
title: task.title,
|
|
293
|
+
scope: task.scope ?? '',
|
|
294
|
+
acceptance: task.acceptance ?? '',
|
|
295
|
+
sliceType,
|
|
296
|
+
nonGoals: contract.nonGoals || [],
|
|
297
|
+
baselineGate: contract.baseline?.gate || '',
|
|
298
|
+
inheritedReviewerTarget: key,
|
|
299
|
+
})
|
|
300
|
+
if (JSON.stringify(livePack) !== JSON.stringify(canonicalPack)) {
|
|
301
|
+
throw new Error(`task "${key}" changed after its acceptance pack was sealed`)
|
|
302
|
+
}
|
|
303
|
+
return true
|
|
116
304
|
}
|
|
117
305
|
|
|
118
306
|
// A FlowTask slice. Each is RUNNABLE — a lane can build + run + self-correct it in
|
|
@@ -244,6 +432,9 @@ export function normalizePlanOutput (raw, { legacy = false } = {}) {
|
|
|
244
432
|
})
|
|
245
433
|
})
|
|
246
434
|
validateDag(tasks)
|
|
435
|
+
for (const task of tasks) {
|
|
436
|
+
if (task.sliceType !== SLICE_TYPE.review) bindContractDigest(task)
|
|
437
|
+
}
|
|
247
438
|
for (const task of tasks) {
|
|
248
439
|
if (task.sliceType !== SLICE_TYPE.review) continue
|
|
249
440
|
// New review tasks verify exactly one builder contract. Legacy persisted reviews
|
|
@@ -252,11 +443,20 @@ export function normalizePlanOutput (raw, { legacy = false } = {}) {
|
|
|
252
443
|
if (task.deps.length !== 1) throw new Error(`review task "${task.key}" must depend on exactly one builder task to inherit its contract`)
|
|
253
444
|
const target = tasks.find((candidate) => candidate.key === task.deps[0])
|
|
254
445
|
if (!target || target.sliceType === SLICE_TYPE.review) throw new Error(`review task "${task.key}" must target a builder/fix/scaffold task`)
|
|
446
|
+
if (task.contract.digest && task.contract.digest !== target.contract.digest) {
|
|
447
|
+
throw new Error(`review task "${task.key}" contract digest does not match target "${target.key}"`)
|
|
448
|
+
}
|
|
255
449
|
task.contract = {
|
|
256
450
|
nonGoals: [...(target.contract?.nonGoals || [])],
|
|
257
451
|
baseline: target.contract?.baseline ? { ...target.contract.baseline } : null,
|
|
258
452
|
inheritedFrom: target.key,
|
|
453
|
+
digest: target.contract.digest,
|
|
454
|
+
acceptancePack: target.contract.acceptancePack,
|
|
259
455
|
}
|
|
456
|
+
// Reviewer-authored prose is not an authority boundary. Normalize the live
|
|
457
|
+
// review row to the builder's sealed acceptance so dispatch and the prompt
|
|
458
|
+
// cannot silently substitute a weaker criterion.
|
|
459
|
+
task.acceptance = target.contract.acceptancePack.task.acceptance
|
|
260
460
|
}
|
|
261
461
|
return { summary, tasks }
|
|
262
462
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.362",
|
|
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,7 @@
|
|
|
93
93
|
"design-edit.mjs",
|
|
94
94
|
"design-source-contract.mjs",
|
|
95
95
|
"flow-review.mjs",
|
|
96
|
+
"flow-receipt.mjs",
|
|
96
97
|
"review-check.mjs",
|
|
97
98
|
"flow-review-gate.mjs",
|
|
98
99
|
"flow-review-reflect.mjs",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"bundleVersion":
|
|
3
|
+
"bundleVersion": 22,
|
|
4
4
|
"contracts": [
|
|
5
5
|
{
|
|
6
6
|
"id": "room-coordination",
|
|
@@ -130,25 +130,28 @@
|
|
|
130
130
|
},
|
|
131
131
|
{
|
|
132
132
|
"id": "flow-completion",
|
|
133
|
-
"version":
|
|
133
|
+
"version": 4,
|
|
134
134
|
"routes": [
|
|
135
135
|
{
|
|
136
136
|
"id": "flow-completion",
|
|
137
137
|
"tools": ["submit_flow_plan", "mark_flow_done", "submit_flow_review", "read_review_file", "run_review_check"],
|
|
138
138
|
"trigger": "\\b(flow|submit_flow_plan|mark_flow_done|submit_flow_review|read_review_file|run_review_check)\\b",
|
|
139
|
-
"prompt": "Managed Flow roles use only their exposed completion and review tools: submit_flow_plan, mark_flow_done, submit_flow_review, read_review_file, and run_review_check. New builder/fix/scaffold tasks must carry a bounded gate-first contract: observable acceptance, explicit non-goals, and a real pre-edit failure/absence gate; completion supplies
|
|
139
|
+
"prompt": "Managed Flow roles use only their exposed completion and review tools: submit_flow_plan, mark_flow_done, submit_flow_review, read_review_file, and run_review_check. New builder/fix/scaffold tasks must carry a bounded gate-first contract: observable acceptance, explicit non-goals, and a real pre-edit failure/absence gate. Approval seals that acceptance pack with a stable digest; completion supplies observed baseline evidence without changing the digest, and the exact dependent reviewer inherits it. A conclusive pass binds one bounded terminal receipt to the exact reviewed candidate SHA and acceptance digest; missing proof holds the Flow instead of completing it. When a bounded detail is ambiguous or the user is unsure, choose the least-invasive reversible default, state the assumption, and continue; ask only when the choice would materially expand scope or authority. read_review_file is approval-free pinned-source access. run_review_check executes target-defined code without OS filesystem or network isolation and therefore remains subject to the runtime's normal approval boundary; approvalPolicy=never reviewers must not call it and must report the skipped check."
|
|
140
140
|
}
|
|
141
141
|
],
|
|
142
142
|
"impact": [
|
|
143
143
|
{"path": "bridge/bridge.mjs", "diffPattern": "submit_flow_plan|mark_flow_done|submit_flow_review|read_review_file|run_review_check"},
|
|
144
144
|
{"path": "bridge/flow-review.mjs"},
|
|
145
|
+
{"path": "bridge/flow-receipt.mjs"},
|
|
145
146
|
{"path": "bridge/flow-review-gate.mjs"},
|
|
146
147
|
{"path": "bridge/flow-task-graph.mjs"}
|
|
147
148
|
],
|
|
148
149
|
"evidence": [
|
|
149
150
|
{"path": "bridge/bridge.mjs", "pattern": "'submit_flow_plan'"},
|
|
150
151
|
{"path": "bridge/bridge.mjs", "pattern": "'mark_flow_done'"},
|
|
151
|
-
{"path": "bridge/bridge.mjs", "pattern": "'submit_flow_review'"}
|
|
152
|
+
{"path": "bridge/bridge.mjs", "pattern": "'submit_flow_review'"},
|
|
153
|
+
{"path": "bridge/flow-task-graph.mjs", "pattern": "acceptancePackDigest"},
|
|
154
|
+
{"path": "bridge/flow-receipt.mjs", "pattern": "Passing flow review receipt requires an exact candidate and acceptance digest"}
|
|
152
155
|
]
|
|
153
156
|
},
|
|
154
157
|
{
|