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 +67 -24
- package/claude-session.mjs +1 -1
- package/event-id.mjs +1 -1
- package/flow-conductor.mjs +11 -5
- package/flow-review.mjs +3 -3
- package/flow-task-graph.mjs +90 -2
- package/package.json +1 -1
- package/recap.mjs +162 -3
- package/side-lane.mjs +1 -1
- package/thinkpool-capabilities.json +3 -3
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
|
-
|
|
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'
|
|
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
|
-
|
|
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).
|
|
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
|
|
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
|
|
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'})
|
|
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
|
-
|
|
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
|
|
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'})
|
|
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 () =>
|
|
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
|
package/claude-session.mjs
CHANGED
|
@@ -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'
|
package/flow-conductor.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
'
|
|
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
|
|
package/flow-task-graph.mjs
CHANGED
|
@@ -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
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
|
-
|
|
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 && !
|
|
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":
|
|
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":
|
|
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": [
|