thinkpool-pair 0.7.96 → 0.7.97

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'
@@ -1015,12 +1017,12 @@ function restoredTurnOpen(log) {
1015
1017
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
1016
1018
  // persist to the host file; tool calls round-trip through the perm card; the
1017
1019
  // rolling log replays to joiners and survives bridge restarts (session-store).
1018
- function openStructured({ id, model, resume, log, commands, mode, spawnedBy }) {
1020
+ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd }) {
1019
1021
  if (sessions.has(id)) return
1020
1022
  // spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
1021
1023
  // session store so the Ensemble flag survives a bridge restart (else a respin
1022
1024
  // 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 }
1025
+ 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
1026
  // C1 (RT-2): per-term contiguous seq counter. Seeded from the restored log's max
1025
1027
  // so a bridge restart resumes ABOVE every persisted seq — fresh events never reuse
1026
1028
  // an old seq, which is what keeps the between-turns restart dup-free (see
@@ -1280,10 +1282,28 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy }) {
1280
1282
  return okText(`Closed agent lane ${target.ref}${target.name ? ` ("${target.name}")` : ''}.`)
1281
1283
  },
1282
1284
  ),
1285
+ // Flow lane → mark this slice done (lane-done return path). Reads the lane's
1286
+ // worktree HEAD as commit_sha (the atomic-revert target) + broadcasts
1287
+ // flow-task-done → the room flips the task done + dispatches the next wave
1288
+ // (slices whose deps just got satisfied).
1289
+ tool(
1290
+ 'mark_flow_done',
1291
+ "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.",
1292
+ {},
1293
+ async () => {
1294
+ const okText = (t) => ({ content: [{ type: 'text', text: t }] })
1295
+ if (!entry.flowSessionId || !entry.flowTaskKey) return okText('Not a Flow lane — nothing to mark done.')
1296
+ let commitSha = null
1297
+ 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.`)
1301
+ },
1302
+ ),
1283
1303
  ],
1284
1304
  })
1285
1305
  entry.session = startClaudeSession({
1286
- cwd: process.cwd(), model, resume, mode,
1306
+ cwd: cwd || process.cwd(), model, resume, mode, rolePrompt,
1287
1307
  mcpServers: { thinkpool: peekServer },
1288
1308
  // Tier C precheck — the PreToolUse gate calls this BEFORE raising a card, so a
1289
1309
  // disabled/looping/over-cap post never bothers a person. Closes over `entry`.
@@ -1291,7 +1311,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy }) {
1291
1311
  // Tier 3 — the SENDER-side gate for post_to_session (the receipt card lives in
1292
1312
  // the target room). roomHop/crossRoomPostCount reset on a real human turn (code-turn).
1293
1313
  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 },
1314
+ env: { ...process.env, ...buildConductorEnv({ flowSessionId, mode }), TP_MOCKUP_OUTBOX: mockupOutbox },
1295
1315
  onEvent: (evt) => {
1296
1316
  // Self-heal a stale resume — the saved SDK session expired. Reopen fresh,
1297
1317
  // keeping the transcript (scrollback survives; live context is gone).
@@ -1349,6 +1369,16 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy }) {
1349
1369
  emitTail(evt)
1350
1370
  },
1351
1371
  requestPermission: (req) => new Promise((resolve) => {
1372
+ // FLOW conductor plan → intercept (no generic card). The plan JSON rides
1373
+ // ExitPlanMode's `plan` arg. Broadcast to the room; its useFlow persists the
1374
+ // task-graph + flips status → awaiting_approval → FlowPlanCard renders. Park
1375
+ // the conductor ('keep' = stay in plan mode, idle; lane dispatch is Step 2).
1376
+ if (entry.flowSessionId && req.risk === 'plan') {
1377
+ bcast('flow-plan', { term: id, flowId: entry.flowSessionId, plan: req.plan || '' })
1378
+ process.stderr.write(`\n ${A.mag}◆ flow plan ready — flow ${String(entry.flowSessionId).slice(0, 8)}.${A.rst}\n`)
1379
+ resolve('keep')
1380
+ return
1381
+ }
1352
1382
  // Keep the broadcast payload with the resolver so a reconnect can re-send it
1353
1383
  // (replay-request handler) — pending cards never enter the replayed event log.
1354
1384
  const payload = { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan, questions: req.questions }
@@ -1464,6 +1494,63 @@ channel
1464
1494
  }
1465
1495
  shutdown()
1466
1496
  })
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
+ })
1467
1554
  .on('broadcast', { event: 'term-open' }, ({ payload }) => {
1468
1555
  if (!payload?.id || !payload?.cmd) return
1469
1556
  // Multi-bridge rooms: a targeted open is for ONE machine. Untargeted
@@ -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.97",
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",