thinkpool-pair 0.7.97 → 0.7.99

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
@@ -40,7 +40,11 @@ import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
40
40
  import { z } from 'zod'
41
41
  import { startClaudeSession } from './claude-session.mjs'
42
42
  import { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, buildConductorEnv } from './flow-conductor.mjs'
43
- import { createFlowWorktree } from './flow-worktree.mjs'
43
+ import { createFlowWorktree, worktreeSpec } from './flow-worktree.mjs'
44
+ import { startPreview, stopAllPreviews } from './flow-preview.mjs'
45
+ import { FLOW_REVIEWER_PROMPT, revertLane } from './flow-review.mjs'
46
+ import { mergeWorktrees, inlineSingleHtml, initRepo } from './flow-assembly.mjs'
47
+ import { canDispatch, FLOW_LIMITS } from './flow-budget.mjs'
44
48
  import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, CROSSROOM, formatPairRoster, crossRoomPostDecision } from './cross-terminal.mjs'
45
49
  import { turnInFlight } from './update-gate.mjs'
46
50
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq } from './session-store.mjs'
@@ -456,6 +460,15 @@ const channel = supabase.channel(`tpcode:${room}`, {
456
460
  config: { broadcast: { self: false }, presence: { key: `bridge:${name}` } },
457
461
  })
458
462
 
463
+ // Thinkpool Flow rides its OWN topic, separate from the room's primary tpcode channel.
464
+ // The web's useFlow hook is a child of CodeRoom, so its effect runs before the room's —
465
+ // sharing tpcode meant useFlow created/owned/removed the room's channel and killed
466
+ // realtime on room open (the 2026-06-30 snag). A dedicated topic keeps the two lifecycles
467
+ // from colliding. The bridge is a separate client, so a second channel here is free.
468
+ const flowChannel = supabase.channel(`tpflow:${room}`, {
469
+ config: { broadcast: { self: false } },
470
+ })
471
+
459
472
  // ── terminal registry ──────────────────────────────────────────────
460
473
  // id → { term (pty), cmd, attached, scrollback, buf }
461
474
  const terms = new Map()
@@ -525,14 +538,14 @@ const defendFrame = (event, payload) => {
525
538
  return p
526
539
  }
527
540
 
528
- const bcast = (event, payload) => {
541
+ const bcast = (event, payload, ch = channel) => {
529
542
  if (event === 'pty-out' || event === 'code-event') lastActivity = Date.now()
530
543
  payload = defendFrame(event, payload)
531
544
  try {
532
- if (channel.channelAdapter?.canPush?.() ?? true) {
533
- channel.send({ type: 'broadcast', event, payload })
545
+ if (ch.channelAdapter?.canPush?.() ?? true) {
546
+ ch.send({ type: 'broadcast', event, payload })
534
547
  } else {
535
- channel.httpSend(event, payload).catch(() => { /* offline — replay covers it */ })
548
+ ch.httpSend(event, payload).catch(() => { /* offline — replay covers it */ })
536
549
  }
537
550
  } catch { /* noop */ }
538
551
  }
@@ -1295,9 +1308,14 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1295
1308
  if (!entry.flowSessionId || !entry.flowTaskKey) return okText('Not a Flow lane — nothing to mark done.')
1296
1309
  let commitSha = null
1297
1310
  try { commitSha = execFileSync('git', ['-C', entry.cwd || process.cwd(), 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() } catch { /* no commits yet */ }
1298
- bcast('flow-task-done', { term: id, flowId: entry.flowSessionId, taskKey: entry.flowTaskKey, laneId: id, commitSha })
1299
- process.stderr.write(`\n ${A.cyan}◆ flow slice done — ${entry.flowTaskKey} (${commitSha ? commitSha.slice(0, 8) : 'no commit'})${A.rst}\n`)
1300
- return okText(`Slice "${entry.flowTaskKey}" marked done${commitSha ? ` (commit ${commitSha.slice(0, 8)})` : ' (no commit yet — commit, then re-call)'}. The room records it + dispatches any slices that depended on you.`)
1311
+ // Step 3 runtime: serve this lane's worktree so the room can iframe a live
1312
+ // preview. Static serve — works for the no-build single-file artifact; URL
1313
+ // rides the done broadcast.
1314
+ let previewUrl = null
1315
+ try { const pv = await startPreview({ dir: entry.cwd || process.cwd(), id: `lane:${id}` }); previewUrl = pv.url } catch { /* preview best-effort */ }
1316
+ bcast('flow-task-done', { term: id, flowId: entry.flowSessionId, taskKey: entry.flowTaskKey, laneId: id, commitSha, previewUrl }, flowChannel)
1317
+ process.stderr.write(`\n ${A.cyan}◆ flow slice done — ${entry.flowTaskKey} (${commitSha ? commitSha.slice(0, 8) : 'no commit'})${previewUrl ? ` · preview ${previewUrl}` : ''}${A.rst}\n`)
1318
+ return okText(`Slice "${entry.flowTaskKey}" marked done${commitSha ? ` (commit ${commitSha.slice(0, 8)})` : ' (no commit yet — commit, then re-call)'}${previewUrl ? `. Live preview: ${previewUrl}` : ''}. The room records it + dispatches any slices that depended on you.`)
1301
1319
  },
1302
1320
  ),
1303
1321
  ],
@@ -1374,7 +1392,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1374
1392
  // task-graph + flips status → awaiting_approval → FlowPlanCard renders. Park
1375
1393
  // the conductor ('keep' = stay in plan mode, idle; lane dispatch is Step 2).
1376
1394
  if (entry.flowSessionId && req.risk === 'plan') {
1377
- bcast('flow-plan', { term: id, flowId: entry.flowSessionId, plan: req.plan || '' })
1395
+ bcast('flow-plan', { term: id, flowId: entry.flowSessionId, plan: req.plan || '' }, flowChannel)
1378
1396
  process.stderr.write(`\n ${A.mag}◆ flow plan ready — flow ${String(entry.flowSessionId).slice(0, 8)}.${A.rst}\n`)
1379
1397
  resolve('keep')
1380
1398
  return
@@ -1494,63 +1512,6 @@ channel
1494
1512
  }
1495
1513
  shutdown()
1496
1514
  })
1497
- .on('broadcast', { event: 'flow-start' }, ({ payload }) => {
1498
- // Ensemble Flow — the room asks this bridge to launch a CONDUCTOR for a Flow run.
1499
- // The room already created the flow_sessions row (flowId); the conductor opens in
1500
- // plan mode (so ExitPlanMode is permitted), seeded with the build prompt. It
1501
- // decomposes → calls ExitPlanMode(planJson) → the requestPermission flow-plan branch
1502
- // broadcasts flow-plan back. `host` routes to one bridge in multi-machine rooms.
1503
- if (!payload?.flowId) return
1504
- if (payload.host && payload.host !== name) return
1505
- for (const [, e] of sessions) if (e.flowSessionId === payload.flowId) return // already conducting this flow
1506
- const cid = randomUUID()
1507
- termNames[cid] = `Flow · ${String(payload.flowId).slice(0, 6)}`
1508
- saveNames(room, termNames)
1509
- openStructured({ id: cid, mode: 'plan', rolePrompt: FLOW_CONDUCTOR_PROMPT, flowSessionId: payload.flowId })
1510
- const ce = sessions.get(cid)
1511
- if (ce?.session) { try { ce.session.sendTurn(payload.prompt || '') } catch { /* session still starting */ } }
1512
- process.stderr.write(`\n ${A.mag}◆ flow conductor launched — flow ${String(payload.flowId).slice(0, 8)} (plan mode).${A.rst}\n`)
1513
- announce()
1514
- })
1515
- .on('broadcast', { event: 'flow-dispatch' }, ({ payload }) => {
1516
- // Ensemble Flow — the room approved the plan + sends the READY tasks. Spawn one
1517
- // worktree-isolated lane per task (FLOW_LANE_PROMPT, its own git tree so lanes
1518
- // never collide — M57). Seed each lane with its slice spec, then echo the
1519
- // assignments back so the room marks the tasks dispatched + flips the flow to
1520
- // building (DB writes stay room-side, member-authed).
1521
- if (!payload?.flowId || !Array.isArray(payload.tasks)) return
1522
- if (payload.host && payload.host !== name) return
1523
- const assignments = []
1524
- for (const t of payload.tasks) {
1525
- try {
1526
- const { dir } = createFlowWorktree({ flowId: payload.flowId, taskKey: t.task_key })
1527
- const laneId = randomUUID()
1528
- termNames[laneId] = `Flow · ${t.task_key}`
1529
- saveNames(room, termNames)
1530
- openStructured({ id: laneId, cwd: dir, mode: 'acceptEdits', rolePrompt: FLOW_LANE_PROMPT, flowSessionId: payload.flowId, flowTaskKey: t.task_key, spawnedBy: `flow:${payload.flowId}` })
1531
- const le = sessions.get(laneId)
1532
- if (le?.session) {
1533
- const spec =
1534
- `[Flow lane — slice "${t.task_key}" of flow ${String(payload.flowId).slice(0, 8)}]\n` +
1535
- `TITLE: ${t.title}\n` +
1536
- `SCOPE (files you OWN — edit ONLY these): ${t.scope || '(none stated)'}\n` +
1537
- `ACCEPTANCE (done = this runs + proves it): ${t.acceptance || '(meet the title)'}\n` +
1538
- (t.deps && t.deps.length ? `DEPENDS ON (already built): ${t.deps.join(', ')}\n` : '') +
1539
- `\nProject (context): ${payload.flowPrompt || ''}\n\n` +
1540
- `Build your slice. Own only your files. Done = it runs + meets acceptance. Commit when done.`
1541
- try { le.session.sendTurn(spec) } catch { /* session still starting */ }
1542
- }
1543
- assignments.push({ task_key: t.task_key, laneId })
1544
- } catch (e) {
1545
- process.stderr.write(`\n ${A.yel}◆ flow dispatch failed for ${t.task_key}: ${e?.message || e}${A.rst}\n`)
1546
- }
1547
- }
1548
- if (assignments.length) {
1549
- bcast('flow-dispatched', { term: 'flow', flowId: payload.flowId, assignments })
1550
- process.stderr.write(`\n ${A.mag}◆ flow dispatched ${assignments.length} lane(s) — flow ${String(payload.flowId).slice(0, 8)}.${A.rst}\n`)
1551
- announce()
1552
- }
1553
- })
1554
1515
  .on('broadcast', { event: 'term-open' }, ({ payload }) => {
1555
1516
  if (!payload?.id || !payload?.cmd) return
1556
1517
  // Multi-bridge rooms: a targeted open is for ONE machine. Untargeted
@@ -1830,6 +1791,123 @@ channel
1830
1791
  }
1831
1792
  })
1832
1793
 
1794
+ // ── Thinkpool Flow control channel (tpflow:<room>) ──────────────────────────
1795
+ // Separate from the room channel above so the web's useFlow hook can own its own
1796
+ // topic without colliding with the room's tpcode channel (see the flowChannel def +
1797
+ // useFlow.js for the 2026-06-30 collision this fixes). Inbound: flow-start (launch a
1798
+ // conductor) + flow-dispatch (spawn lanes). Outbound (via bcast(..., flowChannel)):
1799
+ // flow-plan, flow-dispatched, flow-task-done.
1800
+ flowChannel
1801
+ .on('broadcast', { event: 'flow-start' }, ({ payload }) => {
1802
+ // Ensemble Flow — the room asks this bridge to launch a CONDUCTOR for a Flow run.
1803
+ // The room already created the flow_sessions row (flowId); the conductor opens in
1804
+ // plan mode (so ExitPlanMode is permitted), seeded with the build prompt. It
1805
+ // decomposes → calls ExitPlanMode(planJson) → the requestPermission flow-plan branch
1806
+ // broadcasts flow-plan back. `host` routes to one bridge in multi-machine rooms.
1807
+ if (!payload?.flowId) return
1808
+ if (payload.host && payload.host !== name) return
1809
+ for (const [, e] of sessions) if (e.flowSessionId === payload.flowId) return // already conducting this flow
1810
+ const cid = randomUUID()
1811
+ termNames[cid] = `Flow · ${String(payload.flowId).slice(0, 6)}`
1812
+ saveNames(room, termNames)
1813
+ openStructured({ id: cid, mode: 'plan', rolePrompt: FLOW_CONDUCTOR_PROMPT, flowSessionId: payload.flowId })
1814
+ const ce = sessions.get(cid)
1815
+ if (ce?.session) { try { ce.session.sendTurn(payload.prompt || '') } catch { /* session still starting */ } }
1816
+ process.stderr.write(`\n ${A.mag}◆ flow conductor launched — flow ${String(payload.flowId).slice(0, 8)} (plan mode).${A.rst}\n`)
1817
+ announce()
1818
+ })
1819
+ .on('broadcast', { event: 'flow-dispatch' }, ({ payload }) => {
1820
+ // Ensemble Flow — the room approved the plan + sends the READY tasks. Spawn one
1821
+ // worktree-isolated lane per task (FLOW_LANE_PROMPT, its own git tree so lanes
1822
+ // never collide — M57). Seed each lane with its slice spec, then echo the
1823
+ // assignments back so the room marks the tasks dispatched + flips the flow to
1824
+ // building (DB writes stay room-side, member-authed).
1825
+ if (!payload?.flowId || !Array.isArray(payload.tasks)) return
1826
+ if (payload.host && payload.host !== name) return
1827
+ const assignments = []
1828
+ // Step 6 — cap concurrent Flow lanes (invariant: ≤ FLOW_LIMITS.maxConcurrentLanes).
1829
+ // Overflow tasks stay pending; the room re-dispatches them in the next wave.
1830
+ let liveLanes = [...sessions.values()].filter(e => typeof e.spawnedBy === 'string' && e.spawnedBy.startsWith('flow:')).length
1831
+ for (const t of payload.tasks) {
1832
+ const gate = canDispatch({ mode: payload.mode || 'guide', liveLanes, budget: { capTokens: null, spentTokens: 0, killed: false } })
1833
+ if (!gate.ok) { process.stderr.write(`\n ${A.yel}◆ flow dispatch held (${gate.reason}, ${liveLanes}/${FLOW_LIMITS.maxConcurrentLanes} lanes) — ${assignments.length} spawned this wave.${A.rst}\n`); break }
1834
+ try {
1835
+ // Step 4 — a `review` slice gets the adversarial reviewer prompt (try-to-break),
1836
+ // every other slice gets the builder prompt.
1837
+ const isReview = t.slice_type === 'review'
1838
+ const { dir } = createFlowWorktree({ flowId: payload.flowId, taskKey: t.task_key })
1839
+ const laneId = randomUUID()
1840
+ termNames[laneId] = `Flow · ${t.task_key}`
1841
+ saveNames(room, termNames)
1842
+ openStructured({ id: laneId, cwd: dir, mode: 'acceptEdits', rolePrompt: isReview ? FLOW_REVIEWER_PROMPT : FLOW_LANE_PROMPT, flowSessionId: payload.flowId, flowTaskKey: t.task_key, spawnedBy: `flow:${payload.flowId}` })
1843
+ const le = sessions.get(laneId)
1844
+ if (le?.session) {
1845
+ const spec =
1846
+ `[Flow lane — slice "${t.task_key}" of flow ${String(payload.flowId).slice(0, 8)}]\n` +
1847
+ `TITLE: ${t.title}\n` +
1848
+ `SCOPE (files you OWN — edit ONLY these): ${t.scope || '(none stated)'}\n` +
1849
+ `ACCEPTANCE (done = this runs + proves it): ${t.acceptance || '(meet the title)'}\n` +
1850
+ (t.deps && t.deps.length ? `DEPENDS ON (already built): ${t.deps.join(', ')}\n` : '') +
1851
+ `\nProject (context): ${payload.flowPrompt || ''}\n\n` +
1852
+ `Build your slice. Own only your files. Done = it runs + meets acceptance. Commit when done.`
1853
+ try { le.session.sendTurn(spec) } catch { /* session still starting */ }
1854
+ }
1855
+ assignments.push({ task_key: t.task_key, laneId })
1856
+ liveLanes++
1857
+ } catch (e) {
1858
+ process.stderr.write(`\n ${A.yel}◆ flow dispatch failed for ${t.task_key}: ${e?.message || e}${A.rst}\n`)
1859
+ }
1860
+ }
1861
+ if (assignments.length) {
1862
+ bcast('flow-dispatched', { term: 'flow', flowId: payload.flowId, assignments }, flowChannel)
1863
+ process.stderr.write(`\n ${A.mag}◆ flow dispatched ${assignments.length} lane(s) — flow ${String(payload.flowId).slice(0, 8)}.${A.rst}\n`)
1864
+ announce()
1865
+ }
1866
+ })
1867
+ .on('broadcast', { event: 'flow-assemble' }, async ({ payload }) => {
1868
+ // Step 7 — all slices done + reviewed: merge the lane worktrees into one app, inline
1869
+ // to a single self-contained index.html (the no-build artifact), commit a per-Flow
1870
+ // repo, serve a live preview, and broadcast the result. Zip / GitHub-push export is
1871
+ // offered room-side off this committed repo.
1872
+ if (!payload?.flowId || !Array.isArray(payload.tasks)) return
1873
+ if (payload.host && payload.host !== name) return
1874
+ try {
1875
+ const short = String(payload.flowId).replace(/-/g, '').slice(0, 8)
1876
+ const dirs = payload.tasks
1877
+ .map(t => worktreeSpec({ flowId: payload.flowId, taskKey: t.task_key }).dir)
1878
+ .filter(d => fs.existsSync(d))
1879
+ if (!dirs.length) { process.stderr.write(`\n ${A.yel}◆ flow assemble: no lane worktrees for ${short}.${A.rst}\n`); return }
1880
+ const outDir = path.join(process.cwd(), '.claude', 'worktrees', `flow-${short}-assembled`)
1881
+ fs.rmSync(outDir, { recursive: true, force: true })
1882
+ const merged = mergeWorktrees({ dirs, outDir })
1883
+ const htmlPath = path.join(outDir, 'index.html')
1884
+ if (fs.existsSync(htmlPath)) {
1885
+ try { fs.writeFileSync(htmlPath, inlineSingleHtml({ htmlPath, root: outDir })) } catch (e) { process.stderr.write(`\n ${A.yel}◆ inline failed: ${e?.message || e}${A.rst}\n`) }
1886
+ }
1887
+ try { initRepo({ dir: outDir }) } catch { /* best-effort */ }
1888
+ let previewUrl = null
1889
+ try { const pv = await startPreview({ dir: outDir, id: `assembled:${payload.flowId}` }); previewUrl = pv.url } catch { /* best-effort */ }
1890
+ bcast('flow-assembled', { term: 'flow', flowId: payload.flowId, previewUrl, outDir, files: merged.files, conflicts: merged.conflicts }, flowChannel)
1891
+ process.stderr.write(`\n ${A.mag}◆ flow assembled — ${merged.files.length} file(s)${merged.conflicts.length ? `, ${merged.conflicts.length} conflict(s)` : ''}${previewUrl ? ` · preview ${previewUrl}` : ''} (flow ${short}).${A.rst}\n`)
1892
+ announce()
1893
+ } catch (e) {
1894
+ process.stderr.write(`\n ${A.yel}◆ flow assemble failed: ${e?.message || e}${A.rst}\n`)
1895
+ }
1896
+ })
1897
+ .on('broadcast', { event: 'flow-revert' }, ({ payload }) => {
1898
+ // Step 4 — atomic per-lane revert: roll back ONE slice (remove its worktree +
1899
+ // branch) without touching the others, when adversarial review rejects it. The
1900
+ // room re-dispatches the reverted task on the next wave.
1901
+ if (!payload?.flowId || !payload?.taskKey) return
1902
+ if (payload.host && payload.host !== name) return
1903
+ try {
1904
+ const { branch } = revertLane({ flowId: payload.flowId, taskKey: payload.taskKey })
1905
+ bcast('flow-reverted', { term: 'flow', flowId: payload.flowId, taskKey: payload.taskKey, branch }, flowChannel)
1906
+ process.stderr.write(`\n ${A.yel}◆ flow slice reverted — ${payload.taskKey} (${branch}).${A.rst}\n`)
1907
+ } catch (e) { process.stderr.write(`\n ${A.yel}◆ flow revert failed: ${e?.message || e}${A.rst}\n`) }
1908
+ })
1909
+ .subscribe()
1910
+
1833
1911
  // Watchdog — if the realtime channel is stuck (never connected, or dropped and
1834
1912
  // not recovered) for >60s, exit non-zero so a supervisor/launchd restarts a
1835
1913
  // clean process. Healthy long-lived connections never trip this (brokenSince=0).
@@ -1962,6 +2040,7 @@ async function shutdown(code = 0, farewell = true) {
1962
2040
  // process alive with orphaned PTY children + stale "live" presence.
1963
2041
  setTimeout(() => process.exit(code), 1500)
1964
2042
  clearInterval(flushTimer)
2043
+ try { stopAllPreviews() } catch { /* Flow preview servers — best-effort close */ }
1965
2044
  if (farewell) {
1966
2045
  try {
1967
2046
  const bye = Buffer.from('\r\n[ shared session ended ]\r\n', 'utf8').toString('base64')
@@ -0,0 +1,183 @@
1
+ // Thinkpool Flow — Step 7: assembly + delivery.
2
+ //
3
+ // Lanes each built a DISJOINT slice in its own git worktree (flow-worktree.mjs).
4
+ // Assembly collects those slices into one final app, then delivers it. The MVP
5
+ // artifact ("BLEAK SWORD") is a SINGLE self-contained index.html — no build step,
6
+ // no external assets — but lanes own separate module files (audio.js, sprites.js,
7
+ // combat.js, render.js + an index.html shell that imports them). So assembly must
8
+ // INLINE those modules back into one index.html.
9
+ //
10
+ // Export-destination decision (locked with the spec): a finished Flow ships as a
11
+ // per-Flow git repo (initRepo) + a zip Buffer (zipDir) + an OPTIONAL best-effort
12
+ // GitHub push (pushToGithub). The git repo is the source of truth; zip is the
13
+ // download; GitHub push is opt-in and never blocks delivery.
14
+ //
15
+ // Node built-ins ONLY (fs, path, child_process, zlib) — bridge ships as npm
16
+ // thinkpool-pair with no new deps. Pure over injected fs/git/gh/readFile so the
17
+ // whole step is unit-testable against temp dirs.
18
+ // Spec: docs/specs/2026-06-29-thinkpool-flow.md (Step 7).
19
+
20
+ import { readFileSync as fsReadFile } from 'node:fs'
21
+ import nodeFs from 'node:fs'
22
+ import path from 'node:path'
23
+ import { execFileSync } from 'node:child_process'
24
+
25
+ // ── inlineSingleHtml ─────────────────────────────────────────────────────────
26
+ // Read an index.html and fold its LOCAL relative deps into the document:
27
+ // <script type="module" src="./x.js"> / <script src="./x.js"> → <script>…</script>
28
+ // <link rel="stylesheet" href="./x.css"> → <style>…</style>
29
+ // Only local relative refs are inlined; http(s):// and protocol-relative (//) are
30
+ // left alone. readFile is injected for testability.
31
+ //
32
+ // LIMITATION (documented, on purpose): this is NOT a real ES-module bundler — no
33
+ // dependency resolution, no tree-shaking, no scope isolation. If any inlined script
34
+ // uses `import `/`export `, we concatenate ALL module scripts, in document order,
35
+ // into ONE <script type="module"> block. That only works because Flow's single-file
36
+ // artifacts are written to be concatenation-safe (no cross-file import statements
37
+ // between the lanes' own modules — each lane writes a self-contained module; shared
38
+ // symbols are globals on window, not imported). Bare specifiers / npm imports are
39
+ // out of scope for the single-file deliverable.
40
+ export function inlineSingleHtml ({ htmlPath, root, readFile = fsReadFile }) {
41
+ const baseDir = root || path.dirname(htmlPath)
42
+ let html = String(readFile(htmlPath, 'utf8'))
43
+
44
+ const isLocal = (ref) => ref && !/^[a-z]+:\/\//i.test(ref) && !ref.startsWith('//')
45
+ const resolve = (ref) => path.join(baseDir, ref.replace(/^\.\//, ''))
46
+
47
+ // Collected module-script bodies (import/export users) get merged into one block.
48
+ const moduleBodies = []
49
+
50
+ // <link rel="stylesheet" href="…"> → <style>…</style>
51
+ html = html.replace(
52
+ /<link\b[^>]*\brel=["']stylesheet["'][^>]*>/gi,
53
+ (tag) => {
54
+ const m = tag.match(/\bhref=["']([^"']+)["']/i)
55
+ if (!m || !isLocal(m[1])) return tag
56
+ const css = String(readFile(resolve(m[1]), 'utf8'))
57
+ return `<style>\n${css}\n</style>`
58
+ }
59
+ )
60
+
61
+ // <script … src="…"></script> → inline. Module scripts that import/export are
62
+ // deferred into moduleBodies; everything else inlines in place.
63
+ html = html.replace(
64
+ /<script\b([^>]*)\bsrc=["']([^"']+)["']([^>]*)>\s*<\/script>/gi,
65
+ (tag, pre, src, post) => {
66
+ if (!isLocal(src)) return tag
67
+ const attrs = `${pre} ${post}`
68
+ const isModule = /\btype=["']module["']/i.test(attrs)
69
+ const body = String(readFile(resolve(src), 'utf8'))
70
+ const usesEsm = /(^|[\s;{(])(import|export)\s/.test(body)
71
+ if (isModule && usesEsm) {
72
+ moduleBodies.push(`/* inlined: ${src} */\n${body}`)
73
+ return '' // collected; emitted as one merged module block below
74
+ }
75
+ // Plain script (or module with no import/export): inline directly. Drop
76
+ // type=module when it carries no ESM syntax — a bare inline module is fine
77
+ // either way, but stripping keeps the single file maximally portable.
78
+ return `<script>\n${body}\n</script>`
79
+ }
80
+ )
81
+
82
+ if (moduleBodies.length) {
83
+ const merged = `<script type="module">\n${moduleBodies.join('\n')}\n</script>`
84
+ // Re-attach the merged block where the </body> is (or append if no body tag).
85
+ if (/<\/body>/i.test(html)) html = html.replace(/<\/body>/i, `${merged}\n</body>`)
86
+ else html += `\n${merged}\n`
87
+ }
88
+
89
+ return html
90
+ }
91
+
92
+ // ── mergeWorktrees ───────────────────────────────────────────────────────────
93
+ // Union the files from each lane worktree dir into outDir. Later dirs win on a
94
+ // path conflict (and every conflict is logged). Skips .git, node_modules, .claude.
95
+ // Returns { files, conflicts } — both arrays of outDir-relative paths.
96
+ export function mergeWorktrees ({ dirs, outDir, fs = nodeFs }) {
97
+ const SKIP = new Set(['.git', 'node_modules', '.claude'])
98
+ const seen = new Set()
99
+ const files = []
100
+ const conflicts = []
101
+
102
+ fs.mkdirSync(outDir, { recursive: true })
103
+
104
+ const walk = (srcRoot, rel = '') => {
105
+ const abs = path.join(srcRoot, rel)
106
+ for (const ent of fs.readdirSync(abs, { withFileTypes: true })) {
107
+ if (SKIP.has(ent.name)) continue
108
+ const childRel = rel ? path.join(rel, ent.name) : ent.name
109
+ if (ent.isDirectory()) {
110
+ walk(srcRoot, childRel)
111
+ } else if (ent.isFile()) {
112
+ const dest = path.join(outDir, childRel)
113
+ if (seen.has(childRel)) conflicts.push(childRel)
114
+ else { seen.add(childRel); files.push(childRel) }
115
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
116
+ fs.copyFileSync(path.join(srcRoot, childRel), dest) // later dir wins
117
+ }
118
+ }
119
+ }
120
+
121
+ for (const d of dirs) walk(d)
122
+ return { files, conflicts }
123
+ }
124
+
125
+ // ── zipDir ───────────────────────────────────────────────────────────────────
126
+ // Produce a zip Buffer of dir. PATH USED: shells out to the system `zip` via
127
+ // child_process (`zip -q -r - .` from inside dir → captured to a Buffer) — robust,
128
+ // produces a standards-compliant archive, no hand-rolled ZIP central directory.
129
+ // We do NOT hand-build a zlib-deflate container: a correct ZIP needs the local
130
+ // headers + central directory + CRC32 + end-of-central-directory record, which is
131
+ // a lot of fiddly bytes to get right for no gain when `zip` is on every dev box.
132
+ // Falls back gracefully — if `zip` is missing, throws a clear, catchable error so
133
+ // callers (and the test) can skip rather than crash the bridge.
134
+ export function zipDir ({ dir }) {
135
+ try {
136
+ return execFileSync('zip', ['-q', '-r', '-', '.'], {
137
+ cwd: dir,
138
+ maxBuffer: 256 * 1024 * 1024
139
+ })
140
+ } catch (err) {
141
+ if (err && err.code === 'ENOENT') {
142
+ const e = new Error('zipDir: system `zip` not available')
143
+ e.code = 'NO_ZIP'
144
+ throw e
145
+ }
146
+ throw err
147
+ }
148
+ }
149
+
150
+ // ── initRepo ─────────────────────────────────────────────────────────────────
151
+ // git init + add -A + commit in dir. Injectable git for tests. Returns
152
+ // { committed } — false when there was nothing to commit (empty tree).
153
+ export function initRepo ({ dir, git = runGit }) {
154
+ git(['init', '-q', '-b', 'main'], dir)
155
+ git(['add', '-A'], dir)
156
+ try {
157
+ git(['commit', '-q', '-m', 'Assembled by Thinkpool Flow'], dir)
158
+ return { committed: true }
159
+ } catch {
160
+ return { committed: false } // nothing staged / nothing to commit
161
+ }
162
+ }
163
+
164
+ // ── pushToGithub ─────────────────────────────────────────────────────────────
165
+ // Best-effort, OPTIONAL. Create a private repo from dir and push via the gh CLI.
166
+ // Never throws — if gh is absent / unauthed / errors, returns { pushed:false,
167
+ // reason } so delivery (repo + zip) still succeeds. Injectable gh for tests.
168
+ export async function pushToGithub ({ dir, repoName, gh = ghCli }) {
169
+ try {
170
+ gh(['repo', 'create', repoName, '--private', '--source=.', '--push'], dir)
171
+ return { pushed: true }
172
+ } catch (err) {
173
+ return { pushed: false, reason: err && err.message ? err.message : String(err) }
174
+ }
175
+ }
176
+
177
+ // ── injected defaults ─────────────────────────────────────────────────────────
178
+ function runGit (args, cwd) {
179
+ return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' })
180
+ }
181
+ function ghCli (args, cwd) {
182
+ return execFileSync('gh', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' })
183
+ }
@@ -0,0 +1,84 @@
1
+ // Thinkpool Flow — autonomy modes' budget cap, kill-switch, and adaptive escalation.
2
+ //
3
+ // Steps 5+6 of the Flow build (docs/specs/2026-06-29-thinkpool-flow.md). Step 5 is
4
+ // the autonomy ladder (Steer / Guide / Autopilot) + adaptive escalation — Autopilot
5
+ // pulls the human back in when a task keeps failing. Step 6 is the HARD budget cap +
6
+ // kill-switch — the run stops BEFORE it overruns, never after. Pure module (no IO,
7
+ // no deps), shared by the bridge (conductor) and the client (room UI), exactly like
8
+ // taskGraph.js — except killSwitchEnv(), the single deliberately-impure fn that reads
9
+ // process.env for the operator override.
10
+ //
11
+ // Enforces spec invariants:
12
+ // (b) maxHop:1 — lanes never recursively spawn lanes.
13
+ // (c) Autopilot budget cap is HARD — the kill-switch fires before overrun.
14
+
15
+ // Concurrency / recursion / escalation limits — mirrored by the dispatcher and the
16
+ // bridge. One source of truth for the numbers the spec locks.
17
+ export const FLOW_LIMITS = {
18
+ maxConcurrentLanes: 8, // invariant: ≤8 live Flow lanes at once
19
+ maxHop: 1, // invariant (b): no recursive spawning (a lane can't spawn lanes)
20
+ escalateAfterFails: 2, // adaptive escalation: pull the human in once failCount hits this
21
+ }
22
+
23
+ // A budget ledger. capTokens null = uncapped (steer/guide); a number = Autopilot's
24
+ // HARD cap. `killed` latches true once the cap is hit and never flips back.
25
+ export function makeBudget ({ capTokens = null } = {}) {
26
+ return { capTokens, spentTokens: 0, killed: false }
27
+ }
28
+
29
+ // Immutable spend update — returns a NEW budget (never mutates). If the cap is set
30
+ // and spend reaches it, `killed` latches true: invariant (c), stop before overrun.
31
+ export function recordSpend (budget, tokens) {
32
+ const spentTokens = budget.spentTokens + tokens
33
+ const killed = budget.capTokens != null
34
+ ? (budget.killed || spentTokens >= budget.capTokens)
35
+ : budget.killed
36
+ return { ...budget, spentTokens, killed }
37
+ }
38
+
39
+ // Has the cap been reached? Uncapped budgets never exceed.
40
+ export function budgetExceeded (budget) {
41
+ return budget.capTokens != null && budget.spentTokens >= budget.capTokens
42
+ }
43
+
44
+ // Tokens left before the cap. Uncapped → Infinity. Never negative.
45
+ export function remainingBudget (budget) {
46
+ if (budget.capTokens == null) return Infinity
47
+ return Math.max(0, budget.capTokens - budget.spentTokens)
48
+ }
49
+
50
+ // Gate a dispatch. Lane cap applies in EVERY mode (invariant: ≤8). The budget cap
51
+ // only bites in Autopilot — steer/guide keep a human in the loop, so they ignore the
52
+ // cap (no auto-spend to runaway-protect against). { ok, reason } — reason is '' when ok.
53
+ export function canDispatch ({ mode, liveLanes, budget }) {
54
+ if (liveLanes >= FLOW_LIMITS.maxConcurrentLanes) {
55
+ return { ok: false, reason: 'lane cap' }
56
+ }
57
+ if (mode === FLOW_MODE_AUTOPILOT && (budget.killed || budgetExceeded(budget))) {
58
+ return { ok: false, reason: 'budget cap' }
59
+ }
60
+ return { ok: true, reason: '' }
61
+ }
62
+
63
+ // Should this fail surface to the human? In Autopilot the human is OUT of the loop,
64
+ // so we only escalate (auto-pull-human) once a task has failed escalateAfterFails
65
+ // times — adaptive escalation, not on the first hiccup. In guide/steer the human is
66
+ // ALREADY watching, so any fail surfaces immediately — escalation is always true.
67
+ export function shouldEscalate ({ mode, failCount }) {
68
+ if (mode === FLOW_MODE_AUTOPILOT) return failCount >= FLOW_LIMITS.escalateAfterFails
69
+ return true
70
+ }
71
+
72
+ // THE ONLY IMPURE FN — reads process.env for the operator kill-switch / cap override.
73
+ // TP_FLOW_OFF=1 disables Flow dispatch entirely; TP_FLOW_BUDGET_CAP sets the Autopilot
74
+ // token cap from the environment. Kept isolated + flagged so the rest stays pure.
75
+ export function killSwitchEnv () {
76
+ return {
77
+ disabled: process.env.TP_FLOW_OFF === '1',
78
+ capFromEnv: process.env.TP_FLOW_BUDGET_CAP ? Number(process.env.TP_FLOW_BUDGET_CAP) : null,
79
+ }
80
+ }
81
+
82
+ // Local mirror of the mode literal (taskGraph.js owns FLOW_MODE; this keeps the
83
+ // budget module dependency-free — it only ever needs the one value).
84
+ const FLOW_MODE_AUTOPILOT = 'autopilot'
@@ -0,0 +1,144 @@
1
+ // Thinkpool Flow — per-lane preview runtime.
2
+ //
3
+ // Each Flow lane builds its slice in its own git worktree (flow-worktree.mjs).
4
+ // The room UI iframes a LIVE preview of that slice, so the worktree dir needs to be
5
+ // reachable over localhost. The MVP artifact is a self-contained index.html (no build
6
+ // step), so a static file server is the universal case — Node http + fs + path, no deps
7
+ // (this is published as the `thinkpool-pair` npm package; new deps are not on the table).
8
+ //
9
+ // Spec: docs/specs/2026-06-29-thinkpool-flow.md (Step 3).
10
+
11
+ import http from 'node:http'
12
+ import fs from 'node:fs'
13
+ import fsp from 'node:fs/promises'
14
+ import path from 'node:path'
15
+
16
+ // Module-level registry of live previews so cleanup (flow cancel / shutdown) can sweep
17
+ // every server without each caller tracking handles. id → { server, url, stop }.
18
+ export const previews = new Map()
19
+
20
+ // Minimal extension → MIME map. Covers the self-contained-app surface + common assets.
21
+ // Unknown extensions fall back to application/octet-stream (the browser downloads it).
22
+ const MIME = {
23
+ '.html': 'text/html; charset=utf-8',
24
+ '.htm': 'text/html; charset=utf-8',
25
+ '.js': 'text/javascript; charset=utf-8',
26
+ '.mjs': 'text/javascript; charset=utf-8',
27
+ '.css': 'text/css; charset=utf-8',
28
+ '.json': 'application/json; charset=utf-8',
29
+ '.png': 'image/png',
30
+ '.jpg': 'image/jpeg',
31
+ '.jpeg': 'image/jpeg',
32
+ '.svg': 'image/svg+xml',
33
+ '.wav': 'audio/wav',
34
+ '.mp3': 'audio/mpeg',
35
+ '.ico': 'image/x-icon'
36
+ }
37
+
38
+ function contentType (filePath) {
39
+ return MIME[path.extname(filePath).toLowerCase()] || 'application/octet-stream'
40
+ }
41
+
42
+ // Map a request URL path to a real file under `root`, or null if it would escape `root`
43
+ // (path traversal). Decode + strip query/hash, normalize, then verify the resolved path
44
+ // is still inside root — the relative path between them must not climb out with `..`.
45
+ function resolveUnderRoot (root, urlPath) {
46
+ let pathname
47
+ try {
48
+ pathname = decodeURIComponent(new URL(urlPath, 'http://x').pathname)
49
+ } catch {
50
+ return null // malformed percent-encoding
51
+ }
52
+ const resolved = path.resolve(root, '.' + pathname) // leading '.' keeps it relative to root
53
+ const rel = path.relative(root, resolved)
54
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return null
55
+ return resolved
56
+ }
57
+
58
+ // Is this path a regular file we can read? (false for dirs / missing / errors)
59
+ async function isFile (p) {
60
+ try {
61
+ return (await fsp.stat(p)).isFile()
62
+ } catch {
63
+ return false
64
+ }
65
+ }
66
+
67
+ async function send (res, status, type, body) {
68
+ res.writeHead(status, {
69
+ 'Content-Type': type,
70
+ 'Access-Control-Allow-Origin': '*' // local preview only; deliberately NO COOP/COEP
71
+ })
72
+ res.end(body)
73
+ }
74
+
75
+ // Build the request handler bound to a worktree dir. Routing:
76
+ // - traversal outside dir → 403
77
+ // - existing file → that file
78
+ // - '/' or a directory → index.html
79
+ // - unknown path → index.html if present (SPA fallback), else 404
80
+ function makeHandler (dir) {
81
+ const indexPath = path.join(dir, 'index.html')
82
+ return async (req, res) => {
83
+ const resolved = resolveUnderRoot(dir, req.url || '/')
84
+ if (resolved === null) return send(res, 403, 'text/plain; charset=utf-8', 'Forbidden')
85
+
86
+ // A concrete file hit (and still a real file, not a dir) → serve it.
87
+ if (await isFile(resolved)) {
88
+ try {
89
+ return send(res, 200, contentType(resolved), await fsp.readFile(resolved))
90
+ } catch {
91
+ return send(res, 500, 'text/plain; charset=utf-8', 'Read error')
92
+ }
93
+ }
94
+
95
+ // Directory, root, or unknown path → fall back to index.html (SPA-friendly).
96
+ if (await isFile(indexPath)) {
97
+ try {
98
+ return send(res, 200, contentType(indexPath), await fsp.readFile(indexPath))
99
+ } catch {
100
+ return send(res, 500, 'text/plain; charset=utf-8', 'Read error')
101
+ }
102
+ }
103
+
104
+ return send(res, 404, 'text/plain; charset=utf-8', 'Not found')
105
+ }
106
+ }
107
+
108
+ // Start a static preview server rooted at `dir`. port 0 → OS assigns a free port; the
109
+ // actual bound port is read back from server.address(). Resolves once listening.
110
+ // Returns { url, port, stop } — stop() is idempotent and async-safe.
111
+ export function startPreview ({ dir, port = 0, host = '127.0.0.1', id } = {}) {
112
+ if (!dir) throw new Error('startPreview: dir is required')
113
+
114
+ const server = http.createServer(makeHandler(dir))
115
+
116
+ // Idempotent stop: close once, no-op on subsequent calls; resolve when fully closed.
117
+ let stopped = false
118
+ const stop = () =>
119
+ new Promise((resolve) => {
120
+ if (id !== undefined) previews.delete(id)
121
+ if (stopped) return resolve()
122
+ stopped = true
123
+ server.close(() => resolve())
124
+ })
125
+
126
+ return new Promise((resolve, reject) => {
127
+ server.once('error', reject)
128
+ server.listen(port, host, () => {
129
+ server.removeListener('error', reject)
130
+ const bound = server.address().port
131
+ const url = `http://${host}:${bound}/`
132
+ const handle = { server, url, stop, dir }
133
+ if (id !== undefined) previews.set(id, handle)
134
+ resolve({ url, port: bound, stop })
135
+ })
136
+ })
137
+ }
138
+
139
+ // Stop + deregister every live preview. Best-effort; awaits all closes.
140
+ export async function stopAllPreviews () {
141
+ const handles = [...previews.values()]
142
+ previews.clear()
143
+ await Promise.all(handles.map((h) => h.stop()))
144
+ }
@@ -0,0 +1,92 @@
1
+ // Thinkpool Flow — Step 4: adversarial review + per-lane atomic revert.
2
+ //
3
+ // After a lane commits its slice (commit_sha on flow_tasks = the revert target), an
4
+ // ADVERSARIAL reviewer lane tries to BREAK it: run it, find where acceptance isn't
5
+ // actually met, find regressions/edge cases. Its verdict drives the gate — accept iff
6
+ // every reviewer passed; any fail → revert THIS lane only. The atomic-revert invariant:
7
+ // one lane rolls back (its worktree + branch gone) without touching any other lane's
8
+ // work (disjoint worktrees, M57). Spec: docs/specs/2026-06-29-thinkpool-flow.md (Step 4).
9
+ //
10
+ // Pure over injected git/removeWorktree so it's unit-testable against a temp repo.
11
+
12
+ import { execFileSync } from 'node:child_process'
13
+ import { TASK_STATUS } from './flow-task-graph.mjs'
14
+ import { removeFlowWorktree } from './flow-worktree.mjs'
15
+
16
+ // The reviewer lane's rolePrompt (via startClaudeSession). Mirrors FLOW_LANE_PROMPT's
17
+ // join(' ') style. This lane is ADVERSARIAL — its job is to disprove "done", not to build.
18
+ export const FLOW_REVIEWER_PROMPT = [
19
+ 'THINKPOOL FLOW — you are an ADVERSARIAL REVIEWER in an ensemble build. You did NOT build this slice. Another lane did, and claims it is done. Your job is to TRY TO BREAK IT — independently verify whether its acceptance criteria are ACTUALLY met. The slice you are reviewing (its scope, its acceptance criteria, and its worktree/branch) arrives as your first message.',
20
+
21
+ '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.',
22
+
23
+ 'HUNT FOR FAILURE. Look where the builder didn\'t: edge cases, empty/malformed input, the second click, the reload, concurrent use, the error path. Check the slice didn\'t regress anything outside its acceptance line. A green happy-path is the floor, not the bar.',
24
+
25
+ '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.',
26
+
27
+ 'BE SPECIFIC. Your reasons must name exactly WHAT failed and HOW you found it — the command you ran, the output you got, the acceptance criterion it violated. "Doesn\'t work" is useless. "GET /api/todos returned 500 with `column todos.user_id does not exist`; acceptance required 200 + []" is a usable verdict.',
28
+
29
+ 'EMIT A VERDICT, NOT A mark_flow_done. You do NOT call mark_flow_done — that is the builder\'s path. You emit a REVIEW VERDICT: a single JSON object `{ "pass": <boolean>, "reasons": ["<specific finding>", ...], "taskKey": "<the slice you reviewed>" }`. `pass: false` means revert this lane. Put ONLY that JSON object in your verdict (no surrounding prose, no markdown fences). Do not edit the slice — you review, you do not fix.',
30
+ ].join(' ')
31
+
32
+ // Parse a reviewer's raw verdict. Accepts an object OR a JSON string (optionally
33
+ // ```json-fenced). Tolerant of the fence the model sometimes wraps; throws on garbage.
34
+ // Returns { pass, reasons } — pass coerced to boolean, reasons normalized to string[].
35
+ export function parseReviewVerdict (raw) {
36
+ let obj = raw
37
+ if (typeof raw === 'string') {
38
+ let s = raw.trim()
39
+ const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i)
40
+ if (fence) s = fence[1].trim()
41
+ obj = JSON.parse(s) // throws on unparseable
42
+ }
43
+ if (!obj || typeof obj !== 'object') throw new Error('review verdict is not an object')
44
+ if (typeof obj.pass !== 'boolean') throw new Error('review verdict missing boolean `pass`')
45
+ const reasons = Array.isArray(obj.reasons) ? obj.reasons.map(String) : []
46
+ return { pass: obj.pass, reasons }
47
+ }
48
+
49
+ // Gate decision over a task's verdicts (≥1 reviewer). Accept iff EVERY verdict passes;
50
+ // any fail → mustRevert. reasons collects the failing verdicts' reasons (the why).
51
+ export function reviewDecision ({ verdicts }) {
52
+ const list = Array.isArray(verdicts) ? verdicts : []
53
+ if (list.length === 0) {
54
+ // No reviewer ran — cannot independently verify, so default to reject (the prompt's stance).
55
+ return { accept: false, mustRevert: true, reasons: ['no review verdict produced'] }
56
+ }
57
+ const accept = list.every(v => v.pass === true)
58
+ const reasons = list.filter(v => !v.pass).flatMap(v => Array.isArray(v.reasons) ? v.reasons : [])
59
+ return { accept, mustRevert: !accept, reasons }
60
+ }
61
+
62
+ // Atomic per-lane revert: tear down ONE lane's worktree + branch, nothing else.
63
+ // Delegates to the injected removeWorktree (defaults to removeFlowWorktree). Best-effort
64
+ // — never throws on an already-gone worktree/branch. Returns { reverted, branch }.
65
+ export function revertLane ({ flowId, taskKey, root, git = defaultGit, removeWorktree = removeFlowWorktree }) {
66
+ const { branch } = worktreeBranch({ flowId, taskKey, root })
67
+ try {
68
+ removeWorktree({ flowId, taskKey, root, git })
69
+ } catch { /* best-effort: worktree/branch already gone */ }
70
+ return { reverted: true, branch }
71
+ }
72
+
73
+ // Status a task moves to after the review gate resolves.
74
+ export function nextTaskStatusAfterReview ({ accept }) {
75
+ return accept ? TASK_STATUS.done : TASK_STATUS.reverted
76
+ }
77
+
78
+ // ── internals ───────────────────────────────────────────────────────────────
79
+ // Resolve the lane's branch name without importing worktreeSpec's full surface here;
80
+ // keeps revertLane's return honest even when the injected removeWorktree is a spy.
81
+ function worktreeBranch ({ flowId, taskKey, root }) {
82
+ const short = String(flowId).replace(/-/g, '').slice(0, 8)
83
+ const safeKey = String(taskKey).toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 40).replace(/^-+|-+$/g, '') || 'task'
84
+ void root
85
+ return { branch: `flow/${short}/${safeKey}` }
86
+ }
87
+
88
+ // Default git runner mirrors flow-worktree's — only used if a caller passes neither
89
+ // git nor a custom removeWorktree (the real removeFlowWorktree supplies its own default).
90
+ function defaultGit (args, cwd) {
91
+ return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' })
92
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.97",
3
+ "version": "0.7.99",
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": {
@@ -19,6 +19,10 @@
19
19
  "flow-conductor.mjs",
20
20
  "flow-worktree.mjs",
21
21
  "flow-task-graph.mjs",
22
+ "flow-preview.mjs",
23
+ "flow-review.mjs",
24
+ "flow-assembly.mjs",
25
+ "flow-budget.mjs",
22
26
  "serve-dir.mjs",
23
27
  "service.mjs",
24
28
  "presence.mjs",