thinkpool-pair 0.7.364 → 0.7.366
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/LICENSE +18 -0
- package/README.md +20 -0
- package/abort-turn-barrier.mjs +1 -23
- package/account.mjs +1 -1437
- package/acp-client.mjs +1 -140
- package/agent-detect.mjs +1 -28
- package/agent-notify.mjs +1 -142
- package/agent-visibility.mjs +1 -67
- package/auth-store.mjs +1 -120
- package/bridge.mjs +1 -6266
- package/byok-detect.mjs +1 -126
- package/claude-command-catalog.mjs +1 -91
- package/claude-session.mjs +1 -1519
- package/code-event-contract.mjs +1 -118
- package/codex-app-server.mjs +1 -340
- package/codex-commands.mjs +1 -83
- package/codex-event-mapper.mjs +1 -232
- package/codex-images.mjs +1 -69
- package/codex-mcp-http.mjs +1 -131
- package/codex-session.mjs +1 -1295
- package/command-catalog.mjs +1 -118
- package/command-guidance.mjs +1 -8
- package/context-contract.mjs +1 -95
- package/context-windows.mjs +1 -107
- package/cross-terminal.mjs +1 -789
- package/cumulative-event-relay.mjs +1 -53
- package/design-edit.mjs +1 -424
- package/design-source-contract.mjs +1 -4
- package/direct-pair-room.mjs +1 -57
- package/dispatch-lease.mjs +1 -37
- package/dispatch-permission-cleanup.mjs +1 -86
- package/edit-diff.mjs +1 -136
- package/error-recovery.mjs +1 -50
- package/event-bounds.mjs +1 -121
- package/event-delivery-queue.mjs +1 -60
- package/event-id.mjs +1 -549
- package/evidence-citations.mjs +1 -50
- package/evidence-compact.mjs +1 -11
- package/flow-assembly.mjs +1 -196
- package/flow-budget.mjs +1 -84
- package/flow-conductor.mjs +1 -259
- package/flow-context-store.mjs +1 -387
- package/flow-host-revert.mjs +1 -42
- package/flow-models.mjs +1 -139
- package/flow-preview.mjs +1 -148
- package/flow-receipt.mjs +1 -122
- package/flow-redispatch.mjs +1 -72
- package/flow-review-gate.mjs +1 -402
- package/flow-review-reflect.mjs +1 -115
- package/flow-review.mjs +1 -152
- package/flow-scope-evidence.mjs +1 -117
- package/flow-skill-registry.mjs +1 -140
- package/flow-task-graph.mjs +1 -562
- package/flow-worktree.mjs +1 -71
- package/git-diff-report.mjs +1 -121
- package/hermes-delegation-guard.mjs +1 -14
- package/hermes-event-mapper.mjs +1 -194
- package/hermes-isolation.mjs +1 -61
- package/hermes-model-cache.mjs +1 -54
- package/hermes-policy.mjs +1 -92
- package/hermes-probe.mjs +1 -57
- package/hermes-session.mjs +1 -683
- package/hermes-setup.mjs +1 -167
- package/host-memory.mjs +1 -116
- package/interrupted-resume.mjs +1 -95
- package/keep-awake.mjs +1 -148
- package/key-shape.mjs +1 -49
- package/lane-continuation.mjs +1 -83
- package/lane-lifecycle.mjs +1 -189
- package/lane-worktree.mjs +1 -77
- package/launcher.mjs +1 -354
- package/mcp-flight-recorder.mjs +1 -79
- package/mockup-delivery.mjs +1 -57
- package/model-prices.mjs +1 -113
- package/package.json +13 -4
- package/pair-bus.mjs +1 -98
- package/pair-control-authority.mjs +1 -89
- package/past-work-search.mjs +1 -105
- package/plan-meters.mjs +1 -144
- package/presence.mjs +1 -191
- package/privacy-report.mjs +1 -108
- package/provider-resilience.mjs +1 -356
- package/provider.mjs +1 -133
- package/providers.mjs +1 -491
- package/publish-guard.mjs +2 -0
- package/publish-manifest.json +129 -0
- package/question-response.mjs +1 -58
- package/reap-terminal.mjs +1 -68
- package/recap.mjs +1 -297
- package/replay-transport.mjs +1 -64
- package/repo-search.mjs +1 -190
- package/review-check.mjs +1 -182
- package/runtime-contract.mjs +1 -93
- package/runtime-registry.mjs +1 -64
- package/runtime-session.mjs +1 -20
- package/scheduled-run-admission.mjs +1 -364
- package/scheduled-runs.mjs +1 -268
- package/sdk-admission.mjs +1 -9
- package/sdk-smoke.mjs +1 -61
- package/serve-consent.mjs +1 -118
- package/serve-dir.mjs +1 -40
- package/service.mjs +1 -877
- package/session-store.mjs +1 -484
- package/side-lane.mjs +1 -63
- package/supabase-key.mjs +1 -176
- package/supervisor-ready.mjs +1 -57
- package/switch-provider.mjs +1 -84
- package/terminal-name.mjs +1 -359
- package/terminal-row-reconcile.mjs +1 -70
- package/thinkpool-prompt-contracts.mjs +1 -85
- package/thinkpool-room-prompt.mjs +1 -187
- package/transcript-sanitize.mjs +1 -332
- package/turn-stall.mjs +1 -61
- package/update-gate.mjs +1 -53
- package/viewport.mjs +1 -810
- package/worker-completion.mjs +1 -57
package/flow-assembly.mjs
CHANGED
|
@@ -1,196 +1 @@
|
|
|
1
|
-
// Thinkpool Flow
|
|
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
|
-
// CONTAIN to baseDir — a lane-authored index.html could reference `../../etc/passwd`
|
|
46
|
-
// or an absolute path; without this the bridge would read arbitrary host files and
|
|
47
|
-
// inline them into the served preview (path traversal). Mirrors resolveUnderRoot in
|
|
48
|
-
// flow-preview.mjs: resolve, then reject anything that escapes baseDir. Returns null on
|
|
49
|
-
// an out-of-root / absolute ref so callers leave the tag untouched (never read it).
|
|
50
|
-
const resolve = (ref) => {
|
|
51
|
-
if (!ref || path.isAbsolute(ref)) return null
|
|
52
|
-
const abs = path.resolve(baseDir, ref.replace(/^\.\//, ''))
|
|
53
|
-
const rel = path.relative(baseDir, abs)
|
|
54
|
-
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return rel === '' ? abs : null
|
|
55
|
-
return abs
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Collected module-script bodies (import/export users) get merged into one block.
|
|
59
|
-
const moduleBodies = []
|
|
60
|
-
|
|
61
|
-
// <link rel="stylesheet" href="…"> → <style>…</style>
|
|
62
|
-
html = html.replace(
|
|
63
|
-
/<link\b[^>]*\brel=["']stylesheet["'][^>]*>/gi,
|
|
64
|
-
(tag) => {
|
|
65
|
-
const m = tag.match(/\bhref=["']([^"']+)["']/i)
|
|
66
|
-
if (!m || !isLocal(m[1])) return tag
|
|
67
|
-
const p = resolve(m[1]); if (!p) return tag // out-of-root ref → leave the tag as-is
|
|
68
|
-
const css = String(readFile(p, 'utf8'))
|
|
69
|
-
return `<style>\n${css}\n</style>`
|
|
70
|
-
}
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
// <script … src="…"></script> → inline. Module scripts that import/export are
|
|
74
|
-
// deferred into moduleBodies; everything else inlines in place.
|
|
75
|
-
html = html.replace(
|
|
76
|
-
/<script\b([^>]*)\bsrc=["']([^"']+)["']([^>]*)>\s*<\/script>/gi,
|
|
77
|
-
(tag, pre, src, post) => {
|
|
78
|
-
if (!isLocal(src)) return tag
|
|
79
|
-
const p = resolve(src); if (!p) return tag // out-of-root ref → leave the tag as-is
|
|
80
|
-
const attrs = `${pre} ${post}`
|
|
81
|
-
const isModule = /\btype=["']module["']/i.test(attrs)
|
|
82
|
-
const body = String(readFile(p, 'utf8'))
|
|
83
|
-
const usesEsm = /(^|[\s;{(])(import|export)\s/.test(body)
|
|
84
|
-
if (isModule && usesEsm) {
|
|
85
|
-
moduleBodies.push(`/* inlined: ${src} */\n${body}`)
|
|
86
|
-
return '' // collected; emitted as one merged module block below
|
|
87
|
-
}
|
|
88
|
-
// Plain script (or module with no import/export): inline directly. Drop
|
|
89
|
-
// type=module when it carries no ESM syntax — a bare inline module is fine
|
|
90
|
-
// either way, but stripping keeps the single file maximally portable.
|
|
91
|
-
return `<script>\n${body}\n</script>`
|
|
92
|
-
}
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
if (moduleBodies.length) {
|
|
96
|
-
const merged = `<script type="module">\n${moduleBodies.join('\n')}\n</script>`
|
|
97
|
-
// Re-attach the merged block where the </body> is (or append if no body tag).
|
|
98
|
-
if (/<\/body>/i.test(html)) html = html.replace(/<\/body>/i, `${merged}\n</body>`)
|
|
99
|
-
else html += `\n${merged}\n`
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return html
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// ── mergeWorktrees ───────────────────────────────────────────────────────────
|
|
106
|
-
// Union the files from each lane worktree dir into outDir. Later dirs win on a
|
|
107
|
-
// path conflict (and every conflict is logged). Skips .git, node_modules, .claude.
|
|
108
|
-
// Returns { files, conflicts } — both arrays of outDir-relative paths.
|
|
109
|
-
export function mergeWorktrees ({ dirs, outDir, fs = nodeFs }) {
|
|
110
|
-
const SKIP = new Set(['.git', 'node_modules', '.claude'])
|
|
111
|
-
const seen = new Set()
|
|
112
|
-
const files = []
|
|
113
|
-
const conflicts = []
|
|
114
|
-
|
|
115
|
-
fs.mkdirSync(outDir, { recursive: true })
|
|
116
|
-
|
|
117
|
-
const walk = (srcRoot, rel = '') => {
|
|
118
|
-
const abs = path.join(srcRoot, rel)
|
|
119
|
-
for (const ent of fs.readdirSync(abs, { withFileTypes: true })) {
|
|
120
|
-
if (SKIP.has(ent.name)) continue
|
|
121
|
-
const childRel = rel ? path.join(rel, ent.name) : ent.name
|
|
122
|
-
if (ent.isDirectory()) {
|
|
123
|
-
walk(srcRoot, childRel)
|
|
124
|
-
} else if (ent.isFile()) {
|
|
125
|
-
const dest = path.join(outDir, childRel)
|
|
126
|
-
if (seen.has(childRel)) conflicts.push(childRel)
|
|
127
|
-
else { seen.add(childRel); files.push(childRel) }
|
|
128
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true })
|
|
129
|
-
fs.copyFileSync(path.join(srcRoot, childRel), dest) // later dir wins
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
for (const d of dirs) walk(d)
|
|
135
|
-
return { files, conflicts }
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// ── zipDir ───────────────────────────────────────────────────────────────────
|
|
139
|
-
// Produce a zip Buffer of dir. PATH USED: shells out to the system `zip` via
|
|
140
|
-
// child_process (`zip -q -r - .` from inside dir → captured to a Buffer) — robust,
|
|
141
|
-
// produces a standards-compliant archive, no hand-rolled ZIP central directory.
|
|
142
|
-
// We do NOT hand-build a zlib-deflate container: a correct ZIP needs the local
|
|
143
|
-
// headers + central directory + CRC32 + end-of-central-directory record, which is
|
|
144
|
-
// a lot of fiddly bytes to get right for no gain when `zip` is on every dev box.
|
|
145
|
-
// Falls back gracefully — if `zip` is missing, throws a clear, catchable error so
|
|
146
|
-
// callers (and the test) can skip rather than crash the bridge.
|
|
147
|
-
export function zipDir ({ dir }) {
|
|
148
|
-
try {
|
|
149
|
-
return execFileSync('zip', ['-q', '-r', '-', '.'], {
|
|
150
|
-
cwd: dir,
|
|
151
|
-
maxBuffer: 256 * 1024 * 1024
|
|
152
|
-
})
|
|
153
|
-
} catch (err) {
|
|
154
|
-
if (err && err.code === 'ENOENT') {
|
|
155
|
-
const e = new Error('zipDir: system `zip` not available')
|
|
156
|
-
e.code = 'NO_ZIP'
|
|
157
|
-
throw e
|
|
158
|
-
}
|
|
159
|
-
throw err
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// ── initRepo ─────────────────────────────────────────────────────────────────
|
|
164
|
-
// git init + add -A + commit in dir. Injectable git for tests. Returns
|
|
165
|
-
// { committed } — false when there was nothing to commit (empty tree).
|
|
166
|
-
export function initRepo ({ dir, git = runGit }) {
|
|
167
|
-
git(['init', '-q', '-b', 'main'], dir)
|
|
168
|
-
git(['add', '-A'], dir)
|
|
169
|
-
try {
|
|
170
|
-
git(['commit', '-q', '-m', 'Assembled by Thinkpool Flow'], dir)
|
|
171
|
-
return { committed: true }
|
|
172
|
-
} catch {
|
|
173
|
-
return { committed: false } // nothing staged / nothing to commit
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// ── pushToGithub ─────────────────────────────────────────────────────────────
|
|
178
|
-
// Best-effort, OPTIONAL. Create a private repo from dir and push via the gh CLI.
|
|
179
|
-
// Never throws — if gh is absent / unauthed / errors, returns { pushed:false,
|
|
180
|
-
// reason } so delivery (repo + zip) still succeeds. Injectable gh for tests.
|
|
181
|
-
export async function pushToGithub ({ dir, repoName, gh = ghCli }) {
|
|
182
|
-
try {
|
|
183
|
-
gh(['repo', 'create', repoName, '--private', '--source=.', '--push'], dir)
|
|
184
|
-
return { pushed: true }
|
|
185
|
-
} catch (err) {
|
|
186
|
-
return { pushed: false, reason: err && err.message ? err.message : String(err) }
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// ── injected defaults ─────────────────────────────────────────────────────────
|
|
191
|
-
function runGit (args, cwd) {
|
|
192
|
-
return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' })
|
|
193
|
-
}
|
|
194
|
-
function ghCli (args, cwd) {
|
|
195
|
-
return execFileSync('gh', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' })
|
|
196
|
-
}
|
|
1
|
+
import{readFileSync as t}from"node:fs";import e from"node:fs";import r from"node:path";import{execFileSync as i}from"node:child_process";export function inlineSingleHtml({htmlPath:e,root:i,readFile:n=t}){const o=i||r.dirname(e);let s=String(n(e,"utf8"));const c=t=>t&&!/^[a-z]+:\/\//i.test(t)&&!t.startsWith("//"),p=t=>{if(!t||r.isAbsolute(t))return null;const e=r.resolve(o,t.replace(/^\.\//,"")),i=r.relative(o,e);return""===i||i.startsWith("..")||r.isAbsolute(i)?""===i?e:null:e},u=[];if(s=s.replace(/<link\b[^>]*\brel=["']stylesheet["'][^>]*>/gi,t=>{const e=t.match(/\bhref=["']([^"']+)["']/i);if(!e||!c(e[1]))return t;const r=p(e[1]);return r?`<style>\n${String(n(r,"utf8"))}\n</style>`:t}),s=s.replace(/<script\b([^>]*)\bsrc=["']([^"']+)["']([^>]*)>\s*<\/script>/gi,(t,e,r,i)=>{if(!c(r))return t;const o=p(r);if(!o)return t;const s=/\btype=["']module["']/i.test(`${e} ${i}`),d=String(n(o,"utf8")),l=/(^|[\s;{(])(import|export)\s/.test(d);return s&&l?(u.push(`/* inlined: ${r} */\n${d}`),""):`<script>\n${d}\n<\/script>`}),u.length){const t=`<script type="module">\n${u.join("\n")}\n<\/script>`;/<\/body>/i.test(s)?s=s.replace(/<\/body>/i,`${t}\n</body>`):s+=`\n${t}\n`}return s}export function mergeWorktrees({dirs:t,outDir:i,fs:n=e}){const o=new Set([".git","node_modules",".claude"]),s=new Set,c=[],p=[];n.mkdirSync(i,{recursive:!0});const u=(t,e="")=>{const d=r.join(t,e);for(const l of n.readdirSync(d,{withFileTypes:!0})){if(o.has(l.name))continue;const d=e?r.join(e,l.name):l.name;if(l.isDirectory())u(t,d);else if(l.isFile()){const e=r.join(i,d);s.has(d)?p.push(d):(s.add(d),c.push(d)),n.mkdirSync(r.dirname(e),{recursive:!0}),n.copyFileSync(r.join(t,d),e)}}};for(const e of t)u(e);return{files:c,conflicts:p}}export function zipDir({dir:t}){try{return i("zip",["-q","-r","-","."],{cwd:t,maxBuffer:268435456})}catch(t){if(t&&"ENOENT"===t.code){const t=new Error("zipDir: system `zip` not available");throw t.code="NO_ZIP",t}throw t}}export function initRepo({dir:t,git:e=n}){e(["init","-q","-b","main"],t),e(["add","-A"],t);try{return e(["commit","-q","-m","Assembled by Thinkpool Flow"],t),{committed:!0}}catch{return{committed:!1}}}export async function pushToGithub({dir:t,repoName:e,gh:r=o}){try{return r(["repo","create",e,"--private","--source=.","--push"],t),{pushed:!0}}catch(t){return{pushed:!1,reason:t&&t.message?t.message:String(t)}}}function n(t,e){return i("git",t,{cwd:e,stdio:["ignore","pipe","pipe"],encoding:"utf8"})}function o(t,e){return i("gh",t,{cwd:e,stdio:["ignore","pipe","pipe"],encoding:"utf8"})}
|
package/flow-budget.mjs
CHANGED
|
@@ -1,84 +1 @@
|
|
|
1
|
-
|
|
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'
|
|
1
|
+
export const FLOW_LIMITS={maxConcurrentLanes:8,maxHop:1,escalateAfterFails:2};export function makeBudget({capTokens:e=null}={}){return{capTokens:e,spentTokens:0,killed:!1}}export function recordSpend(e,n){const o=e.spentTokens+n,t=null!=e.capTokens?e.killed||o>=e.capTokens:e.killed;return{...e,spentTokens:o,killed:t}}export function budgetExceeded(e){return null!=e.capTokens&&e.spentTokens>=e.capTokens}export function remainingBudget(e){return null==e.capTokens?1/0:Math.max(0,e.capTokens-e.spentTokens)}export function canDispatch({mode:n,liveLanes:o,budget:t}){return o>=FLOW_LIMITS.maxConcurrentLanes?{ok:!1,reason:"lane cap"}:n===e&&(t.killed||budgetExceeded(t))?{ok:!1,reason:"budget cap"}:{ok:!0,reason:""}}export function shouldEscalate({mode:n,failCount:o}){return n!==e||o>=FLOW_LIMITS.escalateAfterFails}export function killSwitchEnv(){return{disabled:"1"===process.env.TP_FLOW_OFF,capFromEnv:process.env.TP_FLOW_BUDGET_CAP?Number(process.env.TP_FLOW_BUDGET_CAP):null}}const e="autopilot";
|
package/flow-conductor.mjs
CHANGED
|
@@ -1,259 +1 @@
|
|
|
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
|
-
// S1 (context-offload) — read the conductor's BOUNDED cross-wave context from the durable
|
|
18
|
-
// digest store instead of accumulating full lane transcripts. loadDigest already enforces
|
|
19
|
-
// the hard CEILING (summary + pointers per closed slice, truncated before it grows), so
|
|
20
|
-
// the assembled cross-wave context can never overflow the conductor's head. Consume the
|
|
21
|
-
// store — do NOT re-implement it (canonical src/lib/flow/contextStore.js, mirror here).
|
|
22
|
-
import { loadDigest } from './flow-context-store.mjs'
|
|
23
|
-
// Progressive skill registry (S2). Bridge-local mirror of src/lib/flow/skillRegistry.js
|
|
24
|
-
// (same tarball-boundary reason as flow-task-graph). `manifest()` yields compact
|
|
25
|
-
// METADATA ONLY (name/description/whenToUse — never a SKILL.md body); `loadBody(name)`
|
|
26
|
-
// returns a single body on demand. This wiring injects the manifest into a lane's base
|
|
27
|
-
// prompt and loads a body only when the lane activates that skill. DO NOT edit the
|
|
28
|
-
// registry here — consume it.
|
|
29
|
-
import { manifest as skillManifest, loadBody as skillLoadBody } from './flow-skill-registry.mjs'
|
|
30
|
-
|
|
31
|
-
// Re-export the shared plan normalizer so bridge callers import Flow pieces from one
|
|
32
|
-
// place. The implementation lives in the shared model (src/lib/flow/taskGraph.js).
|
|
33
|
-
export { normalizePlanOutput }
|
|
34
|
-
|
|
35
|
-
// Prepended to the room's standard appendSystemPrompt for a Flow conductor session.
|
|
36
|
-
// This prompt IS the moat — decomposition intelligence. Grounded in the Flow research
|
|
37
|
-
// (docs/specs/2026-06-29-thinkpool-flow.md §Research sources): runtime-per-lane is the
|
|
38
|
-
// moat, partition by file ownership, plan-in-store-not-context, right-size the slice
|
|
39
|
-
// count (3 focused beats 7 scattered), explicit acyclic deps.
|
|
40
|
-
export const FLOW_CONDUCTOR_PROMPT = [
|
|
41
|
-
'THINKPOOL FLOW CONDUCTOR — HARD RULES (read first, no exceptions): You are a DECOMPOSITION-ONLY conductor. Your ONLY output action is using the Write tool ONCE to write your task-graph JSON to the file named exactly FLOW_PLAN.json. That single Write IS how you submit your plan — the room ingests it directly. Do NOT use ExitPlanMode and do NOT call submit_flow_plan (both hang here). Do NOT write any other file and do NOT write a plan-document markdown. You MUST NOT write or edit files, MUST NOT run bash/git, MUST NOT call AskUserQuestion, and MUST NOT build the project yourself — those are blocked and will just stall you. You do NOT build; you DECOMPOSE so the lanes build. A fully-specified build is STILL decomposed, never built here. When a detail is ambiguous or the user is unsure, choose the least-invasive reversible default, record that assumption in the summary, and continue. Never ask merely because the user is unsure; ask only when a choice would materially expand scope or authority. User steering always wins. Think, then call submit_flow_plan. That is the whole job.',
|
|
42
|
-
|
|
43
|
-
'DO NOT SPAWN SUBAGENTS. You MUST NOT use the Task/Agent tool or launch Explore / sub-agents — every subagent spawn pops a permission card and stalls the whole flow (this is the #1 way the conductor hangs asking for permission). You almost never need to look at code to decompose: decompose from the build request itself. If you genuinely must inspect something first, read it YOURSELF with Read/Grep/Glob (those are allowed without a prompt) — never delegate it to a spawned agent. Decompose, then Write FLOW_PLAN.json.',
|
|
44
|
-
|
|
45
|
-
'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+).',
|
|
46
|
-
|
|
47
|
-
'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.',
|
|
48
|
-
|
|
49
|
-
'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.',
|
|
50
|
-
|
|
51
|
-
'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.',
|
|
52
|
-
|
|
53
|
-
'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").',
|
|
54
|
-
|
|
55
|
-
'GATE-FIRST CONTRACT per builder/fix/scaffold slice: include an explicit bounded `nonGoals` list (what this slice will not change) and a `baseline` gate describing the real pre-edit condition that must FAIL or be ABSENT. The builder must observe that gate before editing and later supply a bounded evidence receipt; do not invent a RED sentence. A review task must depend on exactly one builder/fix task and inherits that target contract — it verifies the same acceptance, non-goals, and baseline evidence instead of inventing a second baseline.',
|
|
56
|
-
|
|
57
|
-
'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.',
|
|
58
|
-
|
|
59
|
-
'SUBMIT BY WRITING FLOW_PLAN.json — when the decomposition is ready, use the Write tool with file_path "FLOW_PLAN.json" and content set to your plan as ONE JSON object. The room 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 with the reason as the tool result — fix it and Write FLOW_PLAN.json again. Do not also narrate the plan in prose. The exact shape:',
|
|
60
|
-
'{ "summary": "<one line: what we are building; assumptions: …>", "tasks": [ { "key": "<short-unique-kebab-key>", "title": "<imperative title>", "scope": "<files/components this lane owns>", "acceptance": "<runnable proof it is done>", "nonGoals": ["<bounded exclusion>"], "baseline": { "gate": "<what currently fails/is absent before edits>", "evidence": "<optional observed command/output receipt>" }, "deps": ["<other-task-key>", ...], "sliceType": "feature|scaffold|review|fix" } ] }',
|
|
61
|
-
'`key` values are stable short kebab-case identifiers. `deps` references other tasks\' keys. The content of FLOW_PLAN.json must be ONLY the JSON object (no surrounding prose, no markdown fences).',
|
|
62
|
-
|
|
63
|
-
'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.',
|
|
64
|
-
|
|
65
|
-
'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.',
|
|
66
|
-
].join(' ')
|
|
67
|
-
|
|
68
|
-
export const FLOW_CODEX_CONDUCTOR_PROMPT = [
|
|
69
|
-
'THINKPOOL FLOW CONDUCTOR — you are decomposition-only. Do not build, edit files, run commands, or spawn agents. Read only when needed to make the decomposition concrete.',
|
|
70
|
-
'Turn the request into a small acyclic graph of independently runnable slices with disjoint file ownership. Each builder/fix/scaffold task needs key, title, scope, observable acceptance, bounded nonGoals, and a baseline {gate,evidence?}; the gate states what currently fails or is absent before edits. Every review task depends on exactly ONE builder task and inherits/verifies that builder contract. Review is intelligence-sensitive and must never be assigned a cheap/mechanical tier.',
|
|
71
|
-
'When details are ambiguous or the user says they are unsure, choose a safe reversible default, record it in summary.assumptions text, and continue. Do not ask merely because of uncertainty; ask only when a choice materially expands scope or authority. Honor later user steering.',
|
|
72
|
-
'Submit the finished graph by calling the ThinkPool submit_flow_plan MCP tool with one JSON object encoded in its plan argument. Do not write FLOW_PLAN.json and do not use ExitPlanMode. The MCP submission is your only completion path; after it succeeds, stop and wait for the room.',
|
|
73
|
-
].join(' ')
|
|
74
|
-
|
|
75
|
-
// Env vars handed to a conductor session via startClaudeSession({ env }). The conductor
|
|
76
|
-
// reads these to know which Flow run it owns + what mode the human picked.
|
|
77
|
-
export function buildConductorEnv ({ flowSessionId, mode, budgetCapAutopilot = null }) {
|
|
78
|
-
return {
|
|
79
|
-
TP_FLOW_SESSION: flowSessionId,
|
|
80
|
-
TP_FLOW_MODE: mode,
|
|
81
|
-
...(mode === FLOW_MODE.autopilot && budgetCapAutopilot != null
|
|
82
|
-
? { TP_FLOW_BUDGET_CAP: String(budgetCapAutopilot) }
|
|
83
|
-
: {}),
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Status the FlowSession moves to once the conductor emits + the client validates a
|
|
88
|
-
// plan (i.e. the plan-approval card can now be shown). Kept here so bridge + client
|
|
89
|
-
// import the same transition target.
|
|
90
|
-
export const PLAN_READY_STATUS = FLOW_STATUS.awaiting_approval
|
|
91
|
-
|
|
92
|
-
// ── Lane builder ────────────────────────────────────────────────────────────
|
|
93
|
-
// The conductor DECOMPOSES; a lane BUILDS one slice. Lanes dispatch after the human
|
|
94
|
-
// approves the plan (Step 2). Each lane runs in its own git worktree (disjoint tree,
|
|
95
|
-
// atomic-revert target). This prompt is the lane's rolePrompt (via startClaudeSession).
|
|
96
|
-
export const FLOW_LANE_PROMPT = [
|
|
97
|
-
'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.',
|
|
98
|
-
|
|
99
|
-
'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.',
|
|
100
|
-
|
|
101
|
-
'GATE BEFORE EDITS. First reproduce the assigned baseline gate in the real pre-edit state. Record only what you actually observed (a command/behavior + bounded receipt); never fabricate a RED sentence. Preserve the assigned non-goals. If a bounded detail is ambiguous, take the least-invasive reversible default and state it in your receipt; ask only if the choice would materially expand scope or authority.',
|
|
102
|
-
|
|
103
|
-
'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.',
|
|
104
|
-
|
|
105
|
-
'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) with `git` via the Bash tool; that commit is the revert target if review later rejects it. THEN, as your VERY LAST action, signal completion by using the Write tool to write a file named exactly FLOW_DONE in your worktree root with content `{"baselineEvidence":"<real bounded pre-edit command/behavior receipt>"}`. That single Write IS the done signal — it records your commit + tells the room the slice is done (unblocking dependents). Do NOT use mark_flow_done (it hangs here). Do not push. Do NOT write FLOW_DONE before your slice actually runs, meets acceptance, records real baseline evidence, AND is committed.',
|
|
106
|
-
|
|
107
|
-
'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.',
|
|
108
|
-
|
|
109
|
-
'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.',
|
|
110
|
-
].join(' ')
|
|
111
|
-
|
|
112
|
-
export const FLOW_CODEX_LANE_PROMPT = [
|
|
113
|
-
'THINKPOOL FLOW — you are one builder lane in an ensemble. The conductor gives you one runnable slice with disjoint scope. Edit only that scope and never touch another lane or worktree.',
|
|
114
|
-
'Before editing, reproduce the assigned baseline gate in the real pre-edit state. Preserve the explicit non-goals. Record only an actual bounded command/behavior receipt; never manufacture a RED claim. For a bounded ambiguity, take the least-invasive reversible default and state it in that receipt; ask only if a choice would materially expand scope or authority.',
|
|
115
|
-
'Done means the slice runs and its acceptance proof has been observed. Diagnose before changing code, verify with the real command or behavior, and make one focused git commit in your assigned worktree. Do not push.',
|
|
116
|
-
'After the commit and verification succeed, call the ThinkPool mark_flow_done MCP tool exactly once with the real `baselineEvidence` receipt. Do not write FLOW_DONE. The MCP call is the completion signal that records your commit and unblocks dependent slices.',
|
|
117
|
-
'Do not spawn more lanes. If the scope is unsafe or a dependency is missing, report the blocker instead of editing outside your ownership.',
|
|
118
|
-
].join(' ')
|
|
119
|
-
|
|
120
|
-
// Env vars handed to a lane session so it knows its Flow run + which task it owns.
|
|
121
|
-
export function buildLaneEnv ({ flowSessionId, taskKey, laneId }) {
|
|
122
|
-
return {
|
|
123
|
-
TP_FLOW_SESSION: flowSessionId,
|
|
124
|
-
TP_FLOW_TASK_KEY: taskKey,
|
|
125
|
-
TP_FLOW_LANE_ID: laneId,
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// ── assembleCrossWaveContext ────────────────────────────────────────────────
|
|
130
|
-
// S1 (context-offload) — the conductor's bounded cross-wave context for a dispatch
|
|
131
|
-
// wave. Reads the durable digest store (loadDigest, which enforces the hard CEILING)
|
|
132
|
-
// and renders the closed-slice digests as a compact block a lane can be seeded with:
|
|
133
|
-
// a one-line summary + artifact pointers per closed slice, NOT full transcripts.
|
|
134
|
-
//
|
|
135
|
-
// Before S1, each newly-dispatched lane was told only the NAMES of the slices it
|
|
136
|
-
// depends on (`DEPENDS ON (already built): a, b`) with no content, and the conductor
|
|
137
|
-
// itself would (across many waves) accumulate full lane output in its chat context —
|
|
138
|
-
// the overflow this slice fixes. Now the ordered digest is the ONLY cross-wave carrier,
|
|
139
|
-
// and its size is capped by loadDigest's CEILING guarantee (JSON.stringify(digest)
|
|
140
|
-
// length ≤ CEILING), so the assembled context is bounded across ≥2 waves by construction.
|
|
141
|
-
//
|
|
142
|
-
// Returns { text, digest } — `text` is the ready-to-embed block (empty string when the
|
|
143
|
-
// flow has no closed slices yet), `digest` is the raw bounded array (for tests/assertions).
|
|
144
|
-
// Pure over an injected fs so it's unit-testable against a temp digest store.
|
|
145
|
-
export function assembleCrossWaveContext (flowId, { baseDir, deps = null, fs } = {}) {
|
|
146
|
-
if (!flowId || !baseDir) return { text: '', digest: [] }
|
|
147
|
-
let digest
|
|
148
|
-
try {
|
|
149
|
-
digest = loadDigest(flowId, { baseDir, ...(fs ? { fs } : {}) })
|
|
150
|
-
} catch {
|
|
151
|
-
// A malformed/absent digest store must never wedge dispatch — degrade to no context.
|
|
152
|
-
return { text: '', digest: [] }
|
|
153
|
-
}
|
|
154
|
-
// If the caller named the current slice's deps, prefer them (so a lane sees its
|
|
155
|
-
// dependencies first); otherwise show every closed slice. loadDigest already bounds
|
|
156
|
-
// the whole set under CEILING, so filtering only ever shrinks the block.
|
|
157
|
-
const wanted = Array.isArray(deps) && deps.length ? new Set(deps) : null
|
|
158
|
-
const shown = wanted ? digest.filter((d) => wanted.has(d.taskKey)) : digest
|
|
159
|
-
if (!shown.length) return { text: '', digest }
|
|
160
|
-
const lines = shown.map((d) => {
|
|
161
|
-
const ptrs = (d.pointers || [])
|
|
162
|
-
.map((p) => `${p.name}@${String(p.sha).slice(0, 8)}`)
|
|
163
|
-
.join(', ')
|
|
164
|
-
return ` - ${d.summary}${ptrs ? ` [artifacts: ${ptrs}]` : ''}`
|
|
165
|
-
})
|
|
166
|
-
const text = `CLOSED SLICES (bounded digest — summary + artifact pointers, not full output):\n${lines.join('\n')}`
|
|
167
|
-
return { text, digest }
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// ── Progressive skill loading (S2) ───────────────────────────────────────────
|
|
171
|
-
// A lane boots knowing only the skill MANIFEST (a tiny menu: name + one-line
|
|
172
|
-
// description + whenToUse) — never the bodies. It pulls a full SKILL.md body in only
|
|
173
|
-
// when it actually activates that skill. This is the DeerFlow steal: metadata in the
|
|
174
|
-
// base prompt, body on demand. The token win (manifest ≪ Σ bodies) is the whole point.
|
|
175
|
-
|
|
176
|
-
// Resolve the skill directories a Flow lane draws from. publicDir = built-in
|
|
177
|
-
// product-facing skills; customDir = optional user/room-level overrides. Both come from
|
|
178
|
-
// env so the bridge operator can point them at a real skills tree; absent → undefined,
|
|
179
|
-
// which the registry treats as "no skills" (empty manifest, loadBody clean-misses). This
|
|
180
|
-
// is deliberately NOT the .claude/skills dev-side harness — that stays out of scope.
|
|
181
|
-
export function flowSkillDirs (env = process.env) {
|
|
182
|
-
return {
|
|
183
|
-
publicDir: env.TP_FLOW_SKILLS_DIR || undefined,
|
|
184
|
-
customDir: env.TP_FLOW_SKILLS_CUSTOM_DIR || undefined,
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// ── Absolute manifest ceiling (S2 blocker 2) ─────────────────────────────────
|
|
189
|
-
// The token-win invariant (manifest ≪ Σ bodies) is only a RELATIVE ratio — it does
|
|
190
|
-
// nothing to bound the ABSOLUTE size of the manifest block that lands in EVERY lane's
|
|
191
|
-
// base prompt. Custom skills are user/room-uploadable (H1/S6), so a single skill with a
|
|
192
|
-
// pathological description/whenToUse (a 200KB blob) would balloon the base prompt
|
|
193
|
-
// unbounded. These caps give the ceiling teeth: each field is truncated per-entry, and
|
|
194
|
-
// the whole rendered block is hard-capped, with a visible marker where content is cut.
|
|
195
|
-
export const MANIFEST_MAX_CHARS = 4096 // absolute ceiling on the rendered block
|
|
196
|
-
export const MANIFEST_FIELD_MAX = 240 // per-field (description / whenToUse) cap
|
|
197
|
-
const TRUNC_MARK = '…[truncated]'
|
|
198
|
-
|
|
199
|
-
// Truncate a single metadata field to MANIFEST_FIELD_MAX chars with a visible marker.
|
|
200
|
-
function clampField (s) {
|
|
201
|
-
const str = typeof s === 'string' ? s : ''
|
|
202
|
-
if (str.length <= MANIFEST_FIELD_MAX) return str
|
|
203
|
-
return str.slice(0, MANIFEST_FIELD_MAX - TRUNC_MARK.length) + TRUNC_MARK
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Render the compact manifest as a prompt block — METADATA ONLY. Each entry is one line:
|
|
207
|
-
// the skill name, its one-line description, and (if present) whenToUse. There is NO code
|
|
208
|
-
// path here that can emit a SKILL.md body: this function only ever sees manifest entries
|
|
209
|
-
// (which carry no body), so a body cannot leak into the base prompt through it.
|
|
210
|
-
// Returns '' for an empty manifest so the base prompt is unchanged when no skills exist.
|
|
211
|
-
//
|
|
212
|
-
// ABSOLUTE CEILING: every field is clamped to MANIFEST_FIELD_MAX, and the assembled block
|
|
213
|
-
// is hard-capped at MANIFEST_MAX_CHARS — entries that would push past the ceiling are
|
|
214
|
-
// dropped and a visible marker names how many were omitted. A pathological huge-metadata
|
|
215
|
-
// skill therefore cannot grow the base prompt beyond MANIFEST_MAX_CHARS (+ a small header).
|
|
216
|
-
export function renderSkillManifestBlock (entries) {
|
|
217
|
-
if (!Array.isArray(entries) || entries.length === 0) return ''
|
|
218
|
-
const header = [
|
|
219
|
-
'AVAILABLE SKILLS (menu only — names + descriptions, NOT the instructions). You boot',
|
|
220
|
-
'knowing this menu; you do NOT carry any skill\'s full instructions. When a task matches',
|
|
221
|
-
'a skill (or you invoke it slash-style), the room loads that ONE skill\'s full body for',
|
|
222
|
-
'you on demand — never all of them, never up front. The menu:',
|
|
223
|
-
].join('\n')
|
|
224
|
-
|
|
225
|
-
const rendered = []
|
|
226
|
-
let used = header.length
|
|
227
|
-
let dropped = 0
|
|
228
|
-
for (const e of entries) {
|
|
229
|
-
const when = e.whenToUse ? ` — use when: ${clampField(e.whenToUse)}` : ''
|
|
230
|
-
const line = ` • ${e.name}: ${clampField(e.description)}${when}`
|
|
231
|
-
// +1 for the '\n' joining this line to what precedes it.
|
|
232
|
-
if (used + 1 + line.length > MANIFEST_MAX_CHARS) { dropped++; continue }
|
|
233
|
-
rendered.push(line)
|
|
234
|
-
used += 1 + line.length
|
|
235
|
-
}
|
|
236
|
-
if (dropped > 0) {
|
|
237
|
-
rendered.push(` …[${dropped} more skill(s) omitted — manifest ceiling reached]`)
|
|
238
|
-
}
|
|
239
|
-
return [header, ...rendered].join('\n')
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// Build a lane's base prompt: the static FLOW_LANE_PROMPT (or FLOW_REVIEWER-style base
|
|
243
|
-
// passed in) PLUS the skill MANIFEST block — metadata only, never bodies. This is what a
|
|
244
|
-
// lane session boots with. The token win vs. inlining bodies is asserted in tests.
|
|
245
|
-
// dirs/fs are injectable for unit-testing against temp fixtures; default to env dirs.
|
|
246
|
-
export function buildLanePrompt ({ base = FLOW_LANE_PROMPT, publicDir, customDir, fs } = {}) {
|
|
247
|
-
const dirs = publicDir === undefined && customDir === undefined ? flowSkillDirs() : { publicDir, customDir }
|
|
248
|
-
const entries = skillManifest({ ...dirs, ...(fs ? { fs } : {}) })
|
|
249
|
-
const block = renderSkillManifestBlock(entries)
|
|
250
|
-
return block ? `${base}\n\n${block}` : base
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// Load exactly ONE skill's full body, on demand, when a lane activates it. Returns the
|
|
254
|
-
// SKILL.md body string, or null on a clean miss (unknown / disabled skill — never a
|
|
255
|
-
// throw). Callers inject this into the lane mid-task; it is never part of the base prompt.
|
|
256
|
-
export function activateLaneSkill (name, { publicDir, customDir, fs } = {}) {
|
|
257
|
-
const dirs = publicDir === undefined && customDir === undefined ? flowSkillDirs() : { publicDir, customDir }
|
|
258
|
-
return skillLoadBody(name, { ...dirs, ...(fs ? { fs } : {}) })
|
|
259
|
-
}
|
|
1
|
+
import{FLOW_MODE as e,FLOW_STATUS as t,normalizePlanOutput as o}from"./flow-task-graph.mjs";import{loadDigest as n}from"./flow-context-store.mjs";import{manifest as s,loadBody as i}from"./flow-skill-registry.mjs";export{o as normalizePlanOutput};export const FLOW_CONDUCTOR_PROMPT=["THINKPOOL FLOW CONDUCTOR — HARD RULES (read first, no exceptions): You are a DECOMPOSITION-ONLY conductor. Your ONLY output action is using the Write tool ONCE to write your task-graph JSON to the file named exactly FLOW_PLAN.json. That single Write IS how you submit your plan — the room ingests it directly. Do NOT use ExitPlanMode and do NOT call submit_flow_plan (both hang here). Do NOT write any other file and do NOT write a plan-document markdown. You MUST NOT write or edit files, MUST NOT run bash/git, MUST NOT call AskUserQuestion, and MUST NOT build the project yourself — those are blocked and will just stall you. You do NOT build; you DECOMPOSE so the lanes build. A fully-specified build is STILL decomposed, never built here. When a detail is ambiguous or the user is unsure, choose the least-invasive reversible default, record that assumption in the summary, and continue. Never ask merely because the user is unsure; ask only when a choice would materially expand scope or authority. User steering always wins. Think, then call submit_flow_plan. That is the whole job.","DO NOT SPAWN SUBAGENTS. You MUST NOT use the Task/Agent tool or launch Explore / sub-agents — every subagent spawn pops a permission card and stalls the whole flow (this is the #1 way the conductor hangs asking for permission). You almost never need to look at code to decompose: decompose from the build request itself. If you genuinely must inspect something first, read it YOURSELF with Read/Grep/Glob (those are allowed without a prompt) — never delegate it to a spawned agent. Decompose, then Write FLOW_PLAN.json.","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+).","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.","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.","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.",'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").',"GATE-FIRST CONTRACT per builder/fix/scaffold slice: include an explicit bounded `nonGoals` list (what this slice will not change) and a `baseline` gate describing the real pre-edit condition that must FAIL or be ABSENT. The builder must observe that gate before editing and later supply a bounded evidence receipt; do not invent a RED sentence. A review task must depend on exactly one builder/fix task and inherits that target contract — it verifies the same acceptance, non-goals, and baseline evidence instead of inventing a second baseline.","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.",'SUBMIT BY WRITING FLOW_PLAN.json — when the decomposition is ready, use the Write tool with file_path "FLOW_PLAN.json" and content set to your plan as ONE JSON object. The room 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 with the reason as the tool result — fix it and Write FLOW_PLAN.json again. Do not also narrate the plan in prose. The exact shape:','{ "summary": "<one line: what we are building; assumptions: …>", "tasks": [ { "key": "<short-unique-kebab-key>", "title": "<imperative title>", "scope": "<files/components this lane owns>", "acceptance": "<runnable proof it is done>", "nonGoals": ["<bounded exclusion>"], "baseline": { "gate": "<what currently fails/is absent before edits>", "evidence": "<optional observed command/output receipt>" }, "deps": ["<other-task-key>", ...], "sliceType": "feature|scaffold|review|fix" } ] }',"`key` values are stable short kebab-case identifiers. `deps` references other tasks' keys. The content of FLOW_PLAN.json must be ONLY the JSON object (no surrounding prose, no markdown fences).","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.","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."].join(" ");export const FLOW_CODEX_CONDUCTOR_PROMPT=["THINKPOOL FLOW CONDUCTOR — you are decomposition-only. Do not build, edit files, run commands, or spawn agents. Read only when needed to make the decomposition concrete.","Turn the request into a small acyclic graph of independently runnable slices with disjoint file ownership. Each builder/fix/scaffold task needs key, title, scope, observable acceptance, bounded nonGoals, and a baseline {gate,evidence?}; the gate states what currently fails or is absent before edits. Every review task depends on exactly ONE builder task and inherits/verifies that builder contract. Review is intelligence-sensitive and must never be assigned a cheap/mechanical tier.","When details are ambiguous or the user says they are unsure, choose a safe reversible default, record it in summary.assumptions text, and continue. Do not ask merely because of uncertainty; ask only when a choice materially expands scope or authority. Honor later user steering.","Submit the finished graph by calling the ThinkPool submit_flow_plan MCP tool with one JSON object encoded in its plan argument. Do not write FLOW_PLAN.json and do not use ExitPlanMode. The MCP submission is your only completion path; after it succeeds, stop and wait for the room."].join(" ");export function buildConductorEnv({flowSessionId:t,mode:o,budgetCapAutopilot:n=null}){return{TP_FLOW_SESSION:t,TP_FLOW_MODE:o,...o===e.autopilot&&null!=n?{TP_FLOW_BUDGET_CAP:String(n)}:{}}}export const PLAN_READY_STATUS=t.awaiting_approval;export const FLOW_LANE_PROMPT=["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.","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.","GATE BEFORE EDITS. First reproduce the assigned baseline gate in the real pre-edit state. Record only what you actually observed (a command/behavior + bounded receipt); never fabricate a RED sentence. Preserve the assigned non-goals. If a bounded detail is ambiguous, take the least-invasive reversible default and state it in your receipt; ask only if the choice would materially expand scope or authority.","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.",'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) with `git` via the Bash tool; that commit is the revert target if review later rejects it. THEN, as your VERY LAST action, signal completion by using the Write tool to write a file named exactly FLOW_DONE in your worktree root with content `{"baselineEvidence":"<real bounded pre-edit command/behavior receipt>"}`. That single Write IS the done signal — it records your commit + tells the room the slice is done (unblocking dependents). Do NOT use mark_flow_done (it hangs here). Do not push. Do NOT write FLOW_DONE before your slice actually runs, meets acceptance, records real baseline evidence, AND is committed.',"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.","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."].join(" ");export const FLOW_CODEX_LANE_PROMPT=["THINKPOOL FLOW — you are one builder lane in an ensemble. The conductor gives you one runnable slice with disjoint scope. Edit only that scope and never touch another lane or worktree.","Before editing, reproduce the assigned baseline gate in the real pre-edit state. Preserve the explicit non-goals. Record only an actual bounded command/behavior receipt; never manufacture a RED claim. For a bounded ambiguity, take the least-invasive reversible default and state it in that receipt; ask only if a choice would materially expand scope or authority.","Done means the slice runs and its acceptance proof has been observed. Diagnose before changing code, verify with the real command or behavior, and make one focused git commit in your assigned worktree. Do not push.","After the commit and verification succeed, call the ThinkPool mark_flow_done MCP tool exactly once with the real `baselineEvidence` receipt. Do not write FLOW_DONE. The MCP call is the completion signal that records your commit and unblocks dependent slices.","Do not spawn more lanes. If the scope is unsafe or a dependency is missing, report the blocker instead of editing outside your ownership."].join(" ");export function buildLaneEnv({flowSessionId:e,taskKey:t,laneId:o}){return{TP_FLOW_SESSION:e,TP_FLOW_TASK_KEY:t,TP_FLOW_LANE_ID:o}}export function assembleCrossWaveContext(e,{baseDir:t,deps:o=null,fs:s}={}){if(!e||!t)return{text:"",digest:[]};let i;try{i=n(e,{baseDir:t,...s?{fs:s}:{}})}catch{return{text:"",digest:[]}}const a=Array.isArray(o)&&o.length?new Set(o):null,r=a?i.filter(e=>a.has(e.taskKey)):i;return r.length?{text:`CLOSED SLICES (bounded digest — summary + artifact pointers, not full output):\n${r.map(e=>{const t=(e.pointers||[]).map(e=>`${e.name}@${String(e.sha).slice(0,8)}`).join(", ");return` - ${e.summary}${t?` [artifacts: ${t}]`:""}`}).join("\n")}`,digest:i}:{text:"",digest:i}}export function flowSkillDirs(e=process.env){return{publicDir:e.TP_FLOW_SKILLS_DIR||void 0,customDir:e.TP_FLOW_SKILLS_CUSTOM_DIR||void 0}}export const MANIFEST_MAX_CHARS=4096;export const MANIFEST_FIELD_MAX=240;function a(e){const t="string"==typeof e?e:"";return t.length<=240?t:t.slice(0,228)+"…[truncated]"}export function renderSkillManifestBlock(e){if(!Array.isArray(e)||0===e.length)return"";const t=["AVAILABLE SKILLS (menu only — names + descriptions, NOT the instructions). You boot","knowing this menu; you do NOT carry any skill's full instructions. When a task matches","a skill (or you invoke it slash-style), the room loads that ONE skill's full body for","you on demand — never all of them, never up front. The menu:"].join("\n"),o=[];let n=t.length,s=0;for(const t of e){const e=t.whenToUse?` — use when: ${a(t.whenToUse)}`:"",i=` • ${t.name}: ${a(t.description)}${e}`;n+1+i.length>4096?s++:(o.push(i),n+=1+i.length)}return s>0&&o.push(` …[${s} more skill(s) omitted — manifest ceiling reached]`),[t,...o].join("\n")}export function buildLanePrompt({base:e=FLOW_LANE_PROMPT,publicDir:t,customDir:o,fs:n}={}){const i=void 0===t&&void 0===o?flowSkillDirs():{publicDir:t,customDir:o},a=renderSkillManifestBlock(s({...i,...n?{fs:n}:{}}));return a?`${e}\n\n${a}`:e}export function activateLaneSkill(e,{publicDir:t,customDir:o,fs:n}={}){const s=void 0===t&&void 0===o?flowSkillDirs():{publicDir:t,customDir:o};return i(e,{...s,...n?{fs:n}:{}})}
|