thinkpool-pair 0.7.96 → 0.7.98

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
@@ -32,13 +32,15 @@ import os from 'node:os'
32
32
  import fs from 'node:fs'
33
33
  import path from 'node:path'
34
34
  import readline from 'node:readline'
35
- import { spawn, execSync } from 'node:child_process'
35
+ import { spawn, execSync, execFileSync } from 'node:child_process'
36
36
  import { fileURLToPath } from 'node:url'
37
37
  import { randomUUID } from 'node:crypto'
38
38
  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'
43
+ import { createFlowWorktree } from './flow-worktree.mjs'
42
44
  import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, CROSSROOM, formatPairRoster, crossRoomPostDecision } from './cross-terminal.mjs'
43
45
  import { turnInFlight } from './update-gate.mjs'
44
46
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq } from './session-store.mjs'
@@ -454,6 +456,15 @@ const channel = supabase.channel(`tpcode:${room}`, {
454
456
  config: { broadcast: { self: false }, presence: { key: `bridge:${name}` } },
455
457
  })
456
458
 
459
+ // Thinkpool Flow rides its OWN topic, separate from the room's primary tpcode channel.
460
+ // The web's useFlow hook is a child of CodeRoom, so its effect runs before the room's —
461
+ // sharing tpcode meant useFlow created/owned/removed the room's channel and killed
462
+ // realtime on room open (the 2026-06-30 snag). A dedicated topic keeps the two lifecycles
463
+ // from colliding. The bridge is a separate client, so a second channel here is free.
464
+ const flowChannel = supabase.channel(`tpflow:${room}`, {
465
+ config: { broadcast: { self: false } },
466
+ })
467
+
457
468
  // ── terminal registry ──────────────────────────────────────────────
458
469
  // id → { term (pty), cmd, attached, scrollback, buf }
459
470
  const terms = new Map()
@@ -523,14 +534,14 @@ const defendFrame = (event, payload) => {
523
534
  return p
524
535
  }
525
536
 
526
- const bcast = (event, payload) => {
537
+ const bcast = (event, payload, ch = channel) => {
527
538
  if (event === 'pty-out' || event === 'code-event') lastActivity = Date.now()
528
539
  payload = defendFrame(event, payload)
529
540
  try {
530
- if (channel.channelAdapter?.canPush?.() ?? true) {
531
- channel.send({ type: 'broadcast', event, payload })
541
+ if (ch.channelAdapter?.canPush?.() ?? true) {
542
+ ch.send({ type: 'broadcast', event, payload })
532
543
  } else {
533
- channel.httpSend(event, payload).catch(() => { /* offline — replay covers it */ })
544
+ ch.httpSend(event, payload).catch(() => { /* offline — replay covers it */ })
534
545
  }
535
546
  } catch { /* noop */ }
536
547
  }
@@ -1015,12 +1026,12 @@ function restoredTurnOpen(log) {
1015
1026
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
1016
1027
  // persist to the host file; tool calls round-trip through the perm card; the
1017
1028
  // rolling log replays to joiners and survives bridge restarts (session-store).
1018
- function openStructured({ id, model, resume, log, commands, mode, spawnedBy }) {
1029
+ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd }) {
1019
1030
  if (sessions.has(id)) return
1020
1031
  // spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
1021
1032
  // session store so the Ensemble flag survives a bridge restart (else a respin
1022
1033
  // stripped it and the lane reverted to a plain tab — the t6 "no chip" bug).
1023
- const entry = { cmd: 'claude', kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode, spawnedBy: spawnedBy || undefined }
1034
+ const entry = { cmd: 'claude', kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode, spawnedBy: spawnedBy || undefined, flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null }
1024
1035
  // C1 (RT-2): per-term contiguous seq counter. Seeded from the restored log's max
1025
1036
  // so a bridge restart resumes ABOVE every persisted seq — fresh events never reuse
1026
1037
  // an old seq, which is what keeps the between-turns restart dup-free (see
@@ -1280,10 +1291,28 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy }) {
1280
1291
  return okText(`Closed agent lane ${target.ref}${target.name ? ` ("${target.name}")` : ''}.`)
1281
1292
  },
1282
1293
  ),
1294
+ // Flow lane → mark this slice done (lane-done return path). Reads the lane's
1295
+ // worktree HEAD as commit_sha (the atomic-revert target) + broadcasts
1296
+ // flow-task-done → the room flips the task done + dispatches the next wave
1297
+ // (slices whose deps just got satisfied).
1298
+ tool(
1299
+ 'mark_flow_done',
1300
+ "ThinkPool Flow ONLY — call this ONCE when your slice is built, RUNS, and meets its acceptance criteria. Records your latest commit as the slice's atomic-revert target and signals the room that this slice is done (which unblocks slices that depended on you). No arguments: your commit is read from your worktree. Do not call before your slice actually runs + meets acceptance.",
1301
+ {},
1302
+ async () => {
1303
+ const okText = (t) => ({ content: [{ type: 'text', text: t }] })
1304
+ if (!entry.flowSessionId || !entry.flowTaskKey) return okText('Not a Flow lane — nothing to mark done.')
1305
+ let commitSha = null
1306
+ try { commitSha = execFileSync('git', ['-C', entry.cwd || process.cwd(), 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() } catch { /* no commits yet */ }
1307
+ bcast('flow-task-done', { term: id, flowId: entry.flowSessionId, taskKey: entry.flowTaskKey, laneId: id, commitSha }, flowChannel)
1308
+ process.stderr.write(`\n ${A.cyan}◆ flow slice done — ${entry.flowTaskKey} (${commitSha ? commitSha.slice(0, 8) : 'no commit'})${A.rst}\n`)
1309
+ 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.`)
1310
+ },
1311
+ ),
1283
1312
  ],
1284
1313
  })
1285
1314
  entry.session = startClaudeSession({
1286
- cwd: process.cwd(), model, resume, mode,
1315
+ cwd: cwd || process.cwd(), model, resume, mode, rolePrompt,
1287
1316
  mcpServers: { thinkpool: peekServer },
1288
1317
  // Tier C precheck — the PreToolUse gate calls this BEFORE raising a card, so a
1289
1318
  // disabled/looping/over-cap post never bothers a person. Closes over `entry`.
@@ -1291,7 +1320,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy }) {
1291
1320
  // Tier 3 — the SENDER-side gate for post_to_session (the receipt card lives in
1292
1321
  // the target room). roomHop/crossRoomPostCount reset on a real human turn (code-turn).
1293
1322
  crossRoomPostGate: () => crossRoomPostDecision({ roomHop: entry.roomHop || 0, postCount: entry.crossRoomPostCount || 0, disabled: process.env.TP_PAIRBUS_OFF === '1' }),
1294
- env: { ...process.env, TP_MOCKUP_OUTBOX: mockupOutbox },
1323
+ env: { ...process.env, ...buildConductorEnv({ flowSessionId, mode }), TP_MOCKUP_OUTBOX: mockupOutbox },
1295
1324
  onEvent: (evt) => {
1296
1325
  // Self-heal a stale resume — the saved SDK session expired. Reopen fresh,
1297
1326
  // keeping the transcript (scrollback survives; live context is gone).
@@ -1349,6 +1378,16 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy }) {
1349
1378
  emitTail(evt)
1350
1379
  },
1351
1380
  requestPermission: (req) => new Promise((resolve) => {
1381
+ // FLOW conductor plan → intercept (no generic card). The plan JSON rides
1382
+ // ExitPlanMode's `plan` arg. Broadcast to the room; its useFlow persists the
1383
+ // task-graph + flips status → awaiting_approval → FlowPlanCard renders. Park
1384
+ // the conductor ('keep' = stay in plan mode, idle; lane dispatch is Step 2).
1385
+ if (entry.flowSessionId && req.risk === 'plan') {
1386
+ bcast('flow-plan', { term: id, flowId: entry.flowSessionId, plan: req.plan || '' }, flowChannel)
1387
+ process.stderr.write(`\n ${A.mag}◆ flow plan ready — flow ${String(entry.flowSessionId).slice(0, 8)}.${A.rst}\n`)
1388
+ resolve('keep')
1389
+ return
1390
+ }
1352
1391
  // Keep the broadcast payload with the resolver so a reconnect can re-send it
1353
1392
  // (replay-request handler) — pending cards never enter the replayed event log.
1354
1393
  const payload = { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan, questions: req.questions }
@@ -1743,6 +1782,72 @@ channel
1743
1782
  }
1744
1783
  })
1745
1784
 
1785
+ // ── Thinkpool Flow control channel (tpflow:<room>) ──────────────────────────
1786
+ // Separate from the room channel above so the web's useFlow hook can own its own
1787
+ // topic without colliding with the room's tpcode channel (see the flowChannel def +
1788
+ // useFlow.js for the 2026-06-30 collision this fixes). Inbound: flow-start (launch a
1789
+ // conductor) + flow-dispatch (spawn lanes). Outbound (via bcast(..., flowChannel)):
1790
+ // flow-plan, flow-dispatched, flow-task-done.
1791
+ flowChannel
1792
+ .on('broadcast', { event: 'flow-start' }, ({ payload }) => {
1793
+ // Ensemble Flow — the room asks this bridge to launch a CONDUCTOR for a Flow run.
1794
+ // The room already created the flow_sessions row (flowId); the conductor opens in
1795
+ // plan mode (so ExitPlanMode is permitted), seeded with the build prompt. It
1796
+ // decomposes → calls ExitPlanMode(planJson) → the requestPermission flow-plan branch
1797
+ // broadcasts flow-plan back. `host` routes to one bridge in multi-machine rooms.
1798
+ if (!payload?.flowId) return
1799
+ if (payload.host && payload.host !== name) return
1800
+ for (const [, e] of sessions) if (e.flowSessionId === payload.flowId) return // already conducting this flow
1801
+ const cid = randomUUID()
1802
+ termNames[cid] = `Flow · ${String(payload.flowId).slice(0, 6)}`
1803
+ saveNames(room, termNames)
1804
+ openStructured({ id: cid, mode: 'plan', rolePrompt: FLOW_CONDUCTOR_PROMPT, flowSessionId: payload.flowId })
1805
+ const ce = sessions.get(cid)
1806
+ if (ce?.session) { try { ce.session.sendTurn(payload.prompt || '') } catch { /* session still starting */ } }
1807
+ process.stderr.write(`\n ${A.mag}◆ flow conductor launched — flow ${String(payload.flowId).slice(0, 8)} (plan mode).${A.rst}\n`)
1808
+ announce()
1809
+ })
1810
+ .on('broadcast', { event: 'flow-dispatch' }, ({ payload }) => {
1811
+ // Ensemble Flow — the room approved the plan + sends the READY tasks. Spawn one
1812
+ // worktree-isolated lane per task (FLOW_LANE_PROMPT, its own git tree so lanes
1813
+ // never collide — M57). Seed each lane with its slice spec, then echo the
1814
+ // assignments back so the room marks the tasks dispatched + flips the flow to
1815
+ // building (DB writes stay room-side, member-authed).
1816
+ if (!payload?.flowId || !Array.isArray(payload.tasks)) return
1817
+ if (payload.host && payload.host !== name) return
1818
+ const assignments = []
1819
+ for (const t of payload.tasks) {
1820
+ try {
1821
+ const { dir } = createFlowWorktree({ flowId: payload.flowId, taskKey: t.task_key })
1822
+ const laneId = randomUUID()
1823
+ termNames[laneId] = `Flow · ${t.task_key}`
1824
+ saveNames(room, termNames)
1825
+ openStructured({ id: laneId, cwd: dir, mode: 'acceptEdits', rolePrompt: FLOW_LANE_PROMPT, flowSessionId: payload.flowId, flowTaskKey: t.task_key, spawnedBy: `flow:${payload.flowId}` })
1826
+ const le = sessions.get(laneId)
1827
+ if (le?.session) {
1828
+ const spec =
1829
+ `[Flow lane — slice "${t.task_key}" of flow ${String(payload.flowId).slice(0, 8)}]\n` +
1830
+ `TITLE: ${t.title}\n` +
1831
+ `SCOPE (files you OWN — edit ONLY these): ${t.scope || '(none stated)'}\n` +
1832
+ `ACCEPTANCE (done = this runs + proves it): ${t.acceptance || '(meet the title)'}\n` +
1833
+ (t.deps && t.deps.length ? `DEPENDS ON (already built): ${t.deps.join(', ')}\n` : '') +
1834
+ `\nProject (context): ${payload.flowPrompt || ''}\n\n` +
1835
+ `Build your slice. Own only your files. Done = it runs + meets acceptance. Commit when done.`
1836
+ try { le.session.sendTurn(spec) } catch { /* session still starting */ }
1837
+ }
1838
+ assignments.push({ task_key: t.task_key, laneId })
1839
+ } catch (e) {
1840
+ process.stderr.write(`\n ${A.yel}◆ flow dispatch failed for ${t.task_key}: ${e?.message || e}${A.rst}\n`)
1841
+ }
1842
+ }
1843
+ if (assignments.length) {
1844
+ bcast('flow-dispatched', { term: 'flow', flowId: payload.flowId, assignments }, flowChannel)
1845
+ process.stderr.write(`\n ${A.mag}◆ flow dispatched ${assignments.length} lane(s) — flow ${String(payload.flowId).slice(0, 8)}.${A.rst}\n`)
1846
+ announce()
1847
+ }
1848
+ })
1849
+ .subscribe()
1850
+
1746
1851
  // Watchdog — if the realtime channel is stuck (never connected, or dropped and
1747
1852
  // not recovered) for >60s, exit non-zero so a supervisor/launchd restarts a
1748
1853
  // clean process. Healthy long-lived connections never trip this (brokenSince=0).
@@ -120,7 +120,7 @@ const simplifyBlocks = (blocks = []) => blocks.map((b) => {
120
120
  */
121
121
  const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
122
122
 
123
- export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate }) {
123
+ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, rolePrompt }) {
124
124
  const ac = new AbortController()
125
125
  let input = makeInputStream() // `let`: auto-restart swaps in a fresh stream
126
126
  let sessionId = resume || null
@@ -423,6 +423,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
423
423
  // a bypass session still planned. Bias the room session to DO the work; only
424
424
  // plan when the room is actually in Plan mode (⇧⇥) or the user explicitly asks.
425
425
  appendSystemPrompt: [
426
+ ...(rolePrompt ? [rolePrompt] : []),
426
427
  'ENVIRONMENT (authoritative — overrides any user-global CLAUDE.md or memory that claims otherwise): You are Claude running inside a ThinkPool Code room, driven live by a user (and possibly a partner) from a phone or browser, via the thinkpool-pair bridge.',
427
428
  'You are NOT in cmux. cmux may be installed on this machine and appear in $PATH, but it did not spawn you and its automation/orchestration rules do not apply. If any loaded memory says "Claude Code lives inside cmux," it is describing direct terminal use, not this room — disregard it here. The runtime truth is the env: THINKPOOL_PAIR_ACCOUNT_CHILD / npm_lifecycle_script=thinkpool-pair mark a ThinkPool Code session.',
428
429
  'Bias strongly toward DOING the work, not planning it. For a normal "add / fix / change / build X" request, just make the change directly.',
@@ -0,0 +1,91 @@
1
+ // Thinkpool Flow — the conductor brain.
2
+ //
3
+ // The conductor is a Claude agent session (run via startClaudeSession with
4
+ // `rolePrompt: FLOW_CONDUCTOR_PROMPT`) whose Step-1 job is to DECOMPOSE a "build me
5
+ // X" prompt into a task-graph of RUNNABLE slices + dependencies, then submit that
6
+ // plan for human approval. It does not build or spawn lanes yet — dispatch is Step 2.
7
+ // The plan lives in the task-graph store (DB), not in the conductor's chat context
8
+ // (research move #1). Spec: docs/specs/2026-06-29-thinkpool-flow.md.
9
+
10
+ // Bridge-local MIRROR of src/lib/flow/taskGraph.js — NOT a cross-package import.
11
+ // The bridge ships as its own npm package (thinkpool-pair); `../src/...` does not
12
+ // exist in the published tarball, only in a checkout. flow-task-graph.mjs is a
13
+ // byte-identical copy, kept honest by bridge/flow-task-graph.sync.test.js (fails if
14
+ // it drifts from the canonical src module). One logical source, two reachable copies.
15
+ import { FLOW_MODE, FLOW_STATUS, normalizePlanOutput } from './flow-task-graph.mjs'
16
+
17
+ // Re-export the shared plan normalizer so bridge callers import Flow pieces from one
18
+ // place. The implementation lives in the shared model (src/lib/flow/taskGraph.js).
19
+ export { normalizePlanOutput }
20
+
21
+ // Prepended to the room's standard appendSystemPrompt for a Flow conductor session.
22
+ // This prompt IS the moat — decomposition intelligence. Grounded in the Flow research
23
+ // (docs/specs/2026-06-29-thinkpool-flow.md §Research sources): runtime-per-lane is the
24
+ // moat, partition by file ownership, plan-in-store-not-context, right-size the slice
25
+ // count (3 focused beats 7 scattered), explicit acyclic deps.
26
+ export const FLOW_CONDUCTOR_PROMPT = [
27
+ 'THINKPOOL FLOW — you are the CONDUCTOR of an ensemble build. Your job RIGHT NOW (Step 1) is to decompose the user\'s build request into a TASK-GRAPH: a set of RUNNABLE slices with explicit dependencies, then submit that plan for human approval. Do NOT start building and do NOT spawn lanes yet — that happens only after the user approves the plan (Step 2+).',
28
+
29
+ 'DECOMPOSE INTO RUNNABLE SLICES. Each slice must be something ONE lane can build, RUN in its own browser-side WebContainer sandbox, observe, and self-correct INDEPENDENTLY. A slice that cannot run on its own is not a slice — split it or merge it until it is. The runtime (a lane running what it built) is the moat; slices that can\'t run break the whole model.',
30
+
31
+ 'PARTITION BY FILE OWNERSHIP. Each slice MUST own a disjoint set of files/components — state the exact paths in `scope`. Two lanes editing the same file collide (a branch is NOT isolation when two lanes share a tree). If two slices want the same file, one depends on the other, or you split the file. State ownership precisely.',
32
+
33
+ 'EXPLICIT ACYCLIC DEPENDENCIES. Every slice lists the keys of slices it depends on. Dependencies must form a DAG. A slice needing another\'s output (an API contract, a shared type, a layout component) depends on it. Minimize coupling — prefer independent slices that build in parallel.',
34
+
35
+ 'ACCEPTANCE CRITERIA per slice: how does the lane know it is DONE AND RUNNING? A lane is done when its slice runs in its WebContainer and meets the criteria — not when it has merely written code. State the observable, runnable proof (e.g. "GET /api/todos returns 200 + []", "submitting the form adds a row to the list that persists across reload").',
36
+
37
+ 'RIGHT-SIZE THE SLICE COUNT. Bias to FEW slices that each carry real weight, not many tiny ones. For a small app: 2-3 slices. Concurrent lanes are bounded (a low dispatch ceiling), so a flat fan-out of well-chosen slices beats a sprawling one. Three focused slices outperform seven scattered ones.',
38
+
39
+ 'SUBMIT VIA ExitPlanMode — when the decomposition is ready, CALL the ExitPlanMode tool, passing your plan as ONE JSON object (a JSON string) in its `plan` argument. The room intercepts that call, validates the plan is a real DAG (acyclic, all deps resolve), persists it to the task-graph store, and shows a plan-approval card. A malformed plan is rejected and you will be asked to re-emit. Do not also narrate the plan in prose. The exact shape:',
40
+ '{ "summary": "<one line: what we are building>", "tasks": [ { "key": "<short-unique-kebab-key>", "title": "<imperative title>", "scope": "<files/components this lane owns>", "acceptance": "<runnable proof it is done>", "deps": ["<other-task-key>", ...], "sliceType": "feature|scaffold|review|fix" } ] }',
41
+ '`key` values are stable short kebab-case identifiers. `deps` references other tasks\' keys. Put ONLY the JSON in the ExitPlanMode plan argument (no surrounding prose, no markdown fences).',
42
+
43
+ 'PLAN LIVES IN THE STORE, NOT THE CHAT. Your JSON plan is the artifact — it gets persisted to the task-graph (flow_sessions / flow_tasks) and rendered as an approval card. Do not ALSO narrate a long plan in prose. You may add 1-2 sentences framing the decomposition choice (why these slices, what was coupled), nothing more.',
44
+
45
+ 'MODE-AWARE. The user picked a mode — steer (watch every lane live), guide (DEFAULT: approve this plan, then review at phase boundaries), or autopilot (full-auto with a budget cap + adaptive escalation that pulls them in only if a lane stalls). Produce the full plan now regardless; the mode changes how much the human intervenes later, not how you decompose.',
46
+ ].join(' ')
47
+
48
+ // Env vars handed to a conductor session via startClaudeSession({ env }). The conductor
49
+ // reads these to know which Flow run it owns + what mode the human picked.
50
+ export function buildConductorEnv ({ flowSessionId, mode, budgetCapAutopilot = null }) {
51
+ return {
52
+ TP_FLOW_SESSION: flowSessionId,
53
+ TP_FLOW_MODE: mode,
54
+ ...(mode === FLOW_MODE.autopilot && budgetCapAutopilot != null
55
+ ? { TP_FLOW_BUDGET_CAP: String(budgetCapAutopilot) }
56
+ : {}),
57
+ }
58
+ }
59
+
60
+ // Status the FlowSession moves to once the conductor emits + the client validates a
61
+ // plan (i.e. the plan-approval card can now be shown). Kept here so bridge + client
62
+ // import the same transition target.
63
+ export const PLAN_READY_STATUS = FLOW_STATUS.awaiting_approval
64
+
65
+ // ── Lane builder ────────────────────────────────────────────────────────────
66
+ // The conductor DECOMPOSES; a lane BUILDS one slice. Lanes dispatch after the human
67
+ // approves the plan (Step 2). Each lane runs in its own git worktree (disjoint tree,
68
+ // atomic-revert target). This prompt is the lane's rolePrompt (via startClaudeSession).
69
+ export const FLOW_LANE_PROMPT = [
70
+ 'THINKPOOL FLOW — you are ONE LANE in an ensemble build. The conductor already decomposed the project into RUNNABLE slices; you own exactly ONE. Your task (scope + acceptance + deps) arrives as your first message.',
71
+
72
+ 'OWN YOUR FILES ONLY. The `scope` names the files/components this lane OWNS — they are DISJOINT from every other lane. Edit ONLY those. Touching another lane\'s files is a collision that breaks the merge — never do it. If you genuinely need a file outside your scope, STOP and say so instead of editing it.',
73
+
74
+ 'DONE MEANS RUNS. You are not done when you\'ve written code — you\'re done when your slice RUNS and meets the `acceptance` criteria (the observable, runnable proof). Verify it yourself before declaring done. State the proof (the command, the output) when you finish.',
75
+
76
+ 'COMMIT WHEN DONE, THEN SIGNAL. You are in your own git worktree on your own branch. When your slice meets acceptance — it RUNS and you have verified the proof — commit it atomically (one focused commit); that commit is the revert target if review later rejects it. Then call the `mark_flow_done` tool ONCE: it records your commit as this slice\'s revert target and tells the room the slice is done (which unblocks any slices depending on you). Do not push. Do NOT call mark_flow_done before your slice actually runs + meets acceptance.',
77
+
78
+ 'STAY IN YOUR LANE. Do NOT spawn further lanes (one hop only — the fork-bomb breaker). Do NOT edit other worktrees. If you\'re blocked on a dependency that isn\'t ready, say so + stop — another lane is building it.',
79
+
80
+ 'BE RIGOROUS, NOT VIBES. Trace the data path, root-cause before fixing, verify before claiming done. A lane that ships a guess costs the whole ensemble.',
81
+ ].join(' ')
82
+
83
+ // Env vars handed to a lane session so it knows its Flow run + which task it owns.
84
+ export function buildLaneEnv ({ flowSessionId, taskKey, laneId }) {
85
+ return {
86
+ TP_FLOW_SESSION: flowSessionId,
87
+ TP_FLOW_TASK_KEY: taskKey,
88
+ TP_FLOW_LANE_ID: laneId,
89
+ }
90
+ }
91
+
@@ -0,0 +1,166 @@
1
+ // Thinkpool Flow — task-graph data model.
2
+ //
3
+ // A Flow run = one FlowSession (the "build me X" prompt + mode + status) decomposed
4
+ // by the conductor into FlowTasks (RUNNABLE slices + dependencies). The plan lives
5
+ // HERE, in the store, not in the conductor's chat context — research move #1
6
+ // (docs/specs/2026-06-29-thinkpool-flow.md): "move the plan out of the context
7
+ // window into a task-graph." Pure module — shared by the bridge (conductor) and the
8
+ // client (room UI), and mirrored by CHECK constraints in the
9
+ // 20260629120000_flow_sessions_tasks migration (one source of truth).
10
+
11
+ export const FLOW_STATUS = {
12
+ planning: 'planning', // conductor is decomposing
13
+ awaiting_approval: 'awaiting_approval', // plan submitted, waiting on the human
14
+ approved: 'approved', // human approved; lanes may dispatch (Step 2+)
15
+ building: 'building', // lanes are running
16
+ reviewing: 'reviewing', // adversarial-review phase
17
+ assembling: 'assembling', // merging worktrees
18
+ done: 'done',
19
+ failed: 'failed',
20
+ cancelled: 'cancelled',
21
+ }
22
+
23
+ export const TASK_STATUS = {
24
+ pending: 'pending',
25
+ ready: 'ready', // deps satisfied, awaiting dispatch
26
+ dispatched: 'dispatched', // a lane is running it
27
+ reviewing: 'reviewing', // adversarial-review lane is checking it
28
+ done: 'done',
29
+ failed: 'failed',
30
+ reverted: 'reverted', // rolled back by the atomic-revert net
31
+ }
32
+
33
+ export const FLOW_MODE = {
34
+ steer: 'steer', // human watches every lane, live
35
+ guide: 'guide', // phase-gated (DEFAULT): approve plan + phase reviews
36
+ autopilot: 'autopilot', // full-auto, adaptive escalation + budget cap
37
+ }
38
+
39
+ export const SLICE_TYPE = {
40
+ feature: 'feature',
41
+ scaffold: 'scaffold', // project bootstrap / shared foundation
42
+ review: 'review', // adversarial-review lane
43
+ fix: 'fix',
44
+ }
45
+
46
+ // A FlowTask slice. Each is RUNNABLE — a lane can build + run + self-correct it in
47
+ // its own WebContainer (research: the runtime is the moat). Partition by file
48
+ // ownership (scope) so two lanes never edit the same file (a branch is NOT isolation).
49
+ export function makeTask ({ key, title, scope = '', acceptance = '', deps = [], sliceType = SLICE_TYPE.feature }) {
50
+ if (!key || typeof key !== 'string') throw new Error('task key required (string)')
51
+ if (!title) throw new Error('task title required')
52
+ if (!Array.isArray(deps)) throw new Error('deps must be an array of task keys')
53
+ if (!Object.values(SLICE_TYPE).includes(sliceType)) throw new Error(`unknown sliceType: ${sliceType}`)
54
+ return {
55
+ key,
56
+ title,
57
+ scope, // files/components this lane OWNS (disjoint from other slices)
58
+ acceptance, // runnable proof the lane + reviewer use to call it done
59
+ deps, // task keys this depends on (must form a DAG)
60
+ sliceType,
61
+ status: TASK_STATUS.pending,
62
+ laneId: null, // the spawned lane's terminal id, once dispatched
63
+ commitSha: null, // per-lane atomic commit (revert target)
64
+ failCount: 0, // adaptive-escalation counter (Autopilot pulls human in at N)
65
+ }
66
+ }
67
+
68
+ // Validate that tasks form a real DAG: unique keys, every dep references an existing
69
+ // key, no self-deps, no cycles. Throws on invalid. Returns the tasks (chainable).
70
+ export function validateDag (tasks) {
71
+ if (!Array.isArray(tasks) || tasks.length === 0) throw new Error('at least one task required')
72
+ const keys = new Set(tasks.map(t => t.key))
73
+ if (keys.size !== tasks.length) throw new Error('duplicate task keys')
74
+ const byKey = new Map(tasks.map(t => [t.key, t]))
75
+ for (const t of tasks) {
76
+ if (t.deps.includes(t.key)) throw new Error(`task "${t.key}" depends on itself`)
77
+ for (const d of t.deps) {
78
+ if (!keys.has(d)) throw new Error(`task "${t.key}" depends on unknown task "${d}"`)
79
+ }
80
+ }
81
+ // Cycle detection (DFS, three-color). byKey guaranteed complete by the checks above.
82
+ const WHITE = 0, GRAY = 1, BLACK = 2
83
+ const color = new Map(tasks.map(t => [t.key, WHITE]))
84
+ function visit (k) {
85
+ color.set(k, GRAY)
86
+ for (const d of byKey.get(k).deps) {
87
+ const c = color.get(d)
88
+ if (c === GRAY) throw new Error(`cycle detected at "${k}" → "${d}"`)
89
+ if (c === WHITE) visit(d)
90
+ }
91
+ color.set(k, BLACK)
92
+ }
93
+ for (const t of tasks) if (color.get(t.key) === WHITE) visit(t.key)
94
+ return tasks
95
+ }
96
+
97
+ // Tasks whose deps are all DONE → ready to dispatch. Pure.
98
+ export function readyTasks (tasks) {
99
+ const done = new Set(tasks.filter(t => t.status === TASK_STATUS.done).map(t => t.key))
100
+ return tasks.filter(t =>
101
+ t.status === TASK_STATUS.pending &&
102
+ t.deps.every(d => done.has(d))
103
+ )
104
+ }
105
+
106
+ // Topological order (Kahn's algorithm). Returns keys. Throws if not a DAG.
107
+ export function topoOrder (tasks) {
108
+ validateDag(tasks)
109
+ const indeg = new Map(tasks.map(t => [t.key, t.deps.length]))
110
+ const dependents = new Map(tasks.map(t => [t.key, []])) // dep-key → keys that depend on it
111
+ for (const t of tasks) for (const d of t.deps) dependents.get(d).push(t.key)
112
+ const queue = tasks.filter(t => indeg.get(t.key) === 0).map(t => t.key)
113
+ const order = []
114
+ while (queue.length) {
115
+ const k = queue.shift()
116
+ order.push(k)
117
+ for (const m of dependents.get(k)) {
118
+ indeg.set(m, indeg.get(m) - 1)
119
+ if (indeg.get(m) === 0) queue.push(m)
120
+ }
121
+ }
122
+ if (order.length !== tasks.length) throw new Error('cycle detected (topo incomplete)')
123
+ return order
124
+ }
125
+
126
+ // A FlowSession (the run). `tasks` is the decomposed plan (FlowTask[]).
127
+ export function makeFlowSession ({ prompt, mode = FLOW_MODE.guide, sessionRef = null, budgetCapAutopilot = null }) {
128
+ if (!prompt) throw new Error('prompt required')
129
+ if (!Object.values(FLOW_MODE).includes(mode)) throw new Error(`unknown mode: ${mode}`)
130
+ return {
131
+ prompt,
132
+ mode,
133
+ sessionRef, // the /code room session id this Flow runs in
134
+ status: FLOW_STATUS.planning,
135
+ budgetCapAutopilot, // token/step cap (Autopilot only); null in guide/steer
136
+ createdAt: null, // stamped by the DB
137
+ tasks: [],
138
+ }
139
+ }
140
+
141
+ // Normalize the conductor's raw plan output into a validated { summary, tasks }.
142
+ // Accepts a parsed object OR a JSON string (optionally ```json-fenced). Throws if the
143
+ // tasks don't form a real DAG. Lives in this shared module so neither the bridge nor
144
+ // the client has to import across the bridge↔src boundary.
145
+ export function normalizePlanOutput (raw) {
146
+ let obj = raw
147
+ if (typeof raw === 'string') {
148
+ let s = raw.trim()
149
+ const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i)
150
+ if (fence) s = fence[1].trim()
151
+ obj = JSON.parse(s)
152
+ }
153
+ if (!obj || typeof obj !== 'object') throw new Error('plan output is not an object')
154
+ const summary = typeof obj.summary === 'string' && obj.summary.trim() ? obj.summary.trim() : ''
155
+ if (!Array.isArray(obj.tasks) || obj.tasks.length === 0) throw new Error('plan has no tasks')
156
+ const tasks = obj.tasks.map((t) => makeTask({
157
+ key: String(t.key),
158
+ title: String(t.title ?? t.key),
159
+ scope: String(t.scope ?? ''),
160
+ acceptance: String(t.acceptance ?? ''),
161
+ deps: Array.isArray(t.deps) ? t.deps.map(String) : [],
162
+ sliceType: Object.values(SLICE_TYPE).includes(t.sliceType) ? t.sliceType : SLICE_TYPE.feature,
163
+ }))
164
+ validateDag(tasks)
165
+ return { summary, tasks }
166
+ }
@@ -0,0 +1,53 @@
1
+ // Thinkpool Flow — per-lane git worktree isolation.
2
+ //
3
+ // A Flow task-lane builds its slice in its OWN worktree. A branch is NOT isolation
4
+ // when ≥2 lanes share one working tree (M57) — two lanes editing files in the shared
5
+ // checkout collide. A worktree gives each lane a disjoint tree + its own branch, and
6
+ // that branch is the atomic-revert target (commit_sha on flow_tasks, Step 4).
7
+ //
8
+ // Pure over injected git/fs so it's unit-testable against a temp repo.
9
+ // Spec: docs/specs/2026-06-29-thinkpool-flow.md (Step 2).
10
+
11
+ import { execFileSync } from 'node:child_process'
12
+ import path from 'node:path'
13
+ import fs from 'node:fs'
14
+
15
+ const ROOT = process.cwd() // the bridge's checkout (the shared main repo root)
16
+
17
+ // The lane's branch + directory for a (flowId, taskKey). Deterministic so a
18
+ // re-dispatch of the same task reuses the same worktree.
19
+ export function worktreeSpec ({ flowId, taskKey, root = ROOT }) {
20
+ const wtRoot = path.join(root, '.claude', 'worktrees')
21
+ const short = String(flowId).replace(/-/g, '').slice(0, 8)
22
+ const safeKey = String(taskKey).toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 40).replace(/^-+|-+$/g, '') || 'task'
23
+ const branch = `flow/${short}/${safeKey}`
24
+ const dir = path.join(wtRoot, `flow-${short}-${safeKey}`)
25
+ return { branch, dir, wtRoot }
26
+ }
27
+
28
+ // Create a worktree for a task-lane off `base` (default origin/main). Idempotent: if
29
+ // the dir already holds a worktree, reuse it. Returns { dir, branch, created }.
30
+ export function createFlowWorktree ({ flowId, taskKey, base = 'origin/main', root = ROOT, git = runGit }) {
31
+ const { branch, dir, wtRoot } = worktreeSpec({ flowId, taskKey, root })
32
+ if (fs.existsSync(path.join(dir, '.git'))) return { dir, branch, created: false }
33
+ fs.mkdirSync(wtRoot, { recursive: true })
34
+ try {
35
+ git(['worktree', 'add', '-b', branch, dir, base], root)
36
+ } catch {
37
+ // Branch already exists (a prior dispatch of this task) — check it out instead.
38
+ git(['worktree', 'add', dir, branch], root)
39
+ }
40
+ return { dir, branch, created: true }
41
+ }
42
+
43
+ // Remove a lane's worktree + delete its branch (on revert / cancel / flow cancel).
44
+ // Best-effort: missing worktree/branch is not an error.
45
+ export function removeFlowWorktree ({ flowId, taskKey, root = ROOT, git = runGit }) {
46
+ const { branch, dir } = worktreeSpec({ flowId, taskKey, root })
47
+ try { git(['worktree', 'remove', dir, '--force'], root) } catch { /* already gone */ }
48
+ try { git(['branch', '-D', branch], root) } catch { /* already gone */ }
49
+ }
50
+
51
+ function runGit (args, cwd) {
52
+ return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' })
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.96",
3
+ "version": "0.7.98",
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": {
@@ -16,6 +16,9 @@
16
16
  "transcript-sanitize.mjs",
17
17
  "session-store.mjs",
18
18
  "cross-terminal.mjs",
19
+ "flow-conductor.mjs",
20
+ "flow-worktree.mjs",
21
+ "flow-task-graph.mjs",
19
22
  "serve-dir.mjs",
20
23
  "service.mjs",
21
24
  "presence.mjs",