thinkpool-pair 0.7.112 → 0.7.114

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
@@ -39,8 +39,12 @@ import { createClient } from '@supabase/supabase-js'
39
39
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
40
40
  import { z } from 'zod'
41
41
  import { startClaudeSession } from './claude-session.mjs'
42
- import { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, buildConductorEnv } from './flow-conductor.mjs'
42
+ import { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, buildConductorEnv, assembleCrossWaveContext, buildLanePrompt } from './flow-conductor.mjs'
43
43
  import { normalizePlanOutput } from './flow-task-graph.mjs' // FL-B1 — validate the conductor's submit_flow_plan task-graph
44
+ // S1 (context-offload) — durable digest store. mark_flow_done digests a closed slice in;
45
+ // the dispatch loop reads the bounded cross-wave context back out (via assembleCrossWaveContext,
46
+ // which enforces the CEILING). Consume the store — the internals live in flow-context-store.mjs.
47
+ import { writeLaneArtifact, digestSlice, appendDigest, resumeLane } from './flow-context-store.mjs'
44
48
  import { createFlowWorktree, worktreeSpec } from './flow-worktree.mjs'
45
49
  import { startPreview, stopAllPreviews, previews } from './flow-preview.mjs'
46
50
  // FL-M9 — per-lane preview servers leak (one per done lane, never stopped until shutdown).
@@ -53,8 +57,24 @@ function stopFlowPreviews (flowId, laneId = null) {
53
57
  }
54
58
  }
55
59
  import { FLOW_REVIEWER_PROMPT, revertLane, parseReviewVerdict } from './flow-review.mjs'
60
+ import { reviewGateDecision } from './flow-review-gate.mjs'
56
61
  import { mergeWorktrees, inlineSingleHtml, initRepo } from './flow-assembly.mjs'
57
62
  import { canDispatch, FLOW_LIMITS, makeBudget, recordSpend, killSwitchEnv } from './flow-budget.mjs'
63
+ // S4 slice 2 — clean re-dispatch. When a lane is killed mid-tool-call (budget/review/restart)
64
+ // and re-dispatched, heal its transcript (Heal-3 consumer, no dangling tool_use → no 400),
65
+ // resume the healed session, and keep its revert target. flowRedispatch (flowId::taskKey →
66
+ // { resumeSessionId, revertTarget }) bridges the flow-revert kill to the next flow-dispatch
67
+ // wave. Re-dispatch opens NO per-lane realtime channel — it reuses the room-wide tpflow
68
+ // broadcast, so no duplicate-subscribe collision is possible (H41; see flow-redispatch.mjs).
69
+ // Spec: docs/specs/2026-06-30-flow-build-s4-clean-redispatch.md
70
+ import { prepareRedispatch, redispatchKey } from './flow-redispatch.mjs'
71
+ import { sanitizeSession } from './transcript-sanitize.mjs'
72
+ // ACCEPTED LIMITATION — this registry is in-memory only. A bridge restart in the window between
73
+ // a flow-revert kill and the next dispatch wave loses the pending resume record, so the task
74
+ // re-dispatches COLD (fresh lane, no resume). That's safe: the healed transcript survives on
75
+ // disk (sanitizeSession wrote it), so a cold re-dispatch can't 400 on a dangling tool_use — it
76
+ // just loses the resume/revert-target carry. Not worth persisting for that narrow window.
77
+ const flowRedispatch = new Map()
58
78
  // FL-B2 — per-flow HARD budget ledger (flowId → budget). Accumulates each lane's
59
79
  // completed-turn output tokens (onEvent 'result') so the autopilot cap stops the next
60
80
  // wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
@@ -1117,7 +1137,7 @@ function restoredTurnOpen(log) {
1117
1137
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
1118
1138
  // persist to the host file; tool calls round-trip through the perm card; the
1119
1139
  // rolling log replays to joiners and survives bridge restarts (session-store).
1120
- function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd }) {
1140
+ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots }) {
1121
1141
  if (sessions.has(id)) return
1122
1142
  // spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
1123
1143
  // session store so the Ensemble flag survives a bridge restart (else a respin
@@ -1129,6 +1149,10 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1129
1149
  // event-id.mjs + src/pages/code/seqDedup.js). pushLog() is the single stamp point.
1130
1150
  entry.seq = makeSeqCounter(maxSeq(entry.log))
1131
1151
  entry.rolePrompt = rolePrompt || null // FL-M6 — persisted so a bridge restart restores the flow role prompt
1152
+ // S5 (slice 1b) — a REVIEW lane's write-block scope: the worktree root(s) of the slice(s)
1153
+ // it reviews (its deps). Persisted so a bridge restart re-arms the structural gate. Empty/
1154
+ // absent on builder lanes → reviewGate stays null → builder toolset UNCHANGED.
1155
+ entry.reviewSliceRoots = Array.isArray(reviewSliceRoots) ? reviewSliceRoots.filter(Boolean) : []
1132
1156
  // FL-B1 (lane side) — record a flow lane's slice as done. Shared by the mark_flow_done
1133
1157
  // MCP tool AND the FLOW_DONE sentinel-Write intercept: like the conductor's submit, the
1134
1158
  // MCP tool is DEFERRED (needs ToolSearch, which hangs), so lanes signal done by Writing a
@@ -1140,6 +1164,33 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1140
1164
  try { commitSha = execFileSync('git', ['-C', entry.cwd || process.cwd(), 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() } catch { /* no commits yet */ }
1141
1165
  let previewUrl = null
1142
1166
  try { const pv = await startPreview({ dir: entry.cwd || process.cwd(), id: `lane:${entry.flowSessionId}:${id}` }); previewUrl = pv.url } catch { /* preview best-effort */ }
1167
+ // S1 (context-offload) — digest THIS closed slice into the durable store so the next
1168
+ // dispatch wave reads a bounded summary + pointers for it (never the full transcript).
1169
+ // Best-effort + fully guarded: a store failure must never block the done signal.
1170
+ try {
1171
+ const worktreeDir = entry.cwd || process.cwd()
1172
+ // Every closed slice must carry ≥1 pointer (appendDigest's zero-pointer guard), so
1173
+ // record a synthetic done-marker artifact in the lane's OWN worktree (content-addressed,
1174
+ // idempotent — a re-dispatched lane re-writing it is a no-op). Then fold in any real
1175
+ // artifacts the lane wrote under .flow/artifacts/ so a resume/peer can find them.
1176
+ const marker = writeLaneArtifact(
1177
+ entry.flowSessionId, entry.flowTaskKey, '_slice.done',
1178
+ JSON.stringify({ taskKey: entry.flowTaskKey, commitSha: commitSha || null, previewUrl: previewUrl || null }),
1179
+ { worktreeDir },
1180
+ )
1181
+ const laneArtifacts = resumeLane(entry.flowSessionId, { worktreeDir })
1182
+ const artifacts = [
1183
+ { taskKey: entry.flowTaskKey, name: '_slice.done', path: marker.path, sha: marker.sha },
1184
+ ...laneArtifacts.map((a) => ({ taskKey: a.taskKey, name: a.name, path: a.path, sha: a.sha })),
1185
+ ]
1186
+ const digest = digestSlice(
1187
+ { key: entry.flowTaskKey, title: termNames[id] || entry.flowTaskKey },
1188
+ { acceptanceProof: commitSha ? `committed ${commitSha.slice(0, 8)}` : 'slice done', artifacts },
1189
+ )
1190
+ appendDigest(entry.flowSessionId, digest, { baseDir: process.cwd() }) // idempotent per (flowId, taskKey)
1191
+ } catch (e) {
1192
+ process.stderr.write(`\n ${A.dim}◆ flow digest skip ${entry.flowTaskKey} — ${e?.message || e}${A.rst}\n`)
1193
+ }
1143
1194
  bcast('flow-task-done', { term: id, flowId: entry.flowSessionId, taskKey: entry.flowTaskKey, laneId: id, commitSha, previewUrl }, flowChannel)
1144
1195
  process.stderr.write(`\n ${A.cyan}◆ flow slice done — ${entry.flowTaskKey} (${commitSha ? commitSha.slice(0, 8) : 'no commit'})${previewUrl ? ` · preview ${previewUrl}` : ''}${A.rst}\n`)
1145
1196
  entry.flowDone = true // FL-B3 — retire immediately so it drops from the ≤8 lane cap
@@ -1204,7 +1255,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1204
1255
  // subagent-block; without cwd it runs outside its worktree; without rolePrompt it loses its
1205
1256
  // Flow role. blockSubagents / onLaneDone / onReviewVerdict / onSubmitPlan all derive from
1206
1257
  // these, so restoring them restores the whole behavior.
1207
- const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands, mode: entry.mode, spawnedBy: entry.spawnedBy, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, cwd: entry.cwd, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null })
1258
+ const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands, mode: entry.mode, spawnedBy: entry.spawnedBy, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, cwd: entry.cwd, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [] })
1208
1259
  const persist = () => saveSession(room, id, sessionData())
1209
1260
  // Synchronous flush of this session's record. Used on open (so a brand-new session
1210
1261
  // has a file under its id BEFORE its first event — surviving a restart inside the
@@ -1470,8 +1521,24 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1470
1521
  ),
1471
1522
  ],
1472
1523
  })
1524
+ // S5 (slice 1b) — the review-lane write-block, wired into the live PreToolUse path.
1525
+ // Null for builder lanes (no reviewSliceRoots) → their toolset is UNCHANGED. For a review
1526
+ // lane, deny a mutating tool call if it targets ANY reviewed slice's worktree, allowing the
1527
+ // reviewer's own FLOW_REVIEW.json (verdictFile) + all reads + run/test Bash. reviewGateDecision
1528
+ // takes ONE sliceRoot, so we fold over every reviewed slice: deny on the first that denies.
1529
+ const reviewVerdictFile = path.join(cwd || process.cwd(), 'FLOW_REVIEW.json')
1530
+ const reviewGate = (entry.reviewSliceRoots.length)
1531
+ ? ({ toolName, input }) => {
1532
+ for (const sliceRoot of entry.reviewSliceRoots) {
1533
+ const d = reviewGateDecision({ toolName, input, sliceRoot, verdictFile: reviewVerdictFile })
1534
+ if (!d.allow) return d
1535
+ }
1536
+ return { allow: true, reason: 'no reviewed slice targeted' }
1537
+ }
1538
+ : null
1473
1539
  entry.session = startClaudeSession({
1474
1540
  cwd: cwd || process.cwd(), model, resume, mode, rolePrompt,
1541
+ reviewGate,
1475
1542
  // A Flow CONDUCTOR (flowSessionId set, no flowTaskKey) must DECOMPOSE, not explore via
1476
1543
  // a fan-out of Task/Explore subagents, and must not build. Hard-block Task/Bash/Write for
1477
1544
  // conductors so it reads with Read/Grep/Glob and submits via the FLOW_PLAN.json sentinel.
@@ -2018,9 +2085,11 @@ channel
2018
2085
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
2019
2086
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
2020
2087
  // lanes keep their worktree cwd + the ability to mark done.
2021
- openStructured({ id: rec.id, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, cwd: rec.cwd, rolePrompt: rec.rolePrompt })
2088
+ openStructured({ id: rec.id, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, cwd: rec.cwd, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots })
2022
2089
  const re = sessions.get(rec.id)
2023
2090
  if (re && rec.flowReviewTarget) re.flowReviewTarget = rec.flowReviewTarget
2091
+ // S4 — a resumed re-dispatch lane keeps its surviving revert target across a bridge restart.
2092
+ if (re && rec.revertTarget) re.revertTarget = rec.revertTarget
2024
2093
  // FL-M6 — a flow LANE whose turn had already ENDED before the restart finished its
2025
2094
  // slice, but its done-signal was lost while the bridge was down (resume won't re-run
2026
2095
  // an ended turn, so it would sit idle → the flow stalls forever). Reconcile: re-record
@@ -2131,16 +2200,53 @@ flowChannel
2131
2200
  const laneId = randomUUID()
2132
2201
  termNames[laneId] = `Flow · ${t.task_key}`
2133
2202
  saveNames(room, termNames)
2203
+ // S4 slice 2 — clean re-dispatch. If this task was force-killed mid-tool-call on an
2204
+ // earlier wave, flow-revert left a pending record (its transcript already Heal-3'd, its
2205
+ // resume sessionId + revert target captured). CONSUME it here: resume the healed session
2206
+ // (no dangling tool_use → no 400) instead of a cold start, and carry the revert target
2207
+ // forward (revertLane deleted the branch, so this is the only survivor). Delete the record
2208
+ // on consume so a re-broadcast can't double-resume (idempotent).
2209
+ // H41: the resumed lane opens no realtime channel of its own — it reuses the room-wide
2210
+ // tpflow broadcast channel (opened once at startup), so no duplicate-subscribe collision
2211
+ // is possible. There is no per-lane topic to compute or stamp.
2212
+ const rk = redispatchKey(payload.flowId, t.task_key)
2213
+ const redispatch = flowRedispatch.get(rk) || null
2214
+ if (redispatch) flowRedispatch.delete(rk)
2215
+ // S5 (slice 1b) — a REVIEW lane reviews the slices it DEPENDS ON. Its write-block scope
2216
+ // is those slices' worktree roots; the lane runs in its OWN worktree (dir above), so its
2217
+ // reads/runs/tests + verdict Write are unaffected — only writes INTO a reviewed slice are
2218
+ // denied. Builder lanes get [] → reviewGate stays null → their toolset is UNCHANGED.
2219
+ const reviewSliceRoots = isReview
2220
+ ? (t.deps || []).map((dep) => worktreeSpec({ flowId: payload.flowId, taskKey: dep }).dir)
2221
+ : []
2134
2222
  // Lanes build autonomously in their own worktree — bypassPermissions so they
2135
2223
  // don't stall on a card for every write/bash (matches the user's expectation that
2136
2224
  // a Flow summoned from a bypass terminal runs hands-off).
2137
- openStructured({ id: laneId, cwd: dir, mode: 'bypassPermissions', rolePrompt: isReview ? FLOW_REVIEWER_PROMPT : FLOW_LANE_PROMPT, flowSessionId: payload.flowId, flowTaskKey: t.task_key, spawnedBy: `flow:${payload.flowId}` })
2225
+ // S2 — build the lane's base prompt from the static role prompt PLUS the skill
2226
+ // MANIFEST (metadata only, never bodies; absolute-ceiling capped). buildLanePrompt
2227
+ // reads the skills tree from env (TP_FLOW_SKILLS_DIR / _CUSTOM_DIR) — no tree →
2228
+ // empty manifest → the base prompt is returned unchanged. A body only ever enters
2229
+ // a lane later, on demand, via activateLaneSkill — never the base prompt here.
2230
+ // S4 — resume: on a re-dispatch, replay the killed lane's HEALED transcript (Heal-3'd,
2231
+ // no dangling tool_use → no 400) instead of a cold start; undefined for a fresh lane.
2232
+ const laneBase = isReview ? FLOW_REVIEWER_PROMPT : FLOW_LANE_PROMPT
2233
+ const laneRolePrompt = buildLanePrompt({ base: laneBase })
2234
+ openStructured({ id: laneId, cwd: dir, mode: 'bypassPermissions', rolePrompt: laneRolePrompt, flowSessionId: payload.flowId, flowTaskKey: t.task_key, spawnedBy: `flow:${payload.flowId}`, resume: redispatch?.resumeSessionId || undefined, reviewSliceRoots })
2138
2235
  const le = sessions.get(laneId)
2139
2236
  if (le) {
2237
+ // S4 — stamp the surviving revert target on the resumed lane so a later reviewer still
2238
+ // knows what to roll back to (revertLane deleted the branch, so this carry is the only
2239
+ // survivor). No per-lane topic: the lane rides the room-wide tpflow broadcast (H41).
2240
+ if (redispatch) {
2241
+ le.revertTarget = redispatch.revertTarget ?? null
2242
+ }
2140
2243
  // FL-B4 — a review slice reviews the slices it DEPENDS ON; remember the primary
2141
2244
  // target so a FAIL verdict knows what to revert even if the model omits taskKey.
2142
2245
  if (isReview) le.flowReviewTarget = (t.deps && t.deps[0]) || null
2143
- if (le.session) {
2246
+ // A resumed lane already carries its work in the healed transcript — re-sending the
2247
+ // full build spec would restart it from scratch, discarding the resume. Only seed the
2248
+ // slice spec on a FRESH lane; a resumed lane picks up where it was killed.
2249
+ if (le.session && !redispatch) {
2144
2250
  const spec = isReview
2145
2251
  ? `[Flow REVIEW lane — slice "${t.task_key}" of flow ${String(payload.flowId).slice(0, 8)}]\n` +
2146
2252
  `You are ADVERSARIALLY reviewing the slice(s): ${(t.deps || []).join(', ') || t.title}\n` +
@@ -2156,6 +2262,11 @@ flowChannel
2156
2262
  `SCOPE (files you OWN — edit ONLY these): ${t.scope || '(none stated)'}\n` +
2157
2263
  `ACCEPTANCE (done = this runs + proves it): ${t.acceptance || '(meet the title)'}\n` +
2158
2264
  (t.deps && t.deps.length ? `DEPENDS ON (already built): ${t.deps.join(', ')}\n` : '') +
2265
+ // S1 (context-offload) — inject the BOUNDED cross-wave context (loadDigest
2266
+ // enforces CEILING) instead of accumulating full lane transcripts. Filter to
2267
+ // this slice's deps so a lane sees its dependencies' closed digests first.
2268
+ ((crossWave) => crossWave ? `${crossWave}\n` : '')(
2269
+ assembleCrossWaveContext(payload.flowId, { baseDir: process.cwd(), deps: t.deps }).text) +
2159
2270
  `\nProject (context): ${payload.flowPrompt || ''}\n\n` +
2160
2271
  `Build your slice. Own only your files. Done = it runs + meets acceptance. Commit when done.`
2161
2272
  try { le.session.sendTurn(spec) } catch { /* session still starting */ }
@@ -2217,6 +2328,25 @@ flowChannel
2217
2328
  let killed = false
2218
2329
  for (const [sid, e] of sessions) {
2219
2330
  if (e.flowSessionId === payload.flowId && e.flowTaskKey === payload.taskKey && !e.flowDone) {
2331
+ // S4 slice 2 — prepare a CLEAN re-dispatch BEFORE we kill the lane + remove its
2332
+ // worktree. prepareRedispatch heals the transcript (Heal-3 consumer — a dangling
2333
+ // tool_use from a mid-tool-call kill gets a synthetic tool_result so a resume can't
2334
+ // 400) and captures the resume sessionId + the revert target (which revertLane's
2335
+ // branch delete would otherwise destroy). We record it under flowId::taskKey so the
2336
+ // next flow-dispatch wave resumes the healed session. Idempotent: healing an
2337
+ // already-clean transcript is a no-op. No per-lane topic is captured — the resumed
2338
+ // lane reuses the room-wide tpflow broadcast, so there is no H41 collision to guard.
2339
+ try {
2340
+ const prep = prepareRedispatch({
2341
+ lane: { cwd: e.cwd, session: e.session, commitSha: e.commitSha, revertTarget: payload.commitSha ?? e.commitSha ?? null },
2342
+ sanitize: sanitizeSession,
2343
+ })
2344
+ flowRedispatch.set(redispatchKey(payload.flowId, payload.taskKey), {
2345
+ resumeSessionId: prep.resumeSessionId,
2346
+ revertTarget: prep.revertTarget,
2347
+ })
2348
+ if (prep.healed) process.stderr.write(`\n ${A.dim}◆ flow re-dispatch heal — ${payload.taskKey}: ${prep.healed} dangling tool block(s) healed before resume.${A.rst}\n`)
2349
+ } catch (err) { process.stderr.write(`\n ${A.yel}◆ flow re-dispatch prep failed (${payload.taskKey}): ${err?.message || err} — will re-dispatch fresh.${A.rst}\n`) }
2220
2350
  e.flowDone = true
2221
2351
  try { endStructured(sid) } catch { /* already gone */ }
2222
2352
  stopFlowPreviews(payload.flowId, sid) // FL-M9 — drop the reverted lane's preview
@@ -15,6 +15,7 @@
15
15
  import { randomUUID } from 'node:crypto'
16
16
  import { query } from '@anthropic-ai/claude-agent-sdk'
17
17
  import { sanitizeSession } from './transcript-sanitize.mjs'
18
+ import { reviewGatePreToolDecision } from './flow-review-gate.mjs'
18
19
 
19
20
  // ── risk classification — the accent/danger tier of the permission card ──
20
21
  // low (read-only) · medium (writes/runs) · network (leaves the machine) ·
@@ -120,7 +121,25 @@ const simplifyBlocks = (blocks = []) => blocks.map((b) => {
120
121
  */
121
122
  const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
122
123
 
123
- export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null }) {
124
+ // Per-turn salience reminder. The full ThinkPool Code ruleset lives in
125
+ // `appendSystemPrompt` (baked once at session start), but on a long session — heavy
126
+ // tool output, and especially after auto-compaction — the model's attention drifts
127
+ // off a system prompt that sits behind the large host CLAUDE.md. The classic tell:
128
+ // the agent hands the room a host-local path (`open …`, "it's in ~/claude-shots/")
129
+ // or narrates instead of showing, forgetting it's driven from a phone. So we re-state
130
+ // the highest-drift rules as a compact <system-reminder> appended to EVERY user turn,
131
+ // adjacent to where the model's attention actually is — the same trick the host
132
+ // harness uses to keep CLAUDE.md alive. ~120 tokens/turn; changes nothing but salience.
133
+ const TP_ROOM_REMINDER = [
134
+ 'You are Claude in a ThinkPool Code room, driven live from a phone or browser — NOT a local terminal. Keep using the room\'s features.',
135
+ 'ARTIFACTS: never hand the room a local path, a file:// URL, a localhost address, an `open …`/Preview reference, or "it\'s in ~/claude-shots/" — the room cannot see the host. Surface every image by reading the PNG into your turn so it renders inline; for HTML use the $TP_MOCKUP_OUTBOX render helper (inline desktop+mobile card) and/or publish to a GitHub Pages URL and post that link.',
136
+ 'SHOW, don\'t narrate: a picture of the result beats a wall of text on a phone — err toward more screenshots.',
137
+ 'ENSEMBLE: fan parallel work out with spawn_terminal (your own fresh lanes), coordinate via read_terminal / list_sessions; never hijack a busy sibling.',
138
+ 'FLOW / PLANS: write plans as normal chat messages as you form them — the room does not surface plan files or ExitPlanMode text.',
139
+ 'Verify before claiming done — show runtime evidence you produced, not "should work, go test it".',
140
+ ].join(' ')
141
+
142
+ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null }) {
124
143
  const ac = new AbortController()
125
144
  let input = makeInputStream() // `let`: auto-restart swaps in a fresh stream
126
145
  let sessionId = resume || null
@@ -297,6 +316,19 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
297
316
  try { res = (await onLaneDone()) || res } catch (e) { res = { ok: false, message: `done signal failed: ${e?.message || e}` } }
298
317
  return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: res.message } }
299
318
  }
319
+ // S5 (slice 1b) — REVIEW-LANE WRITE-BLOCK. A review lane is ADVERSARIAL: it reads +
320
+ // runs/tests the slice(s) it reviews and emits a verdict, but it must be STRUCTURALLY
321
+ // unable to Edit/Write/commit the code under review (a reviewer that edits is a second
322
+ // builder → a false PASS). `reviewGate` is set ONLY on review lanes (bridge.mjs wires it
323
+ // to reviewGateDecision over the reviewed slices' worktree roots + the lane's own
324
+ // FLOW_REVIEW.json). It runs AFTER the FLOW_REVIEW.json verdict intercept above, so the
325
+ // reviewer can still emit its verdict, and BEFORE the normal auto-allow path, so the deny
326
+ // is structural even in the lane's bypassPermissions mode. Read + run/test stay allowed
327
+ // (the gate allows read-only tools + non-write Bash — the reviewer's whole method). The
328
+ // real mitigation for Bash-quoting bypasses is worktree isolation (the review lane runs
329
+ // in its OWN worktree, not the slice's) — guaranteed by the spawn path.
330
+ const reviewDeny = reviewGatePreToolDecision({ reviewGate, toolName, toolInput })
331
+ if (reviewDeny) return reviewDeny
300
332
  // ThinkPool cross-terminal READ (B2) is read-only + within-room — never
301
333
  // prompt, in any mode. It still surfaces as a tool card so the room sees the peek.
302
334
  if (toolName === 'mcp__thinkpool__read_terminal') {
@@ -722,7 +754,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
722
754
  runQuery()
723
755
 
724
756
  return {
725
- sendTurn(text) { if (!closed) { turnActive = true; sawSuggestion = false; if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } lastEvtTs = Date.now(); stalledSent = false; input.push(String(text)) } },
757
+ sendTurn(text) { if (!closed) { turnActive = true; sawSuggestion = false; if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } lastEvtTs = Date.now(); stalledSent = false; input.push([{ type: 'text', text: String(text) }, { type: 'text', text: `<system-reminder>\n${TP_ROOM_REMINDER}\n</system-reminder>` }]) } },
726
758
  // Set the permission mode — Claude Code's ⇧⇥ cycle. setPermissionMode is a
727
759
  // streaming control request (drives plan-mode behaviour SDK-side); the local
728
760
  // `mode` drives our PreToolUse auto-approve policy. Echo so the room syncs.
@@ -14,6 +14,20 @@
14
14
  // it drifts from the canonical src module). One logical source, two reachable copies.
15
15
  import { FLOW_MODE, FLOW_STATUS, normalizePlanOutput } from './flow-task-graph.mjs'
16
16
 
17
+ // S1 (context-offload) — read the conductor's BOUNDED cross-wave context from the durable
18
+ // digest store instead of accumulating full lane transcripts. loadDigest already enforces
19
+ // the hard CEILING (summary + pointers per closed slice, truncated before it grows), so
20
+ // the assembled cross-wave context can never overflow the conductor's head. Consume the
21
+ // store — do NOT re-implement it (canonical src/lib/flow/contextStore.js, mirror here).
22
+ import { loadDigest } from './flow-context-store.mjs'
23
+ // Progressive skill registry (S2). Bridge-local mirror of src/lib/flow/skillRegistry.js
24
+ // (same tarball-boundary reason as flow-task-graph). `manifest()` yields compact
25
+ // METADATA ONLY (name/description/whenToUse — never a SKILL.md body); `loadBody(name)`
26
+ // returns a single body on demand. This wiring injects the manifest into a lane's base
27
+ // prompt and loads a body only when the lane activates that skill. DO NOT edit the
28
+ // registry here — consume it.
29
+ import { manifest as skillManifest, loadBody as skillLoadBody } from './flow-skill-registry.mjs'
30
+
17
31
  // Re-export the shared plan normalizer so bridge callers import Flow pieces from one
18
32
  // place. The implementation lives in the shared model (src/lib/flow/taskGraph.js).
19
33
  export { normalizePlanOutput }
@@ -93,3 +107,135 @@ export function buildLaneEnv ({ flowSessionId, taskKey, laneId }) {
93
107
  }
94
108
  }
95
109
 
110
+ // ── assembleCrossWaveContext ────────────────────────────────────────────────
111
+ // S1 (context-offload) — the conductor's bounded cross-wave context for a dispatch
112
+ // wave. Reads the durable digest store (loadDigest, which enforces the hard CEILING)
113
+ // and renders the closed-slice digests as a compact block a lane can be seeded with:
114
+ // a one-line summary + artifact pointers per closed slice, NOT full transcripts.
115
+ //
116
+ // Before S1, each newly-dispatched lane was told only the NAMES of the slices it
117
+ // depends on (`DEPENDS ON (already built): a, b`) with no content, and the conductor
118
+ // itself would (across many waves) accumulate full lane output in its chat context —
119
+ // the overflow this slice fixes. Now the ordered digest is the ONLY cross-wave carrier,
120
+ // and its size is capped by loadDigest's CEILING guarantee (JSON.stringify(digest)
121
+ // length ≤ CEILING), so the assembled context is bounded across ≥2 waves by construction.
122
+ //
123
+ // Returns { text, digest } — `text` is the ready-to-embed block (empty string when the
124
+ // flow has no closed slices yet), `digest` is the raw bounded array (for tests/assertions).
125
+ // Pure over an injected fs so it's unit-testable against a temp digest store.
126
+ export function assembleCrossWaveContext (flowId, { baseDir, deps = null, fs } = {}) {
127
+ if (!flowId || !baseDir) return { text: '', digest: [] }
128
+ let digest
129
+ try {
130
+ digest = loadDigest(flowId, { baseDir, ...(fs ? { fs } : {}) })
131
+ } catch {
132
+ // A malformed/absent digest store must never wedge dispatch — degrade to no context.
133
+ return { text: '', digest: [] }
134
+ }
135
+ // If the caller named the current slice's deps, prefer them (so a lane sees its
136
+ // dependencies first); otherwise show every closed slice. loadDigest already bounds
137
+ // the whole set under CEILING, so filtering only ever shrinks the block.
138
+ const wanted = Array.isArray(deps) && deps.length ? new Set(deps) : null
139
+ const shown = wanted ? digest.filter((d) => wanted.has(d.taskKey)) : digest
140
+ if (!shown.length) return { text: '', digest }
141
+ const lines = shown.map((d) => {
142
+ const ptrs = (d.pointers || [])
143
+ .map((p) => `${p.name}@${String(p.sha).slice(0, 8)}`)
144
+ .join(', ')
145
+ return ` - ${d.summary}${ptrs ? ` [artifacts: ${ptrs}]` : ''}`
146
+ })
147
+ const text = `CLOSED SLICES (bounded digest — summary + artifact pointers, not full output):\n${lines.join('\n')}`
148
+ return { text, digest }
149
+ }
150
+
151
+ // ── Progressive skill loading (S2) ───────────────────────────────────────────
152
+ // A lane boots knowing only the skill MANIFEST (a tiny menu: name + one-line
153
+ // description + whenToUse) — never the bodies. It pulls a full SKILL.md body in only
154
+ // when it actually activates that skill. This is the DeerFlow steal: metadata in the
155
+ // base prompt, body on demand. The token win (manifest ≪ Σ bodies) is the whole point.
156
+
157
+ // Resolve the skill directories a Flow lane draws from. publicDir = built-in
158
+ // product-facing skills; customDir = optional user/room-level overrides. Both come from
159
+ // env so the bridge operator can point them at a real skills tree; absent → undefined,
160
+ // which the registry treats as "no skills" (empty manifest, loadBody clean-misses). This
161
+ // is deliberately NOT the .claude/skills dev-side harness — that stays out of scope.
162
+ export function flowSkillDirs (env = process.env) {
163
+ return {
164
+ publicDir: env.TP_FLOW_SKILLS_DIR || undefined,
165
+ customDir: env.TP_FLOW_SKILLS_CUSTOM_DIR || undefined,
166
+ }
167
+ }
168
+
169
+ // ── Absolute manifest ceiling (S2 blocker 2) ─────────────────────────────────
170
+ // The token-win invariant (manifest ≪ Σ bodies) is only a RELATIVE ratio — it does
171
+ // nothing to bound the ABSOLUTE size of the manifest block that lands in EVERY lane's
172
+ // base prompt. Custom skills are user/room-uploadable (H1/S6), so a single skill with a
173
+ // pathological description/whenToUse (a 200KB blob) would balloon the base prompt
174
+ // unbounded. These caps give the ceiling teeth: each field is truncated per-entry, and
175
+ // the whole rendered block is hard-capped, with a visible marker where content is cut.
176
+ export const MANIFEST_MAX_CHARS = 4096 // absolute ceiling on the rendered block
177
+ export const MANIFEST_FIELD_MAX = 240 // per-field (description / whenToUse) cap
178
+ const TRUNC_MARK = '…[truncated]'
179
+
180
+ // Truncate a single metadata field to MANIFEST_FIELD_MAX chars with a visible marker.
181
+ function clampField (s) {
182
+ const str = typeof s === 'string' ? s : ''
183
+ if (str.length <= MANIFEST_FIELD_MAX) return str
184
+ return str.slice(0, MANIFEST_FIELD_MAX - TRUNC_MARK.length) + TRUNC_MARK
185
+ }
186
+
187
+ // Render the compact manifest as a prompt block — METADATA ONLY. Each entry is one line:
188
+ // the skill name, its one-line description, and (if present) whenToUse. There is NO code
189
+ // path here that can emit a SKILL.md body: this function only ever sees manifest entries
190
+ // (which carry no body), so a body cannot leak into the base prompt through it.
191
+ // Returns '' for an empty manifest so the base prompt is unchanged when no skills exist.
192
+ //
193
+ // ABSOLUTE CEILING: every field is clamped to MANIFEST_FIELD_MAX, and the assembled block
194
+ // is hard-capped at MANIFEST_MAX_CHARS — entries that would push past the ceiling are
195
+ // dropped and a visible marker names how many were omitted. A pathological huge-metadata
196
+ // skill therefore cannot grow the base prompt beyond MANIFEST_MAX_CHARS (+ a small header).
197
+ export function renderSkillManifestBlock (entries) {
198
+ if (!Array.isArray(entries) || entries.length === 0) return ''
199
+ const header = [
200
+ 'AVAILABLE SKILLS (menu only — names + descriptions, NOT the instructions). You boot',
201
+ 'knowing this menu; you do NOT carry any skill\'s full instructions. When a task matches',
202
+ 'a skill (or you invoke it slash-style), the room loads that ONE skill\'s full body for',
203
+ 'you on demand — never all of them, never up front. The menu:',
204
+ ].join('\n')
205
+
206
+ const rendered = []
207
+ let used = header.length
208
+ let dropped = 0
209
+ for (const e of entries) {
210
+ const when = e.whenToUse ? ` — use when: ${clampField(e.whenToUse)}` : ''
211
+ const line = ` • ${e.name}: ${clampField(e.description)}${when}`
212
+ // +1 for the '\n' joining this line to what precedes it.
213
+ if (used + 1 + line.length > MANIFEST_MAX_CHARS) { dropped++; continue }
214
+ rendered.push(line)
215
+ used += 1 + line.length
216
+ }
217
+ if (dropped > 0) {
218
+ rendered.push(` …[${dropped} more skill(s) omitted — manifest ceiling reached]`)
219
+ }
220
+ return [header, ...rendered].join('\n')
221
+ }
222
+
223
+ // Build a lane's base prompt: the static FLOW_LANE_PROMPT (or FLOW_REVIEWER-style base
224
+ // passed in) PLUS the skill MANIFEST block — metadata only, never bodies. This is what a
225
+ // lane session boots with. The token win vs. inlining bodies is asserted in tests.
226
+ // dirs/fs are injectable for unit-testing against temp fixtures; default to env dirs.
227
+ export function buildLanePrompt ({ base = FLOW_LANE_PROMPT, publicDir, customDir, fs } = {}) {
228
+ const dirs = publicDir === undefined && customDir === undefined ? flowSkillDirs() : { publicDir, customDir }
229
+ const entries = skillManifest({ ...dirs, ...(fs ? { fs } : {}) })
230
+ const block = renderSkillManifestBlock(entries)
231
+ return block ? `${base}\n\n${block}` : base
232
+ }
233
+
234
+ // Load exactly ONE skill's full body, on demand, when a lane activates it. Returns the
235
+ // SKILL.md body string, or null on a clean miss (unknown / disabled skill — never a
236
+ // throw). Callers inject this into the lane mid-task; it is never part of the base prompt.
237
+ export function activateLaneSkill (name, { publicDir, customDir, fs } = {}) {
238
+ const dirs = publicDir === undefined && customDir === undefined ? flowSkillDirs() : { publicDir, customDir }
239
+ return skillLoadBody(name, { ...dirs, ...(fs ? { fs } : {}) })
240
+ }
241
+
@@ -0,0 +1,387 @@
1
+ // Thinkpool Flow — durable per-lane context-offload store.
2
+ //
3
+ // Three design patterns fused (docs/specs/2026-06-30-flow-build-s1-context-offload.md):
4
+ // • DBOS-style durable steps — each artifact write is idempotent + content-addressed by
5
+ // (taskKey, name) + sha256; a re-dispatched lane resumes instead of duplicating work.
6
+ // • Headroom-style digest ceiling — the conductor reads one-line summary + pointers per
7
+ // closed slice, capped at CEILING bytes; never an unbounded transcript.
8
+ // • Statewright-style structural containment — a hard path guard rejects any taskKey or
9
+ // name that would escape <worktreeDir>/.flow/artifacts/; structural, not an instruction.
10
+ // Reuses the approach from bridge/flow-assembly.mjs (inlineSingleHtml resolve) and
11
+ // bridge/flow-preview.mjs (resolveUnderRoot) — two existing containment sites.
12
+ //
13
+ // Pure module — canonical at src/lib/flow/contextStore.js, mirrored byte-identically
14
+ // at bridge/flow-context-store.mjs (same reason as taskGraph / flowBudget: the bridge
15
+ // ships as thinkpool-pair npm package and can't import across the bridge↔src boundary
16
+ // at publish time). Sync: bridge/flow-context-store.sync.test.js.
17
+ //
18
+ // Node built-ins only (node:crypto, node:fs, node:path) — no new deps.
19
+ // All fs calls are injectable ({fs} opts) so logic is unit-testable against temp dirs.
20
+
21
+ import { createHash } from 'node:crypto'
22
+ import nodeFs from 'node:fs'
23
+ import path from 'node:path'
24
+
25
+ // Hard ceiling for the conductor's assembled digest context (bytes of serialized JSON).
26
+ // When accumulated size would exceed CEILING, each over-budget entry is truncated to
27
+ // { taskKey, summary, pointers } — never drops a pointer, never silently grows past this.
28
+ export const CEILING = 32 * 1024 // 32 KB
29
+
30
+ // ── containment ──────────────────────────────────────────────────────────────
31
+ // Resolve `segments` under `base`. Returns the absolute path iff it stays strictly
32
+ // inside `base`, otherwise null. Same approach as flow-assembly.mjs (inlineSingleHtml
33
+ // resolve) and flow-preview.mjs (resolveUnderRoot) — reused, not re-invented.
34
+ //
35
+ // STRUCTURAL (not ENOENT-based): every rejection is a pure string-level check that
36
+ // fires before any filesystem call. Do not add fs.exists/stat calls here.
37
+ function containedPath (base, ...segments) {
38
+ for (const seg of segments) {
39
+ if (!seg || typeof seg !== 'string') return null
40
+ if (path.isAbsolute(seg)) return null
41
+ if (seg.includes('\0')) return null
42
+ // STRUCTURAL: reject any segment that contains a path separator character.
43
+ // Prevents 'real/link/escape' and 'a/../../evil' from normalising into a valid
44
+ // sub-path without escaping the per-entry rel-check below. This is the key
45
+ // structural gate — no ENOENT dependency.
46
+ if (seg.includes('/') || seg.includes('\\')) return null
47
+ // STRUCTURAL: reject bare '.' and '..' as full segments (belt-and-suspenders;
48
+ // the rel.startsWith('..') check below would also catch '..' after path.join).
49
+ if (seg === '..' || seg === '.') return null
50
+ // Reject zero-width / Unicode format chars (ZWSP U+200B, ZWNJ U+200C, etc.):
51
+ // trim() does NOT strip them; require at least one real identifier char.
52
+ if (!/[A-Za-z0-9_.-]/.test(seg)) return null
53
+ }
54
+ const joined = path.join(...segments)
55
+ const abs = path.resolve(base, joined)
56
+ const rel = path.relative(base, abs)
57
+ // rel === '' means abs === base (the directory itself) — not a valid write target
58
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return null
59
+ return abs
60
+ }
61
+
62
+ // ── safeSegmentId ─────────────────────────────────────────────────────────────
63
+ // Validate that `val` is a safe single path-segment identifier (used for flowId).
64
+ // Rejects: null/non-string, empty, whitespace-only, null bytes, absolute paths,
65
+ // path separators (/ or \), bare '.' or '..'. Prevents flowId from escaping the
66
+ // digest base directory when interpolated into path.join calls.
67
+ function safeSegmentId (val) {
68
+ if (!val || typeof val !== 'string') return false
69
+ if (!val.trim()) return false
70
+ if (val.includes('\0')) return false
71
+ if (path.isAbsolute(val)) return false
72
+ if (val.includes('/') || val.includes('\\')) return false
73
+ if (val === '..' || val === '.') return false
74
+ // Reject zero-width / Unicode format chars (ZWSP U+200B, ZWNJ U+200C, etc.):
75
+ // trim() does NOT strip them; require at least one real identifier char.
76
+ if (!/[A-Za-z0-9_.-]/.test(val)) return false
77
+ return true
78
+ }
79
+
80
+ // ── assertLeafContained ────────────────────────────────────────────────────────
81
+ // Leaf-level (final-component) containment for a WRITE target — the artifact file or
82
+ // the digest .jsonl. The dir-level realpathSync guards in the callers contain the
83
+ // PARENT directory, but fs.writeFileSync / fs.appendFileSync follow a symlink planted
84
+ // AT the leaf itself, so a symlink at <dest> would let the write escape an
85
+ // already-contained directory. realpathSync(dir) never inspects the leaf's own type —
86
+ // only lstatSync does, because it does NOT follow the final component. It reveals the
87
+ // leaf as a symlink regardless of what the link points at, which is why one check
88
+ // covers all three escape flavors:
89
+ // • ENOENT → no leaf yet; the write will create a regular file → safe, return.
90
+ // • symlink → reject (absolute-, relative-, AND chained-target symlinks alike —
91
+ // lstat stops at the first hop, so a symlink→symlink→victim chain and a
92
+ // relative "../../.." target are both caught here).
93
+ // • regular → realpathSync must still resolve strictly under `canonicalDir`.
94
+ // Must be called BEFORE any idempotency readFileSync of the leaf (which would itself
95
+ // follow a leaf symlink). This is the leaf half of containment; the dir half stays in
96
+ // the callers (writeLaneArtifact / appendDigest). (B5 breaks 1-3.)
97
+ function assertLeafContained (fs, leafPath, canonicalDir) {
98
+ let st
99
+ try {
100
+ st = fs.lstatSync(leafPath)
101
+ } catch (e) {
102
+ if (e && e.code === 'ENOENT') return // leaf absent — write creates a regular file
103
+ throw e
104
+ }
105
+ if (st.isSymbolicLink()) {
106
+ throw new Error(`symlink escape: refusing to write through a symlink at leaf "${leafPath}"`)
107
+ }
108
+ // Non-symlink leaf: re-verify its real path is still strictly inside the canonical dir.
109
+ const real = fs.realpathSync(leafPath)
110
+ if (!real.startsWith(canonicalDir + path.sep)) {
111
+ throw new Error(`symlink escape: resolved leaf "${real}" escapes canonical dir "${canonicalDir}"`)
112
+ }
113
+ }
114
+
115
+ // ── writeLaneArtifact ─────────────────────────────────────────────────────────
116
+ // Write `content` (string | Buffer) under <worktreeDir>/.flow/artifacts/<taskKey>/<name>.
117
+ // Returns { path, bytes, sha } where sha is the sha256 hex of the content.
118
+ //
119
+ // IDEMPOTENT: if (taskKey, name) already holds byte-identical content, skips the write
120
+ // and returns the same { path, bytes, sha }. Safe to call multiple times on the same
121
+ // artifact — a re-dispatched lane writing what's already there is a no-op.
122
+ //
123
+ // STRUCTURAL PATH GUARD: rejects any taskKey or name that would escape the artifacts
124
+ // base (no '..', no absolute paths, no null bytes, no path separators, no whitespace-only).
125
+ // Throws on violation before any filesystem call is made.
126
+ //
127
+ // REALPATH CONTAINMENT GUARD (directory level): after the structural string checks and
128
+ // mkdirSync, resolves the real path of taskDir via fs.realpathSync and verifies it is
129
+ // still inside the canonical artifacts root (fs.realpathSync(worktreeDir) +
130
+ // '/.flow/artifacts'). Catches symlinks planted at any DIRECTORY level — .flow,
131
+ // .flow/artifacts, or the taskKey dir — in one pass.
132
+ //
133
+ // LEAF CONTAINMENT GUARD (file level): the dir guard cannot see a symlink planted at the
134
+ // dest file itself (realpathSync(dir) never inspects the leaf's type). assertLeafContained
135
+ // lstat's the dest and rejects a leaf symlink — absolute-, relative-, or chained-target —
136
+ // before fs.writeFileSync (or the idempotency readFileSync) can follow it out of the dir.
137
+ export function writeLaneArtifact (flowId, taskKey, name, content, { worktreeDir, fs = nodeFs } = {}) {
138
+ if (!flowId || typeof flowId !== 'string') throw new Error('flowId required (string)')
139
+ if (!taskKey || typeof taskKey !== 'string' || !taskKey.trim()) throw new Error('taskKey required (non-empty string)')
140
+ if (!name || typeof name !== 'string' || !name.trim()) throw new Error('name required (non-empty string)')
141
+ if (!worktreeDir) throw new Error('worktreeDir required')
142
+
143
+ const artifactsBase = path.join(worktreeDir, '.flow', 'artifacts')
144
+
145
+ const taskDir = containedPath(artifactsBase, taskKey)
146
+ if (!taskDir) throw new Error(`taskKey "${taskKey}" rejected — path escapes artifacts base or is invalid`)
147
+
148
+ const dest = containedPath(artifactsBase, taskKey, name)
149
+ if (!dest) throw new Error(`name "${name}" rejected — path escapes artifacts base or is invalid`)
150
+
151
+ const buf = Buffer.isBuffer(content) ? content : Buffer.from(content)
152
+ const sha = createHash('sha256').update(buf).digest('hex')
153
+
154
+ fs.mkdirSync(taskDir, { recursive: true })
155
+
156
+ // REALPATH CONTAINMENT (directory level): after mkdirSync, resolve the real path of
157
+ // taskDir and verify it is still inside the canonical artifacts root.
158
+ // fs.realpathSync(worktreeDir) handles the case where the worktree itself lives under a
159
+ // legitimately-symlinked path; the canonical root is then built as a plain string (no
160
+ // further symlink-following), so a symlink planted at .flow, .flow/artifacts, or the
161
+ // taskKey directory is caught in one structural pass — its resolved path escapes the
162
+ // canonical root, and we throw.
163
+ // Replaces the per-position lstatSync whack-a-mole (was: check taskDir, check dest).
164
+ const canonicalWorktreeDir = fs.realpathSync(worktreeDir)
165
+ const canonicalRoot = path.join(canonicalWorktreeDir, '.flow', 'artifacts')
166
+ const canonicalTaskDir = fs.realpathSync(taskDir)
167
+ if (!canonicalTaskDir.startsWith(canonicalRoot + path.sep)) {
168
+ throw new Error(
169
+ `symlink escape: resolved path "${canonicalTaskDir}" escapes ` +
170
+ `canonical artifacts root "${canonicalRoot}"`
171
+ )
172
+ }
173
+
174
+ // LEAF CONTAINMENT (file level): the dir guard above contains the directory, but
175
+ // fs.writeFileSync follows a symlink planted AT the dest file itself — a symlink at
176
+ // <taskDir>/<name> (absolute-, relative-, or chained-target) would let the write escape
177
+ // the contained dir. Must run BEFORE the idempotency read below, which also follows a
178
+ // leaf symlink via readFileSync. (B5 breaks 1 & 3.)
179
+ assertLeafContained(fs, dest, canonicalTaskDir)
180
+
181
+ // Idempotency: if file already exists with identical bytes, skip the write.
182
+ // Safe now — assertLeafContained has proven dest is a regular file (or absent).
183
+ if (fs.existsSync(dest)) {
184
+ const existing = fs.readFileSync(dest)
185
+ if (existing.equals(buf)) return { path: dest, bytes: buf.length, sha }
186
+ }
187
+
188
+ fs.writeFileSync(dest, buf)
189
+ return { path: dest, bytes: buf.length, sha }
190
+ }
191
+
192
+ // ── digestSlice ───────────────────────────────────────────────────────────────
193
+ // Pure (no fs): compute a digest entry for a closed task. Returns { taskKey, summary,
194
+ // acceptanceProof, pointers }. Callers persist via appendDigest; the conductor reads
195
+ // back via loadDigest with the CEILING compression applied.
196
+ //
197
+ // summary: a single line, <= 120 chars: "[taskKey] title" optionally with the first
198
+ // line of acceptanceProof appended. Guaranteed to contain no newlines.
199
+ // pointers: [{ taskKey, name, path, sha }] from the artifacts written during the task.
200
+ // Artifacts whose taskKey/name/path/sha are missing/empty/undefined are
201
+ // silently dropped — prevents String(undefined) = "undefined" from leaking
202
+ // into pointer fields (DG-3).
203
+ export function digestSlice (task, { acceptanceProof = '', artifacts = [] } = {}) {
204
+ if (!task || !task.key) throw new Error('task with .key required')
205
+ const base = `[${task.key}] ${task.title ?? task.key}`
206
+ const firstLine = acceptanceProof ? String(acceptanceProof).split('\n')[0].trim() : ''
207
+ const summary = (firstLine ? `${base} — ${firstLine}` : base).slice(0, 120)
208
+ // DG-3: filter out artifacts with any missing/empty/undefined field before mapping.
209
+ // (artifacts ?? []) handles an explicit null — destructuring default only fires for
210
+ // undefined; passing artifacts:null leaves artifacts===null here, and null.filter throws.
211
+ // String(undefined) = "undefined" would produce bogus pointer field values.
212
+ const pointers = (artifacts ?? [])
213
+ .filter(a =>
214
+ a.taskKey != null && String(a.taskKey).trim() &&
215
+ a.name != null && String(a.name).trim() &&
216
+ a.path != null && String(a.path).trim() &&
217
+ a.sha != null && String(a.sha).trim()
218
+ )
219
+ .map(a => ({
220
+ taskKey: String(a.taskKey),
221
+ name: String(a.name),
222
+ path: String(a.path),
223
+ sha: String(a.sha),
224
+ }))
225
+ return { taskKey: task.key, summary, acceptanceProof: String(acceptanceProof), pointers }
226
+ }
227
+
228
+ // ── appendDigest ─────────────────────────────────────────────────────────────
229
+ // Persist a digest entry (from digestSlice) to <baseDir>/.flow/digests/<flowId>.jsonl.
230
+ // Append-only JSONL — insertion order is preserved for loadDigest.
231
+ //
232
+ // FLOWID CONTAINMENT: flowId is validated as a safe single path segment (no traversal,
233
+ // separators, null bytes, or whitespace-only) before being interpolated into the path.
234
+ // Prevents appendDigest('../../../victim', ...) from escaping baseDir (DG-1).
235
+ //
236
+ // LEAF CONTAINMENT: after the dir-level realpath check, assertLeafContained lstat's the
237
+ // <flowId>.jsonl file and rejects a leaf symlink (absolute-, relative-, or chained-target)
238
+ // before fs.appendFileSync can follow it out of the digests dir (B5 break 2).
239
+ //
240
+ // ZERO-POINTER guard: rejects any digest with an empty pointers array. Every persisted
241
+ // entry must have >=1 artifact pointer (invariant d). A task with no artifacts must not
242
+ // call appendDigest — the caller decides whether to skip or synthesize a pointer first.
243
+ //
244
+ // IDEMPOTENT: if the same (flowId, taskKey) is already persisted, this is a no-op.
245
+ // Calling appendDigest twice with the same digest never creates duplicate entries.
246
+ export function appendDigest (flowId, digest, { baseDir, fs = nodeFs } = {}) {
247
+ if (!safeSegmentId(flowId)) throw new Error(`flowId "${flowId}" rejected — must be a non-empty single path segment (no traversal, separators, or null bytes)`)
248
+ if (!baseDir) throw new Error('baseDir required')
249
+ if (!digest || !digest.taskKey) throw new Error('digest.taskKey required')
250
+ // ZERO-POINTER guard: every persisted digest must carry >=1 valid pointer (invariant d).
251
+ if (!digest.pointers || digest.pointers.length === 0) {
252
+ throw new Error(
253
+ `digest for "${digest.taskKey}" has no pointers — ` +
254
+ 'a closed slice must produce >=1 artifact pointer before persisting'
255
+ )
256
+ }
257
+ const dir = path.join(baseDir, '.flow', 'digests')
258
+ fs.mkdirSync(dir, { recursive: true })
259
+
260
+ // REALPATH CONTAINMENT: verify the resolved digests directory is still under the
261
+ // canonical base — same one-pass approach as writeLaneArtifact. Catches symlinks
262
+ // planted at .flow or .flow/digests. fs.realpathSync(baseDir) handles a legitimately-
263
+ // symlinked base; canonicalDigestsDir is built as a plain string thereafter.
264
+ const canonicalBase = fs.realpathSync(baseDir)
265
+ const canonicalDigestsDir = path.join(canonicalBase, '.flow', 'digests')
266
+ const canonicalDir = fs.realpathSync(dir)
267
+ if (canonicalDir !== canonicalDigestsDir) {
268
+ throw new Error(
269
+ `symlink escape: resolved digests dir "${canonicalDir}" escapes ` +
270
+ `canonical digests root "${canonicalDigestsDir}"`
271
+ )
272
+ }
273
+
274
+ const file = path.join(dir, `${flowId}.jsonl`)
275
+
276
+ // LEAF CONTAINMENT (file level): the dir guard above contains the digests directory, but
277
+ // fs.appendFileSync follows a symlink planted AT the <flowId>.jsonl leaf — a symlink
278
+ // there (absolute-, relative-, or chained-target) would let the append escape the
279
+ // contained dir. Must run BEFORE the idempotency readFileSync below, which also follows
280
+ // a leaf symlink. (B5 break 2.)
281
+ assertLeafContained(fs, file, canonicalDir)
282
+
283
+ // IDEMPOTENCY: skip if the same taskKey is already persisted for this flowId.
284
+ // Re-digesting the same (flowId, taskKey) is a no-op — no duplicate lines.
285
+ if (fs.existsSync(file)) {
286
+ const lines = String(fs.readFileSync(file, 'utf8')).split('\n').filter(l => l.trim())
287
+ for (const line of lines) {
288
+ try {
289
+ if (JSON.parse(line).taskKey === digest.taskKey) return
290
+ } catch { /* skip malformed lines */ }
291
+ }
292
+ }
293
+ fs.appendFileSync(file, JSON.stringify(digest) + '\n')
294
+ }
295
+
296
+ // ── loadDigest ────────────────────────────────────────────────────────────────
297
+ // Load all closed-slice digests for `flowId` from <baseDir>/.flow/digests/<flowId>.jsonl,
298
+ // applying the hard CEILING compression:
299
+ // • While accumulated serialized size + full-entry length <= CEILING → include full entry
300
+ // • Else if slim (no acceptanceProof) fits → include slim
301
+ // • Else → drop the entry; never exceed CEILING
302
+ //
303
+ // FLOWID CONTAINMENT: flowId is validated as a safe single path segment before use
304
+ // in path construction — same rule as appendDigest (DG-1).
305
+ //
306
+ // CEILING tracking is exact: accumulated == JSON.stringify(result).length at all times,
307
+ // accounting for the outer '['/']' and comma separators between entries.
308
+ //
309
+ // Dedup by taskKey (first occurrence wins) and skips zero-pointer entries for safety
310
+ // against old data that may pre-date the appendDigest zero-pointer guard.
311
+ //
312
+ // Returns digests in insertion order. Total JSON.stringify(result).length <= CEILING.
313
+ export function loadDigest (flowId, { baseDir, fs = nodeFs } = {}) {
314
+ if (!safeSegmentId(flowId)) throw new Error(`flowId "${flowId}" rejected — must be a non-empty single path segment (no traversal, separators, or null bytes)`)
315
+ if (!baseDir) throw new Error('baseDir required')
316
+ const file = path.join(baseDir, '.flow', 'digests', `${flowId}.jsonl`)
317
+ if (!fs.existsSync(file)) return []
318
+ const lines = String(fs.readFileSync(file, 'utf8')).split('\n').filter(l => l.trim())
319
+ const result = []
320
+ const seen = new Set() // dedup by taskKey — first occurrence wins
321
+ // Track exact JSON.stringify(result).length:
322
+ // '[' entries.join(',') ']' = 2 + sum(entryLen) + (n-1 commas)
323
+ // Start at 2 for the outer brackets. Each entry adds (comma if not first) + entryLen.
324
+ // Invariant: accumulated === JSON.stringify(result).length after every push.
325
+ let accumulated = 2 // '[' + ']'
326
+ for (const line of lines) {
327
+ let entry
328
+ try { entry = JSON.parse(line) } catch { continue }
329
+ if (!entry.taskKey) continue
330
+ // Dedup: appendDigest should prevent duplicates, but guard here for old data
331
+ if (seen.has(entry.taskKey)) continue
332
+ // Safety: skip zero-pointer entries (appendDigest now rejects them, but old JSONL may have them)
333
+ if (!entry.pointers || entry.pointers.length === 0) continue
334
+ seen.add(entry.taskKey)
335
+ const slim = { taskKey: entry.taskKey, summary: entry.summary, pointers: entry.pointers }
336
+ const comma = result.length > 0 ? 1 : 0 // comma separator before every entry except the first
337
+ const fullLen = line.length
338
+ const slimLen = JSON.stringify(slim).length
339
+ if (accumulated + comma + fullLen <= CEILING) {
340
+ result.push(entry)
341
+ accumulated += comma + fullLen
342
+ } else if (accumulated + comma + slimLen <= CEILING) {
343
+ // Full would exceed ceiling: emit the slim form (never drop the pointer)
344
+ result.push(slim)
345
+ accumulated += comma + slimLen
346
+ }
347
+ // else: neither full nor slim fits within CEILING — drop this entry.
348
+ // The hard guarantee: JSON.stringify(result).length <= CEILING always holds.
349
+ }
350
+ return result
351
+ }
352
+
353
+ // ── resumeLane ────────────────────────────────────────────────────────────────
354
+ // Re-read a lane's own prior artifacts from <worktreeDir>/.flow/artifacts/.
355
+ // Returns an array of { taskKey, name, path, bytes, sha } — identical to what
356
+ // writeLaneArtifact would have returned for each artifact originally written.
357
+ //
358
+ // IDEMPOTENT: calling multiple times on the same worktree gives the same result.
359
+ // A re-dispatched lane calls this on startup to reconstruct its prior state without
360
+ // redoing any writes. Artifacts whose files are unreadable are silently skipped.
361
+ export function resumeLane (flowId, { worktreeDir, fs = nodeFs } = {}) {
362
+ if (!flowId) throw new Error('flowId required')
363
+ if (!worktreeDir) throw new Error('worktreeDir required')
364
+ const artifactsBase = path.join(worktreeDir, '.flow', 'artifacts')
365
+ if (!fs.existsSync(artifactsBase)) return []
366
+ // [SCOPE: accepted] The read path here is intentionally NOT realpath-checked.
367
+ // resumeLane reads artifacts but does not write; traversing a symlink-redirected path
368
+ // cannot exfiltrate data the caller couldn't already read by other means. The worktree
369
+ // is trusted, non-shared, and single-user by design (git worktree, browser-side flow).
370
+ // The WRITE path in writeLaneArtifact IS realpath-checked — that's where containment
371
+ // matters. If the threat model changes (multi-user, shared fs), add realpath here too.
372
+ const artifacts = []
373
+ for (const taskKey of fs.readdirSync(artifactsBase)) {
374
+ const taskDirPath = path.join(artifactsBase, taskKey)
375
+ let stat
376
+ try { stat = fs.statSync(taskDirPath) } catch { continue }
377
+ if (!stat.isDirectory()) continue
378
+ for (const name of fs.readdirSync(taskDirPath)) {
379
+ const filePath = path.join(taskDirPath, name)
380
+ let buf
381
+ try { buf = fs.readFileSync(filePath) } catch { continue }
382
+ const sha = createHash('sha256').update(buf).digest('hex')
383
+ artifacts.push({ taskKey, name, path: filePath, bytes: buf.length, sha })
384
+ }
385
+ }
386
+ return artifacts
387
+ }
@@ -0,0 +1,72 @@
1
+ // Thinkpool Flow — S4 clean lane kill + re-dispatch (slice 2, flow-redispatch-clean).
2
+ //
3
+ // When a lane is force-killed mid-tool-call (budget kill-switch, review reject, restart)
4
+ // and re-dispatched, three things must hold before the new lane replays the old work:
5
+ //
6
+ // (a) HEALED TRANSCRIPT — a lane killed mid-tool-call leaves an assistant message with a
7
+ // tool_use block that has NO matching tool_result. On the next SDK replay Anthropic
8
+ // 400s ("tool_use ids were found without tool_result blocks"). sanitizeSession's
9
+ // Heal 3 (bridge/transcript-sanitize.mjs — DONE, do not rewrite) injects a synthetic
10
+ // tool_result placeholder. We CONSUME it here: run it on the dying lane's transcript
11
+ // so a resume is clean. Idempotent — a clean transcript is a no-op.
12
+ //
13
+ // (d) REVERT TARGET PRESERVED — revertLane deletes the lane's branch (`git branch -D`), which
14
+ // destroys the commit the killed lane made. The revert target (commit_sha, recorded
15
+ // room-side on flow_tasks) must survive the re-dispatch so a later reviewer still knows
16
+ // what to roll back to. We carry it in the pending-redispatch record.
17
+ //
18
+ // H41 (realtime collision) — NOT this module's concern, by construction. A re-dispatched lane
19
+ // opens NO realtime channel of its own: the bridge opens exactly two channels ONCE at startup
20
+ // (`tpcode:<room>` presence + `tpflow:<room>` presence-free broadcast), and a re-dispatched
21
+ // lane reuses the existing room-wide `tpflow:<room>` broadcast. With no per-lane subscribe there
22
+ // is no second .subscribe() to race a presence callback (feedback_no_duplicate_realtime_channel,
23
+ // Flow #134/#136), so no duplicate-subscribe collision is possible. Earlier drafts computed a
24
+ // per-lane `flowRedispatchTopic` — it was dead (never read by any subscribe) and was removed so
25
+ // a future reader doesn't mistake it for live realtime coverage.
26
+ //
27
+ // DBOS-idempotent: re-running prepareRedispatch on an already-clean lane heals nothing and
28
+ // re-dispatch of a lane with no pending record resumes nothing — both are no-ops.
29
+ //
30
+ // Pure over an injected `sanitize` so it's unit-testable without touching a real transcript.
31
+ // Spec: docs/specs/2026-06-30-flow-build-s4-clean-redispatch.md.
32
+
33
+ /**
34
+ * Prepare a killed lane for clean re-dispatch. Heals its transcript (so a resume can't 400 on
35
+ * a dangling tool_use) and returns everything the re-dispatch needs to resume cleanly.
36
+ *
37
+ * @param {object} o
38
+ * @param {object} o.lane the killed lane's session entry (needs cwd, session.sessionId, commitSha/flowReviewTarget)
39
+ * @param {function} o.sanitize (cwd, sessionId) => { blocks } — inject sanitizeSession here (the Heal-3 consumer)
40
+ * @returns {{ resumeSessionId: string|null, revertTarget: string|null, healed: number, cwd: string|null }}
41
+ */
42
+ export function prepareRedispatch ({ lane, sanitize }) {
43
+ const cwd = lane?.cwd || null
44
+ const resumeSessionId = lane?.session?.sessionId || lane?.sessionId || null
45
+ // Revert target survives the branch deletion: prefer an explicitly recorded commit, else
46
+ // any commitSha the lane carried. null when the lane never committed (nothing to preserve).
47
+ const revertTarget = lane?.revertTarget ?? lane?.commitSha ?? null
48
+
49
+ let healed = 0
50
+ // Heal ONLY when there's a transcript to heal (a resumable session). No sessionId → the lane
51
+ // never got a live SDK session, so there is nothing to replay and nothing to heal (no-op).
52
+ if (resumeSessionId && typeof sanitize === 'function') {
53
+ try {
54
+ const r = sanitize(cwd, resumeSessionId)
55
+ healed = (r && typeof r.blocks === 'number') ? r.blocks : 0
56
+ } catch {
57
+ healed = 0 // a sanitize failure must never block re-dispatch — worst case, resume as before
58
+ }
59
+ }
60
+
61
+ return { resumeSessionId, revertTarget, healed, cwd }
62
+ }
63
+
64
+ /**
65
+ * A pending-redispatch registry. When flow-revert kills a lane, it records the prepared
66
+ * re-dispatch (keyed by flowId+taskKey) so the next flow-dispatch wave resumes the healed
67
+ * session on a clean topic with the revert target intact. Idempotent: re-dispatching a task
68
+ * with no pending record (a first, never-killed dispatch) resolves to a fresh lane (no resume).
69
+ */
70
+ export function redispatchKey (flowId, taskKey) {
71
+ return `${flowId}::${taskKey}`
72
+ }
@@ -40,7 +40,7 @@
40
40
  // reviewer's worktree, not the slice. The wiring layer that provisions the reviewer lane
41
41
  // MUST guarantee this worktree isolation — this gate cannot substitute for it.
42
42
 
43
- import { realpathSync as nodeRealpathSync } from 'node:fs'
43
+ import { realpathSync as nodeRealpathSync, readlinkSync, lstatSync } from 'node:fs'
44
44
  import path from 'node:path'
45
45
 
46
46
  // ── Tool classification ───────────────────────────────────────────────────────
@@ -175,7 +175,17 @@ function _assessFilePath ({
175
175
  }
176
176
  }
177
177
  } catch {
178
- // verdictFile doesn't exist yet — no symlink possible; allow.
178
+ // verdictFile doesn't exist yet — realpathSync(verdictFile) threw ENOENT.
179
+ // That does NOT prove "no symlink possible": a symlinked ANCESTOR directory (or a
180
+ // dangling symlink named FLOW_REVIEW.json) can still resolve the write into the slice.
181
+ // Realpath the nearest EXISTING ancestor and re-check containment of the full target.
182
+ const realAncestor = _realpathNearestAncestor(resolvedVerdict, _realpath)
183
+ if (realAncestor && (_isUnder(realAncestor, canonicalSliceRoot) || _isUnder(realAncestor, inputSliceRoot))) {
184
+ return {
185
+ allow: false,
186
+ reason: `verdictFile "${verdictFile}" resolves (via a symlinked path component) to "${realAncestor}" inside sliceRoot — denied`,
187
+ }
188
+ }
179
189
  }
180
190
  return { allow: true, reason: `write to verdictFile (${path.basename(verdictFile)}) — allowed` }
181
191
  }
@@ -201,12 +211,67 @@ function _assessFilePath ({
201
211
  }
202
212
  }
203
213
  } catch {
204
- // ENOENT or other — step 1 already handled the in-slice case.
214
+ // ENOENT — the file (or a component of it) doesn't exist yet, so realpathSync(filePath)
215
+ // threw before it could follow any symlinked ANCESTOR directory. Step 1 only normalized
216
+ // '..' — it did NOT resolve a symlinked path component (e.g. scratch/link → sliceRoot,
217
+ // then Write scratch/link/PWNED.js lands in the slice). Realpath the nearest EXISTING
218
+ // ancestor, re-join the un-created tail, and re-check containment.
219
+ const realAncestor = _realpathNearestAncestor(resolved, _realpath)
220
+ if (realAncestor && (_isUnder(realAncestor, canonicalSliceRoot) || _isUnder(realAncestor, inputSliceRoot))) {
221
+ return {
222
+ allow: false,
223
+ reason: `${toolName} to "${filePath}" resolves (via a symlinked path component) to "${realAncestor}" inside sliceRoot — denied`,
224
+ }
225
+ }
205
226
  }
206
227
 
207
228
  return { allow: true, reason: `${toolName} to "${filePath}" is outside sliceRoot — allowed` }
208
229
  }
209
230
 
231
+ // ── _realpathNearestAncestor ────────────────────────────────────────────────────
232
+ // For a target path that does NOT fully exist (realpathSync threw ENOENT), walk UP to the
233
+ // nearest existing ancestor directory, realpath-resolve THAT (following any symlinked
234
+ // component), then re-join the remaining un-created tail. The returned absolute path is what
235
+ // the write would ACTUALLY land on once created — which is what containment must be tested
236
+ // against. Returns null only if even the filesystem root can't be resolved (never expected).
237
+ function _realpathNearestAncestor (resolved, _realpath) {
238
+ // If the LEAF itself is a symlink (existing but DANGLING — its target doesn't exist, so
239
+ // realpathSync(resolved) threw), follow the link one hop and resolve from THERE. This is the
240
+ // dangling-verdict-symlink case: scratch/FLOW_REVIEW.json → <sliceRoot>/evil.js. path.resolve
241
+ // does NOT follow symlinks, so without this the write's true landing spot is invisible.
242
+ try {
243
+ if (lstatSync(resolved).isSymbolicLink()) {
244
+ const linkTarget = readlinkSync(resolved)
245
+ const absTarget = path.isAbsolute(linkTarget)
246
+ ? linkTarget
247
+ : path.resolve(path.dirname(resolved), linkTarget)
248
+ // Recurse: the target may itself be non-existent / chained through more symlinks.
249
+ try {
250
+ return _realpath(absTarget)
251
+ } catch {
252
+ return _realpathNearestAncestor(absTarget, _realpath)
253
+ }
254
+ }
255
+ } catch {
256
+ // lstat failed — leaf doesn't exist at all; fall through to ancestor walk below.
257
+ }
258
+
259
+ let ancestor = path.dirname(resolved)
260
+ const tail = [path.basename(resolved)]
261
+ // Walk up until an ancestor resolves, or we hit the root (dirname is a fixed point).
262
+ while (true) {
263
+ try {
264
+ const realAncestor = _realpath(ancestor)
265
+ return path.join(realAncestor, ...tail.reverse())
266
+ } catch {
267
+ const parent = path.dirname(ancestor)
268
+ if (parent === ancestor) return null // reached root without resolving — give up
269
+ tail.push(path.basename(ancestor))
270
+ ancestor = parent
271
+ }
272
+ }
273
+ }
274
+
210
275
  // ── _assessBashCommand ────────────────────────────────────────────────────────
211
276
  // HEURISTIC — see BASH LIMITATION comment at top of file.
212
277
 
@@ -294,6 +359,41 @@ function _assessBashCommand ({ cmd, inputSliceRoot, canonicalSliceRoot }) {
294
359
  return { allow: true, reason: 'Bash command allowed (no write-to-slice pattern detected)' }
295
360
  }
296
361
 
362
+ // ── reviewGatePreToolDecision ───────────────────────────────────────────────────
363
+ // S5 (slice 1b) — the LIVE PreToolUse wiring seam. Lives HERE (not in claude-session.mjs)
364
+ // so it stays SDK-free and unit-testable: claude-session.mjs pulls the Claude Agent SDK at
365
+ // module load, which isn't resolvable in the test env. The PreToolUse hook imports and calls
366
+ // this; the integration test imports it directly.
367
+ //
368
+ // `reviewGate` is a per-review-lane closure (bridge.mjs folds reviewGateDecision over the
369
+ // reviewed slices' worktree roots); it is NULL on builder lanes. Returns:
370
+ // • null → fall through to the normal permission path (read / run-test / verdict).
371
+ // • deny payload → a terminal PreToolUse `permissionDecision: 'deny'` the hook returns
372
+ // immediately — BEFORE the auto-allow/bypass path, so the block is
373
+ // structural even in the lane's bypassPermissions mode.
374
+ // A throwing reviewGate fails CLOSED (deny) — never a silent allow. Builder lanes
375
+ // (reviewGate=null) always return null here → their toolset is UNCHANGED.
376
+ export function reviewGatePreToolDecision ({ reviewGate, toolName, toolInput }) {
377
+ if (!reviewGate) return null
378
+ let g = null
379
+ try {
380
+ g = reviewGate({ toolName, input: toolInput })
381
+ } catch (e) {
382
+ g = { allow: false, reason: `review gate error — denied: ${e?.message || e}` }
383
+ }
384
+ if (g && g.allow === false) {
385
+ return {
386
+ continue: true,
387
+ hookSpecificOutput: {
388
+ hookEventName: 'PreToolUse',
389
+ permissionDecision: 'deny',
390
+ permissionDecisionReason: `Blocked (Flow review lane): ${g.reason} You REVIEW the slice — you do not edit it. Read it, run it to reproduce acceptance, then emit your verdict by Writing FLOW_REVIEW.json.`,
391
+ },
392
+ }
393
+ }
394
+ return null
395
+ }
396
+
297
397
  // ── helpers ───────────────────────────────────────────────────────────────────
298
398
 
299
399
  // Returns true if `filePath` equals `dir` OR is strictly inside it.
@@ -0,0 +1,140 @@
1
+ // Thinkpool Flow — progressive skill registry.
2
+ //
3
+ // A Flow lane boots with only skill METADATA (name + description + whenToUse) injected
4
+ // into its base prompt — the compact manifest(). The full SKILL.md body loads ON DEMAND
5
+ // via loadBody(name) only when the lane actually activates that skill. Token win: the
6
+ // manifest is orders of magnitude smaller than the sum of all skill bodies.
7
+ //
8
+ // Format: each skill lives at <dir>/<skill-name>/SKILL.md with agentskills.io YAML
9
+ // frontmatter (name, description, and optional whenToUse). The body is the full SKILL.md
10
+ // file content (frontmatter included) — the complete text a lane sees on activation.
11
+ //
12
+ // Public/custom split: publicDir carries built-in product skills; customDir (optional)
13
+ // carries user/room-level overrides. Custom skills override public ones by `name` field.
14
+ //
15
+ // Pure module — canonical at src/lib/flow/skillRegistry.js, mirrored byte-identically
16
+ // at bridge/flow-skill-registry.mjs (same reason as taskGraph / contextStore: the bridge
17
+ // ships as thinkpool-pair npm package and can't import across the bridge↔src boundary
18
+ // at publish time). Sync: bridge/flow-skill-registry.sync.test.js.
19
+ //
20
+ // Node built-ins only (node:fs, node:path) — no new deps.
21
+ // All fs calls are injectable (fs option) so logic is unit-testable against temp dirs.
22
+
23
+ import nodeFs from 'node:fs'
24
+ import nodePath from 'node:path'
25
+
26
+ // ── parseFrontmatter ──────────────────────────────────────────────────────────
27
+ // Parse agentskills.io YAML-like frontmatter from a SKILL.md string.
28
+ // Returns { meta: { name, description, whenToUse? } } or null if malformed/missing.
29
+ // Required fields: name, description — missing either → null (skill skipped from manifest).
30
+ // Tolerant: unknown YAML fields silently ignored; only scalar key:value pairs parsed.
31
+ function parseFrontmatter (content) {
32
+ if (typeof content !== 'string') return null
33
+ // Strip BOM if present
34
+ const s = content.charCodeAt(0) === 0xFEFF ? content.slice(1) : content
35
+ if (!s.startsWith('---')) return null
36
+ const afterOpen = s.slice(3)
37
+ // Closing delimiter: a line that is exactly '---' (accept \r\n or \n line endings)
38
+ const closeMatch = afterOpen.match(/\r?\n---(?:\r?\n|$)/)
39
+ if (!closeMatch) return null
40
+ const yamlBlock = afterOpen.slice(0, closeMatch.index)
41
+
42
+ // Scalar key: value extraction only — not a full YAML parser; lists/nested maps silently mis-parsed.
43
+ const meta = {}
44
+ for (const line of yamlBlock.split(/\r?\n/)) {
45
+ const colonIdx = line.indexOf(':')
46
+ if (colonIdx === -1) continue
47
+ const key = line.slice(0, colonIdx).trim()
48
+ const val = line.slice(colonIdx + 1).trim()
49
+ if (key) meta[key] = val
50
+ }
51
+
52
+ // Both name and description are required for a valid skill entry
53
+ if (!meta.name || !meta.description) return null
54
+ return { meta }
55
+ }
56
+
57
+ // ── scanDir ───────────────────────────────────────────────────────────────────
58
+ // Scan `dir` for skill entries. Each valid skill is a subdirectory containing a SKILL.md
59
+ // with parseable frontmatter (name + description required). Malformed SKILL.md → skipped
60
+ // without throwing. Missing or inaccessible dir → returns [].
61
+ // Returns [{ name, description, whenToUse?, _path }] where _path is the absolute SKILL.md
62
+ // path (internal — stripped from manifest() output, used by loadBody).
63
+ function scanDir (dir, { fs = nodeFs } = {}) {
64
+ if (!dir) return []
65
+ let entries
66
+ try {
67
+ entries = fs.readdirSync(dir)
68
+ } catch (e) {
69
+ if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) return []
70
+ throw e
71
+ }
72
+ const skills = []
73
+ for (const entry of entries) {
74
+ const skillDir = nodePath.join(dir, entry)
75
+ let stat
76
+ // lstatSync does NOT follow symlinks — a symlinked entry returns isSymbolicLink()=true
77
+ // and isDirectory()=false, so it is skipped. Using statSync would follow the link and
78
+ // allow scanning a directory outside publicDir/customDir (symlink escape vector).
79
+ try { stat = fs.lstatSync(skillDir) } catch { continue }
80
+ if (!stat.isDirectory()) continue
81
+ const skillMdPath = nodePath.join(skillDir, 'SKILL.md')
82
+ // Belt-and-suspenders: lstat the SKILL.md too — skip if it is itself a symlink.
83
+ // Covers a file-level symlink inside a real (non-symlinked) skill dir.
84
+ let skillMdStat
85
+ try { skillMdStat = fs.lstatSync(skillMdPath) } catch { continue }
86
+ if (skillMdStat.isSymbolicLink()) continue
87
+ let content
88
+ try { content = fs.readFileSync(skillMdPath, 'utf8') } catch { continue }
89
+ const parsed = parseFrontmatter(content)
90
+ if (!parsed) continue // malformed — skip silently, do not throw
91
+ const { meta } = parsed
92
+ const skill = { name: meta.name, description: meta.description, _path: skillMdPath }
93
+ if (meta.whenToUse) skill.whenToUse = meta.whenToUse
94
+ skills.push(skill)
95
+ }
96
+ return skills
97
+ }
98
+
99
+ // ── manifest ──────────────────────────────────────────────────────────────────
100
+ // Build the compact skill manifest: an array of { name, description, whenToUse? } per
101
+ // skill — METADATA ONLY, no SKILL.md body. This is what a lane injects into its base
102
+ // prompt to know which skills are available without loading any body.
103
+ //
104
+ // Merge rule: publicDir skills load first; customDir skills then override/extend by
105
+ // `name`. A custom skill with the same name as a public skill replaces it in the result.
106
+ //
107
+ // Returns [] if both dirs are absent or contain no valid skills.
108
+ export function manifest ({ publicDir, customDir, fs = nodeFs } = {}) {
109
+ const publicSkills = scanDir(publicDir, { fs })
110
+ const customSkills = scanDir(customDir, { fs })
111
+ // Merge: public first, custom overrides by name
112
+ const byName = new Map()
113
+ for (const s of publicSkills) byName.set(s.name, s)
114
+ for (const s of customSkills) byName.set(s.name, s)
115
+ // Return compact entries — strip internal _path
116
+ return [...byName.values()].map(({ name, description, whenToUse }) => {
117
+ const entry = { name, description }
118
+ if (whenToUse !== undefined) entry.whenToUse = whenToUse
119
+ return entry
120
+ })
121
+ }
122
+
123
+ // ── loadBody ──────────────────────────────────────────────────────────────────
124
+ // Load the full SKILL.md content for the named skill, on demand.
125
+ // customDir is checked before publicDir (a custom skill overrides a public of the same name).
126
+ // Unknown name / skill not found in either dir → returns null (clean miss, never throws).
127
+ export function loadBody (name, { publicDir, customDir, fs = nodeFs } = {}) {
128
+ if (!name || typeof name !== 'string') return null
129
+ // Custom takes priority over public — check custom first
130
+ for (const dir of [customDir, publicDir]) {
131
+ const skills = scanDir(dir, { fs })
132
+ const found = skills.find(s => s.name === name)
133
+ if (found) {
134
+ try {
135
+ return fs.readFileSync(found._path, 'utf8')
136
+ } catch { /* skill file disappeared — try next dir */ }
137
+ }
138
+ }
139
+ return null // clean miss — not found in either dir
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.112",
3
+ "version": "0.7.114",
4
4
  "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,6 +24,9 @@
24
24
  "flow-review-gate.mjs",
25
25
  "flow-assembly.mjs",
26
26
  "flow-budget.mjs",
27
+ "flow-context-store.mjs",
28
+ "flow-redispatch.mjs",
29
+ "flow-skill-registry.mjs",
27
30
  "serve-dir.mjs",
28
31
  "service.mjs",
29
32
  "presence.mjs",