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 +66 -5
- package/flow-assembly.mjs +183 -0
- package/flow-budget.mjs +84 -0
- package/flow-preview.mjs +144 -0
- package/flow-review.mjs +92 -0
- package/package.json +5 -1
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
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
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
|
+
}
|
package/flow-budget.mjs
ADDED
|
@@ -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'
|
package/flow-preview.mjs
ADDED
|
@@ -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
|
+
}
|
package/flow-review.mjs
ADDED
|
@@ -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.
|
|
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",
|