thinkpool-pair 0.7.98 → 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'
@@ -1304,9 +1308,14 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1304
1308
  if (!entry.flowSessionId || !entry.flowTaskKey) return okText('Not a Flow lane — nothing to mark done.')
1305
1309
  let commitSha = null
1306
1310
  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.`)
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.`)
1310
1319
  },
1311
1320
  ),
1312
1321
  ],
@@ -1816,13 +1825,21 @@ flowChannel
1816
1825
  if (!payload?.flowId || !Array.isArray(payload.tasks)) return
1817
1826
  if (payload.host && payload.host !== name) return
1818
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
1819
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 }
1820
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'
1821
1838
  const { dir } = createFlowWorktree({ flowId: payload.flowId, taskKey: t.task_key })
1822
1839
  const laneId = randomUUID()
1823
1840
  termNames[laneId] = `Flow · ${t.task_key}`
1824
1841
  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}` })
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}` })
1826
1843
  const le = sessions.get(laneId)
1827
1844
  if (le?.session) {
1828
1845
  const spec =
@@ -1836,6 +1853,7 @@ flowChannel
1836
1853
  try { le.session.sendTurn(spec) } catch { /* session still starting */ }
1837
1854
  }
1838
1855
  assignments.push({ task_key: t.task_key, laneId })
1856
+ liveLanes++
1839
1857
  } catch (e) {
1840
1858
  process.stderr.write(`\n ${A.yel}◆ flow dispatch failed for ${t.task_key}: ${e?.message || e}${A.rst}\n`)
1841
1859
  }
@@ -1846,6 +1864,48 @@ flowChannel
1846
1864
  announce()
1847
1865
  }
1848
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
+ })
1849
1909
  .subscribe()
1850
1910
 
1851
1911
  // Watchdog — if the realtime channel is stuck (never connected, or dropped and
@@ -1980,6 +2040,7 @@ async function shutdown(code = 0, farewell = true) {
1980
2040
  // process alive with orphaned PTY children + stale "live" presence.
1981
2041
  setTimeout(() => process.exit(code), 1500)
1982
2042
  clearInterval(flushTimer)
2043
+ try { stopAllPreviews() } catch { /* Flow preview servers — best-effort close */ }
1983
2044
  if (farewell) {
1984
2045
  try {
1985
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.98",
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",