thinkpool-pair 0.7.360 → 0.7.361

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bridge.mjs CHANGED
@@ -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, normalizePlanOutput, validatePlanForRuntime, validReviewTargetShape } from './flow-task-graph.mjs'
80
+ import { legacyBuilderCompletionAllowed, normalizeBaselineEvidence, normalizePlanOutput, validatePlanForRuntime, 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,
@@ -130,7 +130,7 @@ import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce,
130
130
  import { supersedeDispatchLease } from './dispatch-lease.mjs'
131
131
  import { realtimeRecoveryDecision, turnInFlight } from './update-gate.mjs'
132
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'
133
- import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, ImageEventQueue, imageQueueConfig, uploadCodeImage as uploadCodeImageRequest, usageReportLine, codexUsageReportLine, appendCurrentPersonRequest, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
133
+ 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
134
  import { createLatestReplayPump, requestedReplayIds } from './replay-transport.mjs'
135
135
  import { classifyCodeEvent, unknownCodeEventNotice } from './code-event-contract.mjs'
136
136
  import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
@@ -2427,6 +2427,13 @@ function emitCodexCompactionControl(entry, text, by = null) {
2427
2427
  bcast('code-event', { term: entry.id, evt })
2428
2428
  }
2429
2429
 
2430
+ function armCompactionCheckpoint(entry) {
2431
+ const checkpoint = buildCheckpointFromLog(entry?.log)
2432
+ if (!checkpoint) return false
2433
+ entry.pendingCheckpoint = checkpoint
2434
+ return true
2435
+ }
2436
+
2430
2437
  async function runCodexCompaction(entry, request = {}) {
2431
2438
  const recap = buildRecapFromLog(entry.log, RECAP_CAP, { reason: 'compact' })
2432
2439
  if (!recap) {
@@ -2450,6 +2457,7 @@ async function runCodexCompaction(entry, request = {}) {
2450
2457
  if (!entry.session.turnActive && settleLaneControl(entry)) announce()
2451
2458
  }
2452
2459
  if (nativeCompacted === true) {
2460
+ armCompactionCheckpoint(entry)
2453
2461
  const evt = { kind: 'compaction', trigger: 'manual', preTokens, by: request.by, native: true }
2454
2462
  pushLog(entry, evt)
2455
2463
  bcast('code-event', { term: entry.id, evt })
@@ -2468,7 +2476,9 @@ async function runCodexCompaction(entry, request = {}) {
2468
2476
  emitCodexCompactionControl(entry, 'Codex context compaction unavailable right now', request.by)
2469
2477
  return
2470
2478
  }
2471
- entry.pendingRecap = recap
2479
+ const carry = resolveCheckpointCarry(recap, entry.pendingCheckpoint)
2480
+ entry.pendingRecap = carry.pendingRecap
2481
+ entry.pendingCheckpoint = carry.pendingCheckpoint
2472
2482
  const evt = { kind: 'compaction', trigger: 'manual', preTokens, by: request.by }
2473
2483
  pushLog(entry, evt)
2474
2484
  bcast('code-event', { term: entry.id, evt })
@@ -2523,7 +2533,7 @@ function worktreeSnapshot(cwd) {
2523
2533
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
2524
2534
  // persist to the host file; tool calls round-trip through the perm card; the
2525
2535
  // rolling log replays to joiners and survives bridge restarts (session-store).
2526
- 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, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage, receivedTurnCids, scheduleRunId, scheduleDeadlineAt, scheduleOutcomeRecorded, scheduleAdmissionLease }) {
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 }) {
2527
2537
  if (sessions.has(id)) return
2528
2538
  runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
2529
2539
  // Fail closed before exposing a native lane if its bridge semantic contract
@@ -2635,6 +2645,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2635
2645
  // event-id.mjs + src/pages/code/seqDedup.js). pushLog() is the single stamp point.
2636
2646
  entry.seq = makeSeqCounter(maxSeq(entry.log))
2637
2647
  entry.rolePrompt = rolePrompt || null // FL-M6 — persisted so a bridge restart restores the flow role prompt
2648
+ entry.flowTaskContract = flowTaskContract && typeof flowTaskContract === 'object' ? flowTaskContract : null
2638
2649
  // lastUsage — the terminal's most recent usage/ctx meter. Persisted (sessionData) +
2639
2650
  // restored so a bridge restart can re-emit it on replay: usage is chrome (kept out of
2640
2651
  // the replayed transcript log), so without this a (re)joiner sees "—" for context until
@@ -2649,10 +2660,17 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2649
2660
  // MCP tool AND the FLOW_DONE sentinel-Write intercept: like the conductor's submit, the
2650
2661
  // MCP tool is DEFERRED (needs ToolSearch, which hangs), so lanes signal done by Writing a
2651
2662
  // FLOW_DONE file — a DIRECT tool — which the PreToolUse hook routes here. Returns a message.
2652
- const markFlowDone = async ({ reviewPass = false } = {}) => {
2663
+ const markFlowDone = async ({ reviewPass = false, baselineEvidence = '' } = {}) => {
2653
2664
  if (!entry.flowSessionId || !entry.flowTaskKey) return 'Not a Flow lane — nothing to mark done.'
2654
2665
  if (entry.flowRole !== 'builder' && !(reviewPass && entry.flowRole === 'reviewer')) return 'This Flow role cannot mark a builder slice done.'
2655
2666
  if (entry.flowDone) return 'This slice is already recorded as done.'
2667
+ let baselineReceipt = ''
2668
+ const requiresBaselineReceipt = !reviewPass && !!entry.flowTaskContract?.baseline?.gate
2669
+ if (requiresBaselineReceipt) {
2670
+ try { baselineReceipt = normalizeBaselineEvidence(baselineEvidence) } catch (e) {
2671
+ return `Slice "${entry.flowTaskKey}" is not done: submit the real bounded baselineEvidence receipt (${e?.message || e}). Observe the assigned pre-edit gate; do not fabricate a RED sentence.`
2672
+ }
2673
+ }
2656
2674
  let commitSha = null
2657
2675
  try { commitSha = execFileSync('git', ['-C', entry.cwd || process.cwd(), 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() } catch { /* no commits yet */ }
2658
2676
  if (!reviewPass && entry.dispatchBaseSha && commitSha === entry.dispatchBaseSha) {
@@ -2681,13 +2699,16 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2681
2699
  ]
2682
2700
  const digest = digestSlice(
2683
2701
  { key: entry.flowTaskKey, title: termNames[id] || entry.flowTaskKey },
2684
- { acceptanceProof: commitSha ? `committed ${commitSha.slice(0, 8)}` : 'slice done', artifacts },
2702
+ { acceptanceProof: `${baselineReceipt ? `baseline: ${baselineReceipt}; ` : ''}${commitSha ? `committed ${commitSha.slice(0, 8)}` : 'slice done'}`, artifacts },
2685
2703
  )
2686
2704
  appendDigest(entry.flowSessionId, digest, { baseDir: process.cwd() }) // idempotent per (flowId, taskKey)
2687
2705
  } catch (e) {
2688
2706
  process.stderr.write(`\n ${A.dim}◆ flow digest skip ${entry.flowTaskKey} — ${e?.message || e}${A.rst}\n`)
2689
2707
  }
2690
- bcast('flow-task-done', { term: id, flowId: entry.flowSessionId, taskKey: entry.flowTaskKey, laneId: id, commitSha, previewUrl, ...(reviewPass ? { reviewAction: 'pass' } : {}) }, flowChannel)
2708
+ const completedContract = requiresBaselineReceipt
2709
+ ? { ...entry.flowTaskContract, baseline: { ...entry.flowTaskContract.baseline, evidence: baselineReceipt } }
2710
+ : entry.flowTaskContract || null
2711
+ bcast('flow-task-done', { term: id, flowId: entry.flowSessionId, taskKey: entry.flowTaskKey, laneId: id, commitSha, previewUrl, contract: completedContract, ...(reviewPass ? { reviewAction: 'pass' } : {}) }, flowChannel)
2691
2712
  process.stderr.write(`\n ${A.cyan}◆ flow slice done — ${entry.flowTaskKey} (${commitSha ? commitSha.slice(0, 8) : 'no commit'})${previewUrl ? ` · preview ${previewUrl}` : ''}${A.rst}\n`)
2692
2713
  entry.flowDone = true // FL-B3 — retire immediately so it drops from the ≤8 lane cap
2693
2714
  setTimeout(() => { try { endStructured(id) } catch { /* already gone */ } }, 2500)
@@ -2870,7 +2891,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2870
2891
  // restart. Without this, sessionData omitted it → on restart the resumed session
2871
2892
  // re-launched on the host default (Opus) regardless of the last switch, and the
2872
2893
  // switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
2873
- 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, 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, receivedTurnCids: [...entry.receivedTurnCids].slice(-1000) })
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) })
2874
2895
  const persist = () => saveSession(room, id, sessionData())
2875
2896
  // Synchronous flush of this session's record. Used on open (so a brand-new session
2876
2897
  // has a file under its id BEFORE its first event — surviving a restart inside the
@@ -3450,9 +3471,9 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3450
3471
  // (slices whose deps just got satisfied).
3451
3472
  ...(entry.flowRole === 'builder' ? [tool(
3452
3473
  'mark_flow_done',
3453
- "ThinkPool Flow ONLY — call this ONCE when your slice is built, RUNS, and meets its acceptance criteria. 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). No arguments: your commit is read from your worktree. Do not call before your slice actually runs + meets acceptance.",
3454
- {},
3455
- async () => ({ content: [{ type: 'text', text: await markFlowDone() }] }),
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.",
3475
+ { baselineEvidence: z.string().max(1400).optional().describe('real single-line pre-edit baseline command/behavior receipt; required for new gate-first tasks') },
3476
+ async (args) => ({ content: [{ type: 'text', text: await markFlowDone({ baselineEvidence: args?.baselineEvidence || '' }) }] }),
3456
3477
  )] : []),
3457
3478
  // FL-B1 — the conductor SUBMITS its decomposition through THIS tool, not the built-in
3458
3479
  // ExitPlanMode. In the current SDK, ExitPlanMode is a deferred tool the conductor must
@@ -3462,17 +3483,17 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3462
3483
  // reject malformed plans back to the conductor so it re-emits.
3463
3484
  ...(entry.flowRole === 'conductor' ? [tool(
3464
3485
  'submit_flow_plan',
3465
- '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>","tasks":[{"key":"<kebab>","title":"…","scope":"…","acceptance":"","deps":["<key>"],"sliceType":"feature|scaffold|review|fix"}]}. The room validates it is an acyclic DAG, persists the tasks, and shows the approval card. This is how a conductor FINISHES do NOT use ExitPlanMode, do NOT write a plan file.',
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":"…","acceptance":"observable proof","nonGoals":["bounded exclusion"],"baseline":{"gate":"what fails/is absent before edits","evidence":"optional observed receipt"},"deps":["<key>"],"sliceType":"feature|scaffold|review|fix"}]}. Each builder/fix/scaffold needs acceptance, nonGoals, and a safe bounded baseline gate; review tasks inherit one builder contract. When uncertain, choose and record a safe reversible default; ask only when a choice materially expands scope or authority. The room validates and persists the graph, then shows approval. Do NOT use ExitPlanMode or write a plan file.',
3466
3487
  { plan: z.string().describe('the task-graph as a JSON string (the {summary, tasks:[…]} object)') },
3467
3488
  async (args) => {
3468
3489
  const okText = (t) => ({ content: [{ type: 'text', text: t }] })
3469
3490
  if (!entry.flowSessionId || entry.flowRole !== 'conductor') return okText('Not a Flow conductor — there is no flow to submit a plan for.')
3470
3491
  let norm
3471
3492
  try { norm = validatePlanForRuntime(normalizePlanOutput(args?.plan || ''), entry.runtime) }
3472
- catch (e) { return okText(`Plan REJECTED: ${e?.message || e}. Re-call submit_flow_plan with valid JSON a non-empty "tasks" array, every "deps" entry referencing an existing task "key", and no cycles.`) }
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.`) }
3473
3494
  bcast('flow-plan', { term: id, flowId: entry.flowSessionId, plan: JSON.stringify(norm) }, flowChannel)
3474
3495
  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`)
3475
- return okText(`Plan submitted (${norm.tasks.length} slice${norm.tasks.length === 1 ? '' : 's'}) the room is showing the human an approval card. You are DONE; stop here and wait for approval (lane dispatch is the room's job).`)
3496
+ 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).`)
3476
3497
  },
3477
3498
  )] : []),
3478
3499
  ...(entry.flowRole === 'reviewer' ? [tool(
@@ -3523,7 +3544,13 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3523
3544
  // human turn cannot strand a fresh Codex thread without its recap. It is fired
3524
3545
  // on the init `system` event (see onEvent) — a sendTurn before the input stream is consumed
3525
3546
  // is silently lost (the 2026-07-02 auto-resume bug). Empty string → nothing to carry.
3526
- entry.pendingRecap = (typeof carryRecap === 'string' && carryRecap.trim()) ? carryRecap : null
3547
+ const carry = resolveCheckpointCarry(carryRecap, carryCheckpoint)
3548
+ entry.pendingRecap = carry.pendingRecap
3549
+ // Native compaction keeps the runtime thread alive, so this checkpoint must not
3550
+ // create a synthetic turn. Persist it separately and prepend it exactly once to
3551
+ // the next real person turn. That makes the post-compact continuation explicit
3552
+ // while preserving the runtime's own compacted context.
3553
+ entry.pendingCheckpoint = carry.pendingCheckpoint
3527
3554
  const terminalRolePrompt = [buildTerminalRolePrompt({
3528
3555
  spawnedBy: entry.spawnedBy,
3529
3556
  spawnDepth: entry.spawnDepth,
@@ -3617,14 +3644,21 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3617
3644
  if (!entry.flowSessionId || entry.flowRole !== 'conductor') return { ok: false, message: 'Not a Flow conductor — nothing to submit.' }
3618
3645
  let norm
3619
3646
  try { norm = validatePlanForRuntime(normalizePlanOutput(planText || ''), entry.runtime) }
3620
- catch (e) { return { ok: false, message: `Plan REJECTED: ${e?.message || e}. Re-Write FLOW_PLAN.json with valid JSON a non-empty "tasks" array, every "deps" entry referencing an existing task "key", and no cycles.` } }
3647
+ catch (e) { return { ok: false, message: `Plan REJECTED: ${e?.message || e}. Re-Write FLOW_PLAN.json with valid JSON: a non-empty acyclic tasks array; each builder/fix/scaffold has observable acceptance, bounded nonGoals, and a baseline gate that says what fails/is absent; each review targets exactly one builder contract.` } }
3621
3648
  bcast('flow-plan', { term: id, flowId: entry.flowSessionId, plan: JSON.stringify(norm) }, flowChannel)
3622
3649
  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`)
3623
- return { ok: true, message: `Plan SUBMITTED (${norm.tasks.length} slice${norm.tasks.length === 1 ? '' : 's'}) the room is showing the human an approval card. You are DONE: stop here, do not write anything else, wait for approval (lane dispatch is the room's job).` }
3650
+ return { ok: true, message: `Plan SUBMITTED (${norm.tasks.length} slice${norm.tasks.length === 1 ? '' : 's'}) with gate-first contracts. Record safe reversible assumptions in the summary; user steering remains authoritative. The room is showing approval. You are DONE: stop here and wait for approval.` }
3624
3651
  },
3625
3652
  // FL-B1 (lane side) — a flow lane signals slice-done by Writing FLOW_DONE; the hook
3626
3653
  // routes that Write here (mark_flow_done MCP tool is deferred → ToolSearch → hangs).
3627
- onLaneDone: entry.flowRole === 'builder' ? (async () => ({ ok: true, message: await markFlowDone() })) : null,
3654
+ onLaneDone: entry.flowRole === 'builder' ? (async (raw) => {
3655
+ let baselineEvidence = ''
3656
+ try {
3657
+ const value = JSON.parse(String(raw || '').trim())
3658
+ baselineEvidence = typeof value?.baselineEvidence === 'string' ? value.baselineEvidence : ''
3659
+ } catch { /* legacy FLOW_DONE content remains compatible with legacy tasks */ }
3660
+ return { ok: true, message: await markFlowDone({ baselineEvidence }) }
3661
+ }) : null,
3628
3662
  // FL-B4 — a review lane records its verdict via FLOW_REVIEW.json (revert-on-fail).
3629
3663
  onReviewVerdict: entry.flowRole === 'reviewer' ? onReviewVerdict : null,
3630
3664
  mcpServers: { thinkpool: peekServer },
@@ -3718,12 +3752,12 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3718
3752
  id, runtime: entry.runtime, model: entry.model || model, effort: entry.effort,
3719
3753
  provider: entry.provider, log: entry.log, commands: entry.commands, mode: entry.mode,
3720
3754
  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,
3721
- flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole, flowReviewTarget: entry.flowReviewTarget,
3755
+ flowTaskKey: entry.flowTaskKey, flowTaskContract: entry.flowTaskContract, flowRole: entry.flowRole, flowReviewTarget: entry.flowReviewTarget,
3722
3756
  flowReviewTargets: entry.flowReviewTargets, flowReviewSnapshots: entry.flowReviewSnapshots, flowReviewRound: entry.flowReviewRound,
3723
3757
  dispatchBaseSha: entry.dispatchBaseSha, revertTarget: entry.revertTarget, cwd: entry.cwd,
3724
3758
  managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt,
3725
3759
  reviewSliceRoots: entry.reviewSliceRoots, openedAt: entry.openedAt,
3726
- lastUsage: entry.lastUsage, carryRecap,
3760
+ lastUsage: entry.lastUsage, carryRecap, carryCheckpoint: entry.pendingCheckpoint,
3727
3761
  })
3728
3762
  return sessions.get(id) || null
3729
3763
  },
@@ -3874,6 +3908,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3874
3908
  // mid-turn) flash a brief "Compacting…" beat so it isn't silent — we only learn of it
3875
3909
  // post-hoc, so there's no real duration to animate (Max, 2026-07-02).
3876
3910
  if (evt.kind === 'compaction') {
3911
+ armCompactionCheckpoint(entry)
3877
3912
  if (entry.compacting) {
3878
3913
  // MANUAL /compact: attribute the recap card, then CLEAR the "Compacting…"
3879
3914
  // indicator right here — the compact_boundary milestone IS the "done" signal.
@@ -4245,7 +4280,7 @@ function respawnStructured(id, provider) {
4245
4280
  // openStructured seed from the TARGET provider's configured model, which is the
4246
4281
  // only model this lane was ever asked for. A same-env model change never reaches
4247
4282
  // here — that path is an in-place setModel (see provider-switch).
4248
- const { runtime, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage } = s
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
4249
4284
  // Context-carry (2026-07-08): a provider switch is not an SDK resume — the new backend
4250
4285
  // starts blank mid-conversation. Synthesize a plain-text recap from the VISIBLE log NOW
4251
4286
  // (before teardown) and hand it to the fresh session as its first turn so the agent
@@ -4264,7 +4299,7 @@ function respawnStructured(id, provider) {
4264
4299
  // sessionData() (provider included) synchronously on open, so a bridge restart
4265
4300
  // restores the lane on its CURRENT provider, not the original — and its next
4266
4301
  // announce carries the new provider badge (additive {id,name} projection).
4267
- openStructured({ id, runtime, provider, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage, carryRecap })
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 })
4268
4303
  return true
4269
4304
  }
4270
4305
 
@@ -4969,6 +5004,7 @@ channel
4969
5004
  }
4970
5005
  if (/^\/clear\s*$/.test(text)) {
4971
5006
  s.pendingRecap = null // /clear means forget context — drop any un-fired carry recap
5007
+ s.pendingCheckpoint = null
4972
5008
  s.pendingCodexCompaction = null // explicit clear supersedes a deferred compact
4973
5009
  if (s.runtime === 'codex' || s.runtime === 'hermes') s.session.clearContext?.()
4974
5010
  else s.session.sendTurn(text)
@@ -5041,6 +5077,7 @@ channel
5041
5077
  let sendText = text
5042
5078
  const carried = []
5043
5079
  if (s.pendingRecap) { carried.push(s.pendingRecap); s.pendingRecap = null }
5080
+ if (s.pendingCheckpoint) { carried.push(s.pendingCheckpoint); s.pendingCheckpoint = null }
5044
5081
  if (s.pendingSideContexts?.length) { carried.push(...s.pendingSideContexts); s.pendingSideContexts = [] }
5045
5082
  if (carried.length) { sendText = appendCurrentPersonRequest(carried, text); s.flush?.() }
5046
5083
  // The browser's `hostPath` is display metadata, not host authority. Rebuild
@@ -5236,7 +5273,7 @@ channel
5236
5273
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
5237
5274
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
5238
5275
  // lanes keep their worktree cwd + the ability to mark done.
5239
- 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, 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,
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,
5240
5277
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
5241
5278
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
5242
5279
  // (mid-turn needs auto-resume; flow needs its lane live).
@@ -5511,7 +5548,7 @@ flowChannel
5511
5548
  // Model tiers (2026-07-03-flow-lane-model-tiers): pick the lane's brain by slice_type
5512
5549
  // (scaffold→sonnet, feature/fix/review→opus; env can blanket-override or `inherit` to
5513
5550
  // restore today's exact behavior). undefined → no model key passed (openStructured default).
5514
- openStructured({ id: laneId, runtime: flowRuntime, cwd: dir, model: flowLaneModelFor({ sliceType: t.slice_type, runtime: flowRuntime, catalog: flowCatalog }), mode: structuredModeForSlice(flowRuntime, { mode: 'bypassPermissions', flowRole: isReview ? 'reviewer' : 'builder' }), rolePrompt: laneRolePrompt, flowSessionId: payload.flowId, flowTaskKey: t.task_key, flowRole: isReview ? 'reviewer' : 'builder', flowReviewTargets: isReview ? (t.deps || []) : [], flowReviewSnapshots, dispatchBaseSha, revertTarget: redispatch?.revertTarget || null, spawnedBy: `flow:${payload.flowId}`, resume: redispatch?.resumeSessionId || undefined, reviewSliceRoots })
5551
+ openStructured({ id: laneId, runtime: flowRuntime, cwd: dir, model: flowLaneModelFor({ sliceType: t.slice_type, runtime: flowRuntime, catalog: flowCatalog }), mode: structuredModeForSlice(flowRuntime, { mode: 'bypassPermissions', flowRole: isReview ? 'reviewer' : 'builder' }), rolePrompt: laneRolePrompt, flowSessionId: payload.flowId, flowTaskKey: t.task_key, flowTaskContract: t.contract || null, flowRole: isReview ? 'reviewer' : 'builder', flowReviewTargets: isReview ? (t.deps || []) : [], flowReviewSnapshots, dispatchBaseSha, revertTarget: redispatch?.revertTarget || null, spawnedBy: `flow:${payload.flowId}`, resume: redispatch?.resumeSessionId || undefined, reviewSliceRoots })
5515
5552
  const le = sessions.get(laneId)
5516
5553
  if (le) {
5517
5554
  // S4 — stamp the surviving revert target on the resumed lane so a later reviewer still
@@ -5535,6 +5572,9 @@ flowChannel
5535
5572
  t.deps.map((dep) => { const ws = worktreeSpec({ flowId: payload.flowId, taskKey: dep }); return ` - ${dep}: dir ${ws.dir} (branch ${ws.branch})` }).join('\n') + '\n'
5536
5573
  : '') +
5537
5574
  `ACCEPTANCE to INDEPENDENTLY verify: ${t.acceptance || t.title}\n` +
5575
+ `INHERITED NON-GOALS (must remain untouched): ${(t.contract?.nonGoals || []).join(' | ') || '(legacy task: none recorded)'}\n` +
5576
+ `INHERITED BASELINE GATE: ${t.contract?.baseline?.gate || '(legacy task: none recorded)'}\n` +
5577
+ `BUILDER BASELINE EVIDENCE (verify; never invent another baseline): ${t.contract?.baseline?.evidence || '(missing — reject unless this is a legacy task)'}\n` +
5538
5578
  `\nProject (context): ${payload.flowPrompt || ''}\n\n` +
5539
5579
  (flowRuntime === 'codex' || flowRuntime === 'hermes'
5540
5580
  ? `Review it without mutating the builder worktree. For write-producing install/build/test commands, copy its source into scratch under your own current worktree first. Submit the structured verdict with the ThinkPool submit_flow_review MCP tool; never write FLOW_REVIEW.json or call mark_flow_done.`
@@ -5543,6 +5583,9 @@ flowChannel
5543
5583
  `TITLE: ${t.title}\n` +
5544
5584
  `SCOPE (files you OWN — edit ONLY these): ${t.scope || '(none stated)'}\n` +
5545
5585
  `ACCEPTANCE (done = this runs + proves it): ${t.acceptance || '(meet the title)'}\n` +
5586
+ `NON-GOALS (must remain untouched): ${(t.contract?.nonGoals || []).join(' | ') || '(legacy task: none recorded)'}\n` +
5587
+ `BASELINE GATE (before edits, observe this real failure/absence): ${t.contract?.baseline?.gate || '(legacy task: none recorded)'}\n` +
5588
+ `BASELINE EVIDENCE: run/observe the pre-edit gate, then retain one real bounded command/behavior receipt for mark_flow_done; never fabricate a RED sentence.\n` +
5546
5589
  (t.deps && t.deps.length ? `DEPENDS ON (already built): ${t.deps.join(', ')}\n` : '') +
5547
5590
  // S1 (context-offload) — inject the BOUNDED cross-wave context (loadDigest
5548
5591
  // enforces CEILING) instead of accumulating full lane transcripts. Filter to
@@ -594,7 +594,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
594
594
  // that Write to the done logic and feed the result back; the file is never written.
595
595
  if (onLaneDone && (toolName === 'Write' || toolName === 'Edit') && /(?:^|[/\\])FLOW_DONE(\.[a-zA-Z0-9]+)?$/.test(toolInput?.file_path || '')) {
596
596
  let res = { ok: false, message: 'done signal failed' }
597
- try { res = (await onLaneDone()) || res } catch (e) { res = { ok: false, message: `done signal failed: ${e?.message || e}` } }
597
+ try { res = (await onLaneDone(toolInput?.content ?? toolInput?.new_string ?? '')) || res } catch (e) { res = { ok: false, message: `done signal failed: ${e?.message || e}` } }
598
598
  return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: res.message } }
599
599
  }
600
600
  // S5 (slice 1b) — REVIEW-LANE WRITE-BLOCK. A review lane is ADVERSARIAL: it reads +
package/event-id.mjs CHANGED
@@ -546,4 +546,4 @@ export function codexUsageReportLine (sessionModel, snapshot) {
546
546
  * (`from './event-id.mjs'`) working untouched, including bridge/recap.test.mjs.
547
547
  *
548
548
  * recap.mjs must stay in bridge/package.json `files` — event-id.mjs imports it. */
549
- export { CURRENT_PERSON_REQUEST_MARKER, RECAP_CAP, appendCurrentPersonRequest, buildRecapFromLog } from './recap.mjs'
549
+ export { CHECKPOINT_CAP, CURRENT_PERSON_REQUEST_MARKER, RECAP_CAP, appendCurrentPersonRequest, buildCheckpointFromLog, buildRecapFromLog, recapHasCheckpoint, resolveCheckpointCarry } from './recap.mjs'
@@ -38,7 +38,7 @@ export { normalizePlanOutput }
38
38
  // moat, partition by file ownership, plan-in-store-not-context, right-size the slice
39
39
  // count (3 focused beats 7 scattered), explicit acyclic deps.
40
40
  export const FLOW_CONDUCTOR_PROMPT = [
41
- 'THINKPOOL FLOW CONDUCTOR — HARD RULES (read first, no exceptions): You are a DECOMPOSITION-ONLY conductor. Your ONLY output action is using the Write tool ONCE to write your task-graph JSON to the file named exactly FLOW_PLAN.json. That single Write IS how you submit your plan — the room ingests it directly. Do NOT use ExitPlanMode and do NOT call submit_flow_plan (both hang here). Do NOT write any other file and do NOT write a plan-document markdown. You MUST NOT write or edit files, MUST NOT run bash/git, MUST NOT call AskUserQuestion, and MUST NOT build the project yourself — those are blocked and will just stall you. You do NOT build; you DECOMPOSE so the lanes build. A fully-specified build is STILL decomposed, never built here. If a detail is ambiguous, make a reasonable assumption and note it in the plan summary do NOT ask. Think, then call submit_flow_plan. That is the whole job.',
41
+ 'THINKPOOL FLOW CONDUCTOR — HARD RULES (read first, no exceptions): You are a DECOMPOSITION-ONLY conductor. Your ONLY output action is using the Write tool ONCE to write your task-graph JSON to the file named exactly FLOW_PLAN.json. That single Write IS how you submit your plan — the room ingests it directly. Do NOT use ExitPlanMode and do NOT call submit_flow_plan (both hang here). Do NOT write any other file and do NOT write a plan-document markdown. You MUST NOT write or edit files, MUST NOT run bash/git, MUST NOT call AskUserQuestion, and MUST NOT build the project yourself — those are blocked and will just stall you. You do NOT build; you DECOMPOSE so the lanes build. A fully-specified build is STILL decomposed, never built here. When a detail is ambiguous or the user is unsure, choose the least-invasive reversible default, record that assumption in the summary, and continue. Never ask merely because the user is unsure; ask only when a choice would materially expand scope or authority. User steering always wins. Think, then call submit_flow_plan. That is the whole job.',
42
42
 
43
43
  'DO NOT SPAWN SUBAGENTS. You MUST NOT use the Task/Agent tool or launch Explore / sub-agents — every subagent spawn pops a permission card and stalls the whole flow (this is the #1 way the conductor hangs asking for permission). You almost never need to look at code to decompose: decompose from the build request itself. If you genuinely must inspect something first, read it YOURSELF with Read/Grep/Glob (those are allowed without a prompt) — never delegate it to a spawned agent. Decompose, then Write FLOW_PLAN.json.',
44
44
 
@@ -52,10 +52,12 @@ export const FLOW_CONDUCTOR_PROMPT = [
52
52
 
53
53
  'ACCEPTANCE CRITERIA per slice: how does the lane know it is DONE AND RUNNING? A lane is done when its slice runs in its WebContainer and meets the criteria — not when it has merely written code. State the observable, runnable proof (e.g. "GET /api/todos returns 200 + []", "submitting the form adds a row to the list that persists across reload").',
54
54
 
55
+ 'GATE-FIRST CONTRACT per builder/fix/scaffold slice: include an explicit bounded `nonGoals` list (what this slice will not change) and a `baseline` gate describing the real pre-edit condition that must FAIL or be ABSENT. The builder must observe that gate before editing and later supply a bounded evidence receipt; do not invent a RED sentence. A review task must depend on exactly one builder/fix task and inherits that target contract — it verifies the same acceptance, non-goals, and baseline evidence instead of inventing a second baseline.',
56
+
55
57
  'RIGHT-SIZE THE SLICE COUNT. Bias to FEW slices that each carry real weight, not many tiny ones. For a small app: 2-3 slices. Concurrent lanes are bounded (a low dispatch ceiling), so a flat fan-out of well-chosen slices beats a sprawling one. Three focused slices outperform seven scattered ones.',
56
58
 
57
59
  'SUBMIT BY WRITING FLOW_PLAN.json — when the decomposition is ready, use the Write tool with file_path "FLOW_PLAN.json" and content set to your plan as ONE JSON object. The room validates the plan is a real DAG (acyclic, all deps resolve), persists it to the task-graph store, and shows a plan-approval card. A malformed plan is rejected with the reason as the tool result — fix it and Write FLOW_PLAN.json again. Do not also narrate the plan in prose. The exact shape:',
58
- '{ "summary": "<one line: what we are building>", "tasks": [ { "key": "<short-unique-kebab-key>", "title": "<imperative title>", "scope": "<files/components this lane owns>", "acceptance": "<runnable proof it is done>", "deps": ["<other-task-key>", ...], "sliceType": "feature|scaffold|review|fix" } ] }',
60
+ '{ "summary": "<one line: what we are building; assumptions: …>", "tasks": [ { "key": "<short-unique-kebab-key>", "title": "<imperative title>", "scope": "<files/components this lane owns>", "acceptance": "<runnable proof it is done>", "nonGoals": ["<bounded exclusion>"], "baseline": { "gate": "<what currently fails/is absent before edits>", "evidence": "<optional observed command/output receipt>" }, "deps": ["<other-task-key>", ...], "sliceType": "feature|scaffold|review|fix" } ] }',
59
61
  '`key` values are stable short kebab-case identifiers. `deps` references other tasks\' keys. The content of FLOW_PLAN.json must be ONLY the JSON object (no surrounding prose, no markdown fences).',
60
62
 
61
63
  'PLAN LIVES IN THE STORE, NOT THE CHAT. Your JSON plan is the artifact — it gets persisted to the task-graph (flow_sessions / flow_tasks) and rendered as an approval card. Do not ALSO narrate a long plan in prose. You may add 1-2 sentences framing the decomposition choice (why these slices, what was coupled), nothing more.',
@@ -65,7 +67,8 @@ export const FLOW_CONDUCTOR_PROMPT = [
65
67
 
66
68
  export const FLOW_CODEX_CONDUCTOR_PROMPT = [
67
69
  'THINKPOOL FLOW CONDUCTOR — you are decomposition-only. Do not build, edit files, run commands, or spawn agents. Read only when needed to make the decomposition concrete.',
68
- 'Turn the request into a small acyclic graph of independently runnable slices with disjoint file ownership. Each task needs key, title, scope, acceptance, deps, and sliceType (scaffold, feature, fix, or review). Review is intelligence-sensitive and must never be assigned a cheap/mechanical tier. Every review task must depend on exactly ONE builder task; express builder -> review -> downstream explicitly in the DAG.',
70
+ 'Turn the request into a small acyclic graph of independently runnable slices with disjoint file ownership. Each builder/fix/scaffold task needs key, title, scope, observable acceptance, bounded nonGoals, and a baseline {gate,evidence?}; the gate states what currently fails or is absent before edits. Every review task depends on exactly ONE builder task and inherits/verifies that builder contract. Review is intelligence-sensitive and must never be assigned a cheap/mechanical tier.',
71
+ 'When details are ambiguous or the user says they are unsure, choose a safe reversible default, record it in summary.assumptions text, and continue. Do not ask merely because of uncertainty; ask only when a choice materially expands scope or authority. Honor later user steering.',
69
72
  'Submit the finished graph by calling the ThinkPool submit_flow_plan MCP tool with one JSON object encoded in its plan argument. Do not write FLOW_PLAN.json and do not use ExitPlanMode. The MCP submission is your only completion path; after it succeeds, stop and wait for the room.',
70
73
  ].join(' ')
71
74
 
@@ -95,9 +98,11 @@ export const FLOW_LANE_PROMPT = [
95
98
 
96
99
  'OWN YOUR FILES ONLY. The `scope` names the files/components this lane OWNS — they are DISJOINT from every other lane. Edit ONLY those. Touching another lane\'s files is a collision that breaks the merge — never do it. If you genuinely need a file outside your scope, STOP and say so instead of editing it.',
97
100
 
101
+ 'GATE BEFORE EDITS. First reproduce the assigned baseline gate in the real pre-edit state. Record only what you actually observed (a command/behavior + bounded receipt); never fabricate a RED sentence. Preserve the assigned non-goals. If a bounded detail is ambiguous, take the least-invasive reversible default and state it in your receipt; ask only if the choice would materially expand scope or authority.',
102
+
98
103
  'DONE MEANS RUNS. You are not done when you\'ve written code — you\'re done when your slice RUNS and meets the `acceptance` criteria (the observable, runnable proof). Verify it yourself before declaring done. State the proof (the command, the output) when you finish.',
99
104
 
100
- 'COMMIT WHEN DONE, THEN SIGNAL. You are in your own git worktree on your own branch. When your slice meets acceptance — it RUNS and you have verified the proof — commit it atomically (one focused commit) with `git` via the Bash tool; that commit is the revert target if review later rejects it. THEN, as your VERY LAST action, signal completion by using the Write tool to write a file named exactly FLOW_DONE in your worktree root (any content, e.g. "done"). That single Write IS the done signal — it records your commit + tells the room the slice is done (unblocking dependents). Do NOT use mark_flow_done (it hangs here). Do not push. Do NOT write FLOW_DONE before your slice actually runs, meets acceptance, AND is committed.',
105
+ 'COMMIT WHEN DONE, THEN SIGNAL. You are in your own git worktree on your own branch. When your slice meets acceptance — it RUNS and you have verified the proof — commit it atomically (one focused commit) with `git` via the Bash tool; that commit is the revert target if review later rejects it. THEN, as your VERY LAST action, signal completion by using the Write tool to write a file named exactly FLOW_DONE in your worktree root with content `{"baselineEvidence":"<real bounded pre-edit command/behavior receipt>"}`. That single Write IS the done signal — it records your commit + tells the room the slice is done (unblocking dependents). Do NOT use mark_flow_done (it hangs here). Do not push. Do NOT write FLOW_DONE before your slice actually runs, meets acceptance, records real baseline evidence, AND is committed.',
101
106
 
102
107
  'STAY IN YOUR LANE. Do NOT spawn further lanes (one hop only — the fork-bomb breaker). Do NOT edit other worktrees. If you\'re blocked on a dependency that isn\'t ready, say so + stop — another lane is building it.',
103
108
 
@@ -106,8 +111,9 @@ export const FLOW_LANE_PROMPT = [
106
111
 
107
112
  export const FLOW_CODEX_LANE_PROMPT = [
108
113
  'THINKPOOL FLOW — you are one builder lane in an ensemble. The conductor gives you one runnable slice with disjoint scope. Edit only that scope and never touch another lane or worktree.',
114
+ 'Before editing, reproduce the assigned baseline gate in the real pre-edit state. Preserve the explicit non-goals. Record only an actual bounded command/behavior receipt; never manufacture a RED claim. For a bounded ambiguity, take the least-invasive reversible default and state it in that receipt; ask only if a choice would materially expand scope or authority.',
109
115
  'Done means the slice runs and its acceptance proof has been observed. Diagnose before changing code, verify with the real command or behavior, and make one focused git commit in your assigned worktree. Do not push.',
110
- 'After the commit and verification succeed, call the ThinkPool mark_flow_done MCP tool exactly once. Do not write FLOW_DONE. The MCP call is the completion signal that records your commit and unblocks dependent slices.',
116
+ 'After the commit and verification succeed, call the ThinkPool mark_flow_done MCP tool exactly once with the real `baselineEvidence` receipt. Do not write FLOW_DONE. The MCP call is the completion signal that records your commit and unblocks dependent slices.',
111
117
  'Do not spawn more lanes. If the scope is unsafe or a dependency is missing, report the blocker instead of editing outside your ownership.',
112
118
  ].join(' ')
113
119
 
package/flow-review.mjs CHANGED
@@ -17,11 +17,11 @@ import { reviewReflectionDecision, REVIEW_DEFAULTS } from './flow-review-reflect
17
17
  // The reviewer lane's rolePrompt (via startClaudeSession). Mirrors FLOW_LANE_PROMPT's
18
18
  // join(' ') style. This lane is ADVERSARIAL — its job is to disprove "done", not to build.
19
19
  export const FLOW_REVIEWER_PROMPT = [
20
- 'THINKPOOL FLOW — you are an ADVERSARIAL REVIEWER in an ensemble build. You did NOT build this slice. Another lane did, and claims it is done. Your job is to TRY TO BREAK IT — independently verify whether its acceptance criteria are ACTUALLY met. The slice you are reviewing (its scope, its acceptance criteria, and its worktree/branch) arrives as your first message.',
20
+ 'THINKPOOL FLOW — you are an ADVERSARIAL REVIEWER in an ensemble build. You did NOT build this slice. Another lane did, and claims it is done. Your job is to TRY TO BREAK IT — independently verify whether its acceptance criteria are ACTUALLY met. The slice you are reviewing (its scope, acceptance criteria, inherited non-goals, baseline gate/evidence, and worktree/branch) arrives as your first message.',
21
21
 
22
22
  'RUN IT, DON\'T TRUST IT. Check out the lane\'s worktree/branch and RUN the slice yourself. "It compiles" is not "it works". Reproduce the acceptance proof with your own hands — run the command, hit the endpoint, submit the form, reload the page. If you cannot independently reproduce the acceptance criteria, that is a REJECT.',
23
23
 
24
- 'HUNT FOR FAILURE. Look where the builder didn\'t: edge cases, empty/malformed input, the second click, the reload, concurrent use, the error path. Check the slice didn\'t regress anything outside its acceptance line. A green happy-path is the floor, not the bar.',
24
+ 'VERIFY THE INHERITED CONTRACT. Check the builder\'s baseline receipt against the real pre-edit claim where feasible; it must be an observed command/behavior, not a fabricated sentence. Check the implementation did not cross its inherited non-goals. Do not invent another baseline. HUNT FOR FAILURE: edge cases, empty/malformed input, the second click, the reload, concurrent use, and the error path. A green happy-path is the floor, not the bar.',
25
25
 
26
26
  'DEFAULT TO REJECT. If you cannot independently verify EVERY acceptance criterion — reject. Ambiguity, "probably fine", "looks right", an untestable claim, a flaky run — all REJECT. The cost of a false pass (a broken slice merged into the assembly) is far higher than a re-run.',
27
27
 
@@ -35,7 +35,7 @@ export const FLOW_REVIEWER_PROMPT = [
35
35
  export const FLOW_CODEX_REVIEWER_PROMPT = [
36
36
  'THINKPOOL FLOW REVIEW — you are a non-mutating adversarial reviewer. Your native sandbox lets you write only inside your OWN disposable reviewer worktree. The reviewed builder worktree is outside that writable root: never edit, commit, revert, install into, or repair it.',
37
37
  'For any install/build/test that produces files, copy the reviewed source into a scratch directory under your own current worktree first, then run the write-producing command against that scratch copy. Read-only inspection may target the reviewed worktree directly.',
38
- 'Independently reproduce every acceptance criterion and hunt edge cases, reloads, repeated actions, concurrency, malformed input, and error paths. A green happy path is only the floor. If a claim cannot be reproduced, reject it with the exact command/output.',
38
+ 'Independently reproduce every acceptance criterion, verify the inherited baseline evidence and non-goals without inventing a second baseline, and hunt edge cases, reloads, repeated actions, concurrency, malformed input, and error paths. A green happy path is only the floor. If a claim cannot be reproduced, reject it with the exact command/output.',
39
39
  'Submit each review round by calling the ThinkPool submit_flow_review MCP tool with a JSON verdict: {"pass":boolean,"reasons":["specific evidence"],"taskKey":"allowed reviewed task","exhausted":boolean}. Do not write FLOW_REVIEW.json and do not call mark_flow_done. A pass without exhausted=true may trigger another bounded hunt round; a failure triggers the room revert path.',
40
40
  ].join(' ')
41
41
 
@@ -43,10 +43,82 @@ export const SLICE_TYPE = {
43
43
  fix: 'fix',
44
44
  }
45
45
 
46
+ // Gate-first Flow contracts are stored in flow_tasks.contract (JSONB). Keep every
47
+ // free-text field bounded and single-line: this payload crosses the terminal MCP
48
+ // boundary and is later injected into builder/reviewer prompts.
49
+ export const FLOW_CONTRACT_LIMITS = Object.freeze({
50
+ maxNonGoals: 6,
51
+ maxNonGoalChars: 240,
52
+ maxBaselineGateChars: 600,
53
+ maxBaselineEvidenceChars: 1400,
54
+ })
55
+
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 hasDisallowedControl = (value) => {
58
+ for (const char of value) {
59
+ const code = char.charCodeAt(0)
60
+ if (code === 0x7f || (code < 0x20 && code !== 0x09)) return true
61
+ }
62
+ return false
63
+ }
64
+
65
+ function boundedLine (value, label, max, { required = false } = {}) {
66
+ if (value == null || value === '') {
67
+ if (required) throw new Error(`${label} is required`)
68
+ return ''
69
+ }
70
+ if (typeof value !== 'string') throw new Error(`${label} must be a string`)
71
+ const text = value.trim()
72
+ if (!text) {
73
+ if (required) throw new Error(`${label} is required`)
74
+ return ''
75
+ }
76
+ if (text.length > max) throw new Error(`${label} exceeds ${max} characters`)
77
+ if (hasDisallowedControl(text)) throw new Error(`${label} must be safe single-line text`)
78
+ return text
79
+ }
80
+
81
+ export function normalizeBaselineEvidence (value) {
82
+ return boundedLine(value, 'baseline evidence receipt', FLOW_CONTRACT_LIMITS.maxBaselineEvidenceChars, { required: true })
83
+ }
84
+
85
+ function contractSource (task) {
86
+ const source = task?.contract && typeof task.contract === 'object' && !Array.isArray(task.contract)
87
+ ? { ...task.contract }
88
+ : {}
89
+ if (source.nonGoals === undefined) source.nonGoals = task?.nonGoals ?? task?.non_goals
90
+ if (source.baseline === undefined) source.baseline = task?.baseline ?? task?.baselineGate ?? task?.baseline_gate
91
+ return source
92
+ }
93
+
94
+ // Strict for new terminal submissions. `legacy:true` is restore-only: old persisted
95
+ // plans predate this contract and must remain runnable, but they can never be used to
96
+ // submit a new incomplete plan.
97
+ export function normalizeTaskContract (task, { sliceType, legacy = false } = {}) {
98
+ const source = contractSource(task)
99
+ if (sliceType === SLICE_TYPE.review) return null
100
+ const required = !legacy
101
+ const rawNonGoals = source.nonGoals
102
+ const values = rawNonGoals == null ? [] : (Array.isArray(rawNonGoals) ? rawNonGoals : [rawNonGoals])
103
+ if (values.length > FLOW_CONTRACT_LIMITS.maxNonGoals) throw new Error(`non-goals exceeds ${FLOW_CONTRACT_LIMITS.maxNonGoals} items`)
104
+ const nonGoals = values.map((value) => boundedLine(value, 'non-goal', FLOW_CONTRACT_LIMITS.maxNonGoalChars, { required: true }))
105
+ if (required && !nonGoals.length) throw new Error('explicit non-goals are required for builder/fix/scaffold tasks')
106
+
107
+ const rawBaseline = source.baseline
108
+ const baselineObject = typeof rawBaseline === 'string' ? { gate: rawBaseline } : rawBaseline
109
+ if (baselineObject != null && (typeof baselineObject !== 'object' || Array.isArray(baselineObject))) throw new Error('baseline gate must be a string or object')
110
+ const gate = boundedLine(baselineObject?.gate ?? baselineObject?.expectation, 'baseline gate', FLOW_CONTRACT_LIMITS.maxBaselineGateChars, { required })
111
+ if (gate && !BASELINE_NEGATIVE_SIGNAL.test(gate)) throw new Error('baseline gate must state what fails or is absent before implementation')
112
+ const evidence = baselineObject?.evidence == null || baselineObject?.evidence === ''
113
+ ? ''
114
+ : normalizeBaselineEvidence(baselineObject.evidence)
115
+ return { nonGoals, baseline: gate ? { gate, evidence } : null }
116
+ }
117
+
46
118
  // A FlowTask slice. Each is RUNNABLE — a lane can build + run + self-correct it in
47
119
  // its own WebContainer (research: the runtime is the moat). Partition by file
48
120
  // ownership (scope) so two lanes never edit the same file (a branch is NOT isolation).
49
- export function makeTask ({ key, title, scope = '', acceptance = '', deps = [], sliceType = SLICE_TYPE.feature }) {
121
+ export function makeTask ({ key, title, scope = '', acceptance = '', deps = [], sliceType = SLICE_TYPE.feature, contract = null }) {
50
122
  if (!key || typeof key !== 'string') throw new Error('task key required (string)')
51
123
  if (!title) throw new Error('task title required')
52
124
  if (!Array.isArray(deps)) throw new Error('deps must be an array of task keys')
@@ -56,6 +128,7 @@ export function makeTask ({ key, title, scope = '', acceptance = '', deps = [],
56
128
  title,
57
129
  scope, // files/components this lane OWNS (disjoint from other slices)
58
130
  acceptance, // runnable proof the lane + reviewer use to call it done
131
+ contract, // bounded gate-first contract; null only for legacy/restored rows
59
132
  deps, // task keys this depends on (must form a DAG)
60
133
  sliceType,
61
134
  status: TASK_STATUS.pending,
@@ -142,7 +215,7 @@ export function makeFlowSession ({ prompt, mode = FLOW_MODE.guide, sessionRef =
142
215
  // Accepts a parsed object OR a JSON string (optionally ```json-fenced). Throws if the
143
216
  // tasks don't form a real DAG. Lives in this shared module so neither the bridge nor
144
217
  // the client has to import across the bridge↔src boundary.
145
- export function normalizePlanOutput (raw) {
218
+ export function normalizePlanOutput (raw, { legacy = false } = {}) {
146
219
  let obj = raw
147
220
  if (typeof raw === 'string') {
148
221
  let s = raw.trim()
@@ -167,9 +240,24 @@ export function normalizePlanOutput (raw) {
167
240
  acceptance: String(t.acceptance ?? ''),
168
241
  deps: Array.isArray(t.deps) ? t.deps.map(String) : [],
169
242
  sliceType: Object.values(SLICE_TYPE).includes(sliceType) ? sliceType : SLICE_TYPE.feature,
243
+ contract: normalizeTaskContract(t, { sliceType: Object.values(SLICE_TYPE).includes(sliceType) ? sliceType : SLICE_TYPE.feature, legacy }),
170
244
  })
171
245
  })
172
246
  validateDag(tasks)
247
+ for (const task of tasks) {
248
+ if (task.sliceType !== SLICE_TYPE.review) continue
249
+ // New review tasks verify exactly one builder contract. Legacy persisted reviews
250
+ // keep their prior multi-target shape for restore compatibility only.
251
+ if (legacy) continue
252
+ if (task.deps.length !== 1) throw new Error(`review task "${task.key}" must depend on exactly one builder task to inherit its contract`)
253
+ const target = tasks.find((candidate) => candidate.key === task.deps[0])
254
+ if (!target || target.sliceType === SLICE_TYPE.review) throw new Error(`review task "${task.key}" must target a builder/fix/scaffold task`)
255
+ task.contract = {
256
+ nonGoals: [...(target.contract?.nonGoals || [])],
257
+ baseline: target.contract?.baseline ? { ...target.contract.baseline } : null,
258
+ inheritedFrom: target.key,
259
+ }
260
+ }
173
261
  return { summary, tasks }
174
262
  }
175
263
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.360",
3
+ "version": "0.7.361",
4
4
  "description": "Connect Claude Code, Codex, or Hermes on your computer to a Thinkpool Code room.",
5
5
  "type": "module",
6
6
  "bin": {
package/recap.mjs CHANGED
@@ -20,6 +20,13 @@
20
20
 
21
21
  // Default hard cap for the context-carry recap (chars of transcript body, framing extra).
22
22
  export const RECAP_CAP = 20000
23
+ // A checkpoint is deliberately much smaller than its enclosing recap. It is a
24
+ // deterministic index over visible text, not a second transcript or an agent
25
+ // summary. Keeping it bounded independently makes it safe to persist as the
26
+ // pending carry across a reset/restart.
27
+ export const CHECKPOINT_CAP = 6000
28
+ const CHECKPOINT_START = '--- deterministic checkpoint (visible text only; evidence, not hidden state) ---'
29
+ const CHECKPOINT_END = '--- end deterministic checkpoint ---'
23
30
 
24
31
  // A fresh human turn can race the session-init event that would otherwise send a
25
32
  // pending recap by itself. When that happens, the current request must have an
@@ -28,10 +35,156 @@ export const CURRENT_PERSON_REQUEST_MARKER = '--- CURRENT PERSON REQUEST (author
28
35
 
29
36
  // Recaps are an outbound context source. Keep the same privacy boundary as the
30
37
  // bridge manifest without importing Node-only hashing into the browser bundle.
31
- const UNSAFE_CONTEXT = /(?:\/home\/|\/users\/|\/private\/|\/tmp\/|[a-z]:\\|\\\\|sk[_-][a-z0-9_-]{8,}|gsk_[a-z0-9_-]{8,}|bearer\s+[a-z0-9._-]{8,}|BEGIN (?:RSA |OPENSSH )?PRIVATE KEY|raw transcript|tool args?|chain[ -]of[ -]thought|hidden reasoning)/i
38
+ // A visible turn containing an unsafe span is omitted whole. Partial redaction
39
+ // could preserve enough neighboring material to reconstruct a credential or
40
+ // host location, so this boundary deliberately fails closed.
41
+ const HOST_PATH = /(?:file:\/\/|[a-z]:\\|\\\\|(?:^|[^a-z0-9/])(?:~[\\/]|\/(?:users|home|private|tmp|var\/folders|volumes|root|workspace|workspaces|mnt|srv|opt|etc)\/))/i
42
+ // Traversal is a path segment, not a path prefix. Keep it separate from
43
+ // HOST_PATH so nested forms such as `foo/../secret` and `foo\..\secret`
44
+ // cannot bypass a boundary rule intended for absolute path roots.
45
+ const PATH_TRAVERSAL = /(?:^|[^a-z0-9.])\.\.(?:[\\/]|$)/i
46
+ 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
47
+ const SECRET_ASSIGNMENT = /(?:[a-z0-9]+[_ -])*(?:token|secret(?:[_ -]access[_ -]key)?|password|authorization|api[_ -]?key|private[_ -]?key|access[_ -]?key(?:[_ -]?id)?)\s*[:=]\s*\S{4,}/i
48
+ const PROHIBITED_CONTEXT = /(?:BEGIN (?:RSA |OPENSSH )?PRIVATE KEY|raw transcript|tool args?|chain[ -]of[ -]thought|hidden reasoning|system prompt|environment dump|provider key)/i
32
49
  const safeRecapText = (value) => {
33
50
  const text = String(value || '').trim()
34
- return text && !UNSAFE_CONTEXT.test(text) ? text : ''
51
+ return text && !HOST_PATH.test(text) && !PATH_TRAVERSAL.test(text) && !SECRET_VALUE.test(text) && !SECRET_ASSIGNMENT.test(text) && !PROHIBITED_CONTEXT.test(text) ? text : ''
52
+ }
53
+
54
+ const clip = (value, cap) => {
55
+ const text = String(value || '').trim().replace(/\s+/g, ' ')
56
+ if (!text) return ''
57
+ return text.length <= cap ? text : `${text.slice(0, Math.max(0, cap - 1)).trimEnd()}…`
58
+ }
59
+
60
+ const visibleTurns = (log) => {
61
+ if (!Array.isArray(log)) return []
62
+ const turns = []
63
+ for (const event of log) {
64
+ if (!event || typeof event !== 'object') continue
65
+ if (event.kind === 'you') {
66
+ const text = safeRecapText(event.text)
67
+ if (text) turns.push({ who: 'person', text })
68
+ continue
69
+ }
70
+ if (event.kind !== 'assistant') continue
71
+ const text = (Array.isArray(event.blocks) ? event.blocks : [])
72
+ .filter((block) => block?.type === 'text')
73
+ .map((block) => safeRecapText(block.text))
74
+ .filter(Boolean)
75
+ .join('\n')
76
+ if (text) turns.push({ who: 'assistant', text })
77
+ }
78
+ return turns
79
+ }
80
+
81
+ const unique = (values, cap = 6) => [...new Set(values.filter(Boolean))].slice(0, cap)
82
+ const sentenceCandidates = (turns, matcher, cap = 4) => unique(
83
+ turns.flatMap((turn) => turn.text.split(/(?<=[.!?])\s+|\n+/)
84
+ .map((line) => clip(line, 500))
85
+ .filter((line) => matcher.test(line))),
86
+ cap,
87
+ )
88
+
89
+ const publicReceipts = (turns) => {
90
+ const receipts = []
91
+ for (const { text } of turns) {
92
+ // Never infer a receipt from an unsafe source turn; safeRecapText already
93
+ // rejected host paths, credential-shaped values, and hidden/tool text.
94
+ for (const sha of text.matchAll(/\b[0-9a-f]{7,40}\b/gi)) receipts.push(`commit ${sha[0]}`)
95
+ for (const file of text.matchAll(/(?<![\w/])(?:[\w.-]+\/)+[\w.-]+\.(?:[cm]?[jt]sx?|json|md|css|html|py|sh|yml|yaml)\b/g)) receipts.push(`file ${file[0]}`)
96
+ for (const url of text.matchAll(/https?:\/\/[^\s)\]}>]+/gi)) {
97
+ const candidate = safeRecapText(url[0])
98
+ if (candidate) receipts.push(`URL ${candidate}`)
99
+ }
100
+ }
101
+ return unique(receipts, 8)
102
+ }
103
+
104
+ /*
105
+ * Build a small, explicit resume checkpoint from *only* the visible person and
106
+ * assistant text that buildRecapFromLog is allowed to carry. This intentionally
107
+ * does not read tool calls/results, thinking, runtime state, files, or external
108
+ * systems. It is heuristic evidence, so labels stay conservative: absence is
109
+ * stated as "not established" rather than invented work/completion.
110
+ */
111
+ export function buildCheckpointFromLog(log, cap = CHECKPOINT_CAP) {
112
+ const budget = Math.max(0, Number.isFinite(cap) ? Math.floor(cap) : 0)
113
+ if (!budget) return ''
114
+ const turns = visibleTurns(log)
115
+ if (!turns.length) return ''
116
+ const people = turns.filter((turn) => turn.who === 'person')
117
+ const assistants = turns.filter((turn) => turn.who === 'assistant')
118
+ const objective = clip(people.at(-1)?.text || 'not established from visible text', 1400)
119
+ const recent = clip(assistants.at(-1)?.text || turns.at(-1)?.text || 'not established from visible text', 1400)
120
+ const blockers = sentenceCandidates(turns, /\b(block(?:ed|er|ing)?|cannot|can['’]?t|unable|waiting|stuck|failed|failure|error|unverified)\b/i)
121
+ const attempted = sentenceCandidates(assistants, /\b(implemented|added|changed|updated|fixed|ran|tested|checked|inspected|tried|attempted|committed|built)\b/i)
122
+ const completed = sentenceCandidates(assistants, /\b(done|complete(?:d)?|implemented|fixed|passing|passed|succeeded|shipped)\b/i)
123
+ const next = clip(
124
+ people.length > 1 ? people.at(-1)?.text :
125
+ sentenceCandidates(assistants, /\b(next|remaining|follow[- ]?up|todo|will)\b/i, 1)[0] || 'not established from visible text',
126
+ 1000,
127
+ )
128
+ const lines = [
129
+ CHECKPOINT_START,
130
+ `Objective / live request: ${objective}`,
131
+ `Current step / recent work: ${recent}`,
132
+ `Blockers: ${blockers.length ? blockers.join(' | ') : 'none stated in visible text'}`,
133
+ `Attempted / completed actions: ${attempted.length || completed.length ? unique([...attempted, ...completed], 6).join(' | ') : 'not established from visible text'}`,
134
+ `Receipts: ${publicReceipts(turns).join(' | ') || 'none safely detected'}`,
135
+ `Next action: ${next}`,
136
+ CHECKPOINT_END,
137
+ ]
138
+ // Preserve explicit labels under a small caller cap; trim values before
139
+ // trimming structure so a checkpoint never becomes a misleading fragment.
140
+ let checkpoint = lines.join('\n')
141
+ if (checkpoint.length <= budget) return checkpoint
142
+ const minimal = [
143
+ lines[0],
144
+ 'Objective / live request:',
145
+ 'Current step / recent work:',
146
+ 'Blockers:',
147
+ 'Attempted / completed actions:',
148
+ 'Receipts:',
149
+ 'Next action:',
150
+ lines.at(-1),
151
+ ].join('\n')
152
+ // A partial envelope could be mistaken for a complete checkpoint. Fail closed
153
+ // rather than omit a label or expose an over-budget payload.
154
+ if (minimal.length > budget) return ''
155
+ const allowance = budget - minimal.length - 6 // one separating space per field
156
+ const values = [objective, recent, blockers.join(' | '), unique([...attempted, ...completed], 6).join(' | '), publicReceipts(turns).join(' | '), next]
157
+ const shares = values.map((value) => clip(value, Math.max(0, Math.floor(allowance / values.length))))
158
+ checkpoint = [
159
+ lines[0],
160
+ `Objective / live request: ${shares[0] || 'not established'}`,
161
+ `Current step / recent work: ${shares[1] || 'not established'}`,
162
+ `Blockers: ${shares[2] || 'none stated'}`,
163
+ `Attempted / completed actions: ${shares[3] || 'not established'}`,
164
+ `Receipts: ${shares[4] || 'none safely detected'}`,
165
+ `Next action: ${shares[5] || 'not established'}`,
166
+ lines.at(-1),
167
+ ].join('\n')
168
+ return checkpoint
169
+ }
170
+
171
+ export function recapHasCheckpoint(value) {
172
+ const text = String(value || '')
173
+ return text.includes(CHECKPOINT_START) && text.includes(CHECKPOINT_END)
174
+ }
175
+
176
+ // A recap that already contains the checkpoint is the sole delivery owner.
177
+ // Resolve this in one place for both restored sessions and the live fallback
178
+ // compaction path; otherwise an older separately-persisted checkpoint can sit
179
+ // beside the recap and be prepended a second time on the next person turn.
180
+ export function resolveCheckpointCarry(carryRecap, carryCheckpoint) {
181
+ const pendingRecap = typeof carryRecap === 'string' && carryRecap.trim()
182
+ ? carryRecap
183
+ : null
184
+ const pendingCheckpoint = !recapHasCheckpoint(pendingRecap) && typeof carryCheckpoint === 'string' && carryCheckpoint.trim()
185
+ ? carryCheckpoint
186
+ : null
187
+ return { pendingRecap, pendingCheckpoint }
35
188
  }
36
189
 
37
190
  export function appendCurrentPersonRequest(carried, text) {
@@ -88,7 +241,7 @@ const FRAMING = {
88
241
 
89
242
  `reason` selects the framing only; the windowing is identical and the default
90
243
  ('switch') is byte-for-byte what this function returned before the S4 extraction. */
91
- export function buildRecapFromLog(log, cap = RECAP_CAP, { reason = 'switch' } = {}) {
244
+ export function buildRecapFromLog(log, cap = RECAP_CAP, { reason = 'switch', includeCheckpoint = true } = {}) {
92
245
  if (!Array.isArray(log) || !log.length) return ''
93
246
  const budget = Math.max(0, Number.isFinite(cap) ? Math.floor(cap) : 0)
94
247
  if (!budget) return ''
@@ -127,6 +280,11 @@ export function buildRecapFromLog(log, cap = RECAP_CAP, { reason = 'switch' } =
127
280
  kept.reverse()
128
281
  const body = (omitted ? ['[…earlier messages omitted for length…]'] : []).concat(kept).join('\n\n')
129
282
  const frame = FRAMING[reason] || FRAMING.switch
283
+ // Side-lane snapshots already have their own explicit handoff contract. A
284
+ // deterministic resume checkpoint belongs only to a lane that is actually
285
+ // losing SDK context (switch, wake, or compaction); adding it to side
286
+ // snapshots duplicates visible text and can exceed their bounded payload.
287
+ const checkpoint = includeCheckpoint ? buildCheckpointFromLog(log) : ''
130
288
  return [
131
289
  frame.header,
132
290
  frame.body,
@@ -134,5 +292,6 @@ export function buildRecapFromLog(log, cap = RECAP_CAP, { reason = 'switch' } =
134
292
  '--- conversation so far (oldest → newest) ---',
135
293
  body,
136
294
  '--- end recap ---',
295
+ checkpoint,
137
296
  ].join('\n')
138
297
  }
package/side-lane.mjs CHANGED
@@ -12,7 +12,7 @@ export const SIDE_MAIN_TURN_PROMPT = `A room member chose Bring to main.
12
12
  Read the side-lane handoff below, incorporate the relevant findings into the main lane's current work, and respond now. If the handoff recommends a next step that is already authorized and in scope, take it; otherwise explain the concrete impact on the current work.`
13
13
 
14
14
  export function sideSnapshot(log) {
15
- return buildRecapFromLog(Array.isArray(log) ? log : [], SIDE_RECAP_CAP)
15
+ return buildRecapFromLog(Array.isArray(log) ? log : [], SIDE_RECAP_CAP, { includeCheckpoint: false })
16
16
  }
17
17
 
18
18
  export function assistantTextSince(log, afterSeq = 0) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "bundleVersion": 20,
3
+ "bundleVersion": 21,
4
4
  "contracts": [
5
5
  {
6
6
  "id": "room-coordination",
@@ -130,13 +130,13 @@
130
130
  },
131
131
  {
132
132
  "id": "flow-completion",
133
- "version": 2,
133
+ "version": 3,
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. 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."
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 the observed baseline evidence and the exact dependent reviewer inherits 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": [