thinkpool-pair 0.7.112 → 0.7.114
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 +136 -6
- package/claude-session.mjs +34 -2
- package/flow-conductor.mjs +146 -0
- package/flow-context-store.mjs +387 -0
- package/flow-redispatch.mjs +72 -0
- package/flow-review-gate.mjs +103 -3
- package/flow-skill-registry.mjs +140 -0
- package/package.json +4 -1
package/bridge.mjs
CHANGED
|
@@ -39,8 +39,12 @@ import { createClient } from '@supabase/supabase-js'
|
|
|
39
39
|
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
|
|
40
40
|
import { z } from 'zod'
|
|
41
41
|
import { startClaudeSession } from './claude-session.mjs'
|
|
42
|
-
import { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, buildConductorEnv } from './flow-conductor.mjs'
|
|
42
|
+
import { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, buildConductorEnv, assembleCrossWaveContext, buildLanePrompt } from './flow-conductor.mjs'
|
|
43
43
|
import { normalizePlanOutput } from './flow-task-graph.mjs' // FL-B1 — validate the conductor's submit_flow_plan task-graph
|
|
44
|
+
// S1 (context-offload) — durable digest store. mark_flow_done digests a closed slice in;
|
|
45
|
+
// the dispatch loop reads the bounded cross-wave context back out (via assembleCrossWaveContext,
|
|
46
|
+
// which enforces the CEILING). Consume the store — the internals live in flow-context-store.mjs.
|
|
47
|
+
import { writeLaneArtifact, digestSlice, appendDigest, resumeLane } from './flow-context-store.mjs'
|
|
44
48
|
import { createFlowWorktree, worktreeSpec } from './flow-worktree.mjs'
|
|
45
49
|
import { startPreview, stopAllPreviews, previews } from './flow-preview.mjs'
|
|
46
50
|
// FL-M9 — per-lane preview servers leak (one per done lane, never stopped until shutdown).
|
|
@@ -53,8 +57,24 @@ function stopFlowPreviews (flowId, laneId = null) {
|
|
|
53
57
|
}
|
|
54
58
|
}
|
|
55
59
|
import { FLOW_REVIEWER_PROMPT, revertLane, parseReviewVerdict } from './flow-review.mjs'
|
|
60
|
+
import { reviewGateDecision } from './flow-review-gate.mjs'
|
|
56
61
|
import { mergeWorktrees, inlineSingleHtml, initRepo } from './flow-assembly.mjs'
|
|
57
62
|
import { canDispatch, FLOW_LIMITS, makeBudget, recordSpend, killSwitchEnv } from './flow-budget.mjs'
|
|
63
|
+
// S4 slice 2 — clean re-dispatch. When a lane is killed mid-tool-call (budget/review/restart)
|
|
64
|
+
// and re-dispatched, heal its transcript (Heal-3 consumer, no dangling tool_use → no 400),
|
|
65
|
+
// resume the healed session, and keep its revert target. flowRedispatch (flowId::taskKey →
|
|
66
|
+
// { resumeSessionId, revertTarget }) bridges the flow-revert kill to the next flow-dispatch
|
|
67
|
+
// wave. Re-dispatch opens NO per-lane realtime channel — it reuses the room-wide tpflow
|
|
68
|
+
// broadcast, so no duplicate-subscribe collision is possible (H41; see flow-redispatch.mjs).
|
|
69
|
+
// Spec: docs/specs/2026-06-30-flow-build-s4-clean-redispatch.md
|
|
70
|
+
import { prepareRedispatch, redispatchKey } from './flow-redispatch.mjs'
|
|
71
|
+
import { sanitizeSession } from './transcript-sanitize.mjs'
|
|
72
|
+
// ACCEPTED LIMITATION — this registry is in-memory only. A bridge restart in the window between
|
|
73
|
+
// a flow-revert kill and the next dispatch wave loses the pending resume record, so the task
|
|
74
|
+
// re-dispatches COLD (fresh lane, no resume). That's safe: the healed transcript survives on
|
|
75
|
+
// disk (sanitizeSession wrote it), so a cold re-dispatch can't 400 on a dangling tool_use — it
|
|
76
|
+
// just loses the resume/revert-target carry. Not worth persisting for that narrow window.
|
|
77
|
+
const flowRedispatch = new Map()
|
|
58
78
|
// FL-B2 — per-flow HARD budget ledger (flowId → budget). Accumulates each lane's
|
|
59
79
|
// completed-turn output tokens (onEvent 'result') so the autopilot cap stops the next
|
|
60
80
|
// wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
|
|
@@ -1117,7 +1137,7 @@ function restoredTurnOpen(log) {
|
|
|
1117
1137
|
// relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
|
|
1118
1138
|
// persist to the host file; tool calls round-trip through the perm card; the
|
|
1119
1139
|
// rolling log replays to joiners and survives bridge restarts (session-store).
|
|
1120
|
-
function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd }) {
|
|
1140
|
+
function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots }) {
|
|
1121
1141
|
if (sessions.has(id)) return
|
|
1122
1142
|
// spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
|
|
1123
1143
|
// session store so the Ensemble flag survives a bridge restart (else a respin
|
|
@@ -1129,6 +1149,10 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1129
1149
|
// event-id.mjs + src/pages/code/seqDedup.js). pushLog() is the single stamp point.
|
|
1130
1150
|
entry.seq = makeSeqCounter(maxSeq(entry.log))
|
|
1131
1151
|
entry.rolePrompt = rolePrompt || null // FL-M6 — persisted so a bridge restart restores the flow role prompt
|
|
1152
|
+
// S5 (slice 1b) — a REVIEW lane's write-block scope: the worktree root(s) of the slice(s)
|
|
1153
|
+
// it reviews (its deps). Persisted so a bridge restart re-arms the structural gate. Empty/
|
|
1154
|
+
// absent on builder lanes → reviewGate stays null → builder toolset UNCHANGED.
|
|
1155
|
+
entry.reviewSliceRoots = Array.isArray(reviewSliceRoots) ? reviewSliceRoots.filter(Boolean) : []
|
|
1132
1156
|
// FL-B1 (lane side) — record a flow lane's slice as done. Shared by the mark_flow_done
|
|
1133
1157
|
// MCP tool AND the FLOW_DONE sentinel-Write intercept: like the conductor's submit, the
|
|
1134
1158
|
// MCP tool is DEFERRED (needs ToolSearch, which hangs), so lanes signal done by Writing a
|
|
@@ -1140,6 +1164,33 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1140
1164
|
try { commitSha = execFileSync('git', ['-C', entry.cwd || process.cwd(), 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() } catch { /* no commits yet */ }
|
|
1141
1165
|
let previewUrl = null
|
|
1142
1166
|
try { const pv = await startPreview({ dir: entry.cwd || process.cwd(), id: `lane:${entry.flowSessionId}:${id}` }); previewUrl = pv.url } catch { /* preview best-effort */ }
|
|
1167
|
+
// S1 (context-offload) — digest THIS closed slice into the durable store so the next
|
|
1168
|
+
// dispatch wave reads a bounded summary + pointers for it (never the full transcript).
|
|
1169
|
+
// Best-effort + fully guarded: a store failure must never block the done signal.
|
|
1170
|
+
try {
|
|
1171
|
+
const worktreeDir = entry.cwd || process.cwd()
|
|
1172
|
+
// Every closed slice must carry ≥1 pointer (appendDigest's zero-pointer guard), so
|
|
1173
|
+
// record a synthetic done-marker artifact in the lane's OWN worktree (content-addressed,
|
|
1174
|
+
// idempotent — a re-dispatched lane re-writing it is a no-op). Then fold in any real
|
|
1175
|
+
// artifacts the lane wrote under .flow/artifacts/ so a resume/peer can find them.
|
|
1176
|
+
const marker = writeLaneArtifact(
|
|
1177
|
+
entry.flowSessionId, entry.flowTaskKey, '_slice.done',
|
|
1178
|
+
JSON.stringify({ taskKey: entry.flowTaskKey, commitSha: commitSha || null, previewUrl: previewUrl || null }),
|
|
1179
|
+
{ worktreeDir },
|
|
1180
|
+
)
|
|
1181
|
+
const laneArtifacts = resumeLane(entry.flowSessionId, { worktreeDir })
|
|
1182
|
+
const artifacts = [
|
|
1183
|
+
{ taskKey: entry.flowTaskKey, name: '_slice.done', path: marker.path, sha: marker.sha },
|
|
1184
|
+
...laneArtifacts.map((a) => ({ taskKey: a.taskKey, name: a.name, path: a.path, sha: a.sha })),
|
|
1185
|
+
]
|
|
1186
|
+
const digest = digestSlice(
|
|
1187
|
+
{ key: entry.flowTaskKey, title: termNames[id] || entry.flowTaskKey },
|
|
1188
|
+
{ acceptanceProof: commitSha ? `committed ${commitSha.slice(0, 8)}` : 'slice done', artifacts },
|
|
1189
|
+
)
|
|
1190
|
+
appendDigest(entry.flowSessionId, digest, { baseDir: process.cwd() }) // idempotent per (flowId, taskKey)
|
|
1191
|
+
} catch (e) {
|
|
1192
|
+
process.stderr.write(`\n ${A.dim}◆ flow digest skip ${entry.flowTaskKey} — ${e?.message || e}${A.rst}\n`)
|
|
1193
|
+
}
|
|
1143
1194
|
bcast('flow-task-done', { term: id, flowId: entry.flowSessionId, taskKey: entry.flowTaskKey, laneId: id, commitSha, previewUrl }, flowChannel)
|
|
1144
1195
|
process.stderr.write(`\n ${A.cyan}◆ flow slice done — ${entry.flowTaskKey} (${commitSha ? commitSha.slice(0, 8) : 'no commit'})${previewUrl ? ` · preview ${previewUrl}` : ''}${A.rst}\n`)
|
|
1145
1196
|
entry.flowDone = true // FL-B3 — retire immediately so it drops from the ≤8 lane cap
|
|
@@ -1204,7 +1255,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1204
1255
|
// subagent-block; without cwd it runs outside its worktree; without rolePrompt it loses its
|
|
1205
1256
|
// Flow role. blockSubagents / onLaneDone / onReviewVerdict / onSubmitPlan all derive from
|
|
1206
1257
|
// these, so restoring them restores the whole behavior.
|
|
1207
|
-
const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands, mode: entry.mode, spawnedBy: entry.spawnedBy, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, cwd: entry.cwd, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null })
|
|
1258
|
+
const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands, mode: entry.mode, spawnedBy: entry.spawnedBy, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, cwd: entry.cwd, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [] })
|
|
1208
1259
|
const persist = () => saveSession(room, id, sessionData())
|
|
1209
1260
|
// Synchronous flush of this session's record. Used on open (so a brand-new session
|
|
1210
1261
|
// has a file under its id BEFORE its first event — surviving a restart inside the
|
|
@@ -1470,8 +1521,24 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1470
1521
|
),
|
|
1471
1522
|
],
|
|
1472
1523
|
})
|
|
1524
|
+
// S5 (slice 1b) — the review-lane write-block, wired into the live PreToolUse path.
|
|
1525
|
+
// Null for builder lanes (no reviewSliceRoots) → their toolset is UNCHANGED. For a review
|
|
1526
|
+
// lane, deny a mutating tool call if it targets ANY reviewed slice's worktree, allowing the
|
|
1527
|
+
// reviewer's own FLOW_REVIEW.json (verdictFile) + all reads + run/test Bash. reviewGateDecision
|
|
1528
|
+
// takes ONE sliceRoot, so we fold over every reviewed slice: deny on the first that denies.
|
|
1529
|
+
const reviewVerdictFile = path.join(cwd || process.cwd(), 'FLOW_REVIEW.json')
|
|
1530
|
+
const reviewGate = (entry.reviewSliceRoots.length)
|
|
1531
|
+
? ({ toolName, input }) => {
|
|
1532
|
+
for (const sliceRoot of entry.reviewSliceRoots) {
|
|
1533
|
+
const d = reviewGateDecision({ toolName, input, sliceRoot, verdictFile: reviewVerdictFile })
|
|
1534
|
+
if (!d.allow) return d
|
|
1535
|
+
}
|
|
1536
|
+
return { allow: true, reason: 'no reviewed slice targeted' }
|
|
1537
|
+
}
|
|
1538
|
+
: null
|
|
1473
1539
|
entry.session = startClaudeSession({
|
|
1474
1540
|
cwd: cwd || process.cwd(), model, resume, mode, rolePrompt,
|
|
1541
|
+
reviewGate,
|
|
1475
1542
|
// A Flow CONDUCTOR (flowSessionId set, no flowTaskKey) must DECOMPOSE, not explore via
|
|
1476
1543
|
// a fan-out of Task/Explore subagents, and must not build. Hard-block Task/Bash/Write for
|
|
1477
1544
|
// conductors so it reads with Read/Grep/Glob and submits via the FLOW_PLAN.json sentinel.
|
|
@@ -2018,9 +2085,11 @@ channel
|
|
|
2018
2085
|
// FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
|
|
2019
2086
|
// bridge restart: the conductor keeps its subagent-block + plan interception, and
|
|
2020
2087
|
// lanes keep their worktree cwd + the ability to mark done.
|
|
2021
|
-
openStructured({ id: rec.id, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, cwd: rec.cwd, rolePrompt: rec.rolePrompt })
|
|
2088
|
+
openStructured({ id: rec.id, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, cwd: rec.cwd, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots })
|
|
2022
2089
|
const re = sessions.get(rec.id)
|
|
2023
2090
|
if (re && rec.flowReviewTarget) re.flowReviewTarget = rec.flowReviewTarget
|
|
2091
|
+
// S4 — a resumed re-dispatch lane keeps its surviving revert target across a bridge restart.
|
|
2092
|
+
if (re && rec.revertTarget) re.revertTarget = rec.revertTarget
|
|
2024
2093
|
// FL-M6 — a flow LANE whose turn had already ENDED before the restart finished its
|
|
2025
2094
|
// slice, but its done-signal was lost while the bridge was down (resume won't re-run
|
|
2026
2095
|
// an ended turn, so it would sit idle → the flow stalls forever). Reconcile: re-record
|
|
@@ -2131,16 +2200,53 @@ flowChannel
|
|
|
2131
2200
|
const laneId = randomUUID()
|
|
2132
2201
|
termNames[laneId] = `Flow · ${t.task_key}`
|
|
2133
2202
|
saveNames(room, termNames)
|
|
2203
|
+
// S4 slice 2 — clean re-dispatch. If this task was force-killed mid-tool-call on an
|
|
2204
|
+
// earlier wave, flow-revert left a pending record (its transcript already Heal-3'd, its
|
|
2205
|
+
// resume sessionId + revert target captured). CONSUME it here: resume the healed session
|
|
2206
|
+
// (no dangling tool_use → no 400) instead of a cold start, and carry the revert target
|
|
2207
|
+
// forward (revertLane deleted the branch, so this is the only survivor). Delete the record
|
|
2208
|
+
// on consume so a re-broadcast can't double-resume (idempotent).
|
|
2209
|
+
// H41: the resumed lane opens no realtime channel of its own — it reuses the room-wide
|
|
2210
|
+
// tpflow broadcast channel (opened once at startup), so no duplicate-subscribe collision
|
|
2211
|
+
// is possible. There is no per-lane topic to compute or stamp.
|
|
2212
|
+
const rk = redispatchKey(payload.flowId, t.task_key)
|
|
2213
|
+
const redispatch = flowRedispatch.get(rk) || null
|
|
2214
|
+
if (redispatch) flowRedispatch.delete(rk)
|
|
2215
|
+
// S5 (slice 1b) — a REVIEW lane reviews the slices it DEPENDS ON. Its write-block scope
|
|
2216
|
+
// is those slices' worktree roots; the lane runs in its OWN worktree (dir above), so its
|
|
2217
|
+
// reads/runs/tests + verdict Write are unaffected — only writes INTO a reviewed slice are
|
|
2218
|
+
// denied. Builder lanes get [] → reviewGate stays null → their toolset is UNCHANGED.
|
|
2219
|
+
const reviewSliceRoots = isReview
|
|
2220
|
+
? (t.deps || []).map((dep) => worktreeSpec({ flowId: payload.flowId, taskKey: dep }).dir)
|
|
2221
|
+
: []
|
|
2134
2222
|
// Lanes build autonomously in their own worktree — bypassPermissions so they
|
|
2135
2223
|
// don't stall on a card for every write/bash (matches the user's expectation that
|
|
2136
2224
|
// a Flow summoned from a bypass terminal runs hands-off).
|
|
2137
|
-
|
|
2225
|
+
// S2 — build the lane's base prompt from the static role prompt PLUS the skill
|
|
2226
|
+
// MANIFEST (metadata only, never bodies; absolute-ceiling capped). buildLanePrompt
|
|
2227
|
+
// reads the skills tree from env (TP_FLOW_SKILLS_DIR / _CUSTOM_DIR) — no tree →
|
|
2228
|
+
// empty manifest → the base prompt is returned unchanged. A body only ever enters
|
|
2229
|
+
// a lane later, on demand, via activateLaneSkill — never the base prompt here.
|
|
2230
|
+
// S4 — resume: on a re-dispatch, replay the killed lane's HEALED transcript (Heal-3'd,
|
|
2231
|
+
// no dangling tool_use → no 400) instead of a cold start; undefined for a fresh lane.
|
|
2232
|
+
const laneBase = isReview ? FLOW_REVIEWER_PROMPT : FLOW_LANE_PROMPT
|
|
2233
|
+
const laneRolePrompt = buildLanePrompt({ base: laneBase })
|
|
2234
|
+
openStructured({ id: laneId, cwd: dir, mode: 'bypassPermissions', rolePrompt: laneRolePrompt, flowSessionId: payload.flowId, flowTaskKey: t.task_key, spawnedBy: `flow:${payload.flowId}`, resume: redispatch?.resumeSessionId || undefined, reviewSliceRoots })
|
|
2138
2235
|
const le = sessions.get(laneId)
|
|
2139
2236
|
if (le) {
|
|
2237
|
+
// S4 — stamp the surviving revert target on the resumed lane so a later reviewer still
|
|
2238
|
+
// knows what to roll back to (revertLane deleted the branch, so this carry is the only
|
|
2239
|
+
// survivor). No per-lane topic: the lane rides the room-wide tpflow broadcast (H41).
|
|
2240
|
+
if (redispatch) {
|
|
2241
|
+
le.revertTarget = redispatch.revertTarget ?? null
|
|
2242
|
+
}
|
|
2140
2243
|
// FL-B4 — a review slice reviews the slices it DEPENDS ON; remember the primary
|
|
2141
2244
|
// target so a FAIL verdict knows what to revert even if the model omits taskKey.
|
|
2142
2245
|
if (isReview) le.flowReviewTarget = (t.deps && t.deps[0]) || null
|
|
2143
|
-
|
|
2246
|
+
// A resumed lane already carries its work in the healed transcript — re-sending the
|
|
2247
|
+
// full build spec would restart it from scratch, discarding the resume. Only seed the
|
|
2248
|
+
// slice spec on a FRESH lane; a resumed lane picks up where it was killed.
|
|
2249
|
+
if (le.session && !redispatch) {
|
|
2144
2250
|
const spec = isReview
|
|
2145
2251
|
? `[Flow REVIEW lane — slice "${t.task_key}" of flow ${String(payload.flowId).slice(0, 8)}]\n` +
|
|
2146
2252
|
`You are ADVERSARIALLY reviewing the slice(s): ${(t.deps || []).join(', ') || t.title}\n` +
|
|
@@ -2156,6 +2262,11 @@ flowChannel
|
|
|
2156
2262
|
`SCOPE (files you OWN — edit ONLY these): ${t.scope || '(none stated)'}\n` +
|
|
2157
2263
|
`ACCEPTANCE (done = this runs + proves it): ${t.acceptance || '(meet the title)'}\n` +
|
|
2158
2264
|
(t.deps && t.deps.length ? `DEPENDS ON (already built): ${t.deps.join(', ')}\n` : '') +
|
|
2265
|
+
// S1 (context-offload) — inject the BOUNDED cross-wave context (loadDigest
|
|
2266
|
+
// enforces CEILING) instead of accumulating full lane transcripts. Filter to
|
|
2267
|
+
// this slice's deps so a lane sees its dependencies' closed digests first.
|
|
2268
|
+
((crossWave) => crossWave ? `${crossWave}\n` : '')(
|
|
2269
|
+
assembleCrossWaveContext(payload.flowId, { baseDir: process.cwd(), deps: t.deps }).text) +
|
|
2159
2270
|
`\nProject (context): ${payload.flowPrompt || ''}\n\n` +
|
|
2160
2271
|
`Build your slice. Own only your files. Done = it runs + meets acceptance. Commit when done.`
|
|
2161
2272
|
try { le.session.sendTurn(spec) } catch { /* session still starting */ }
|
|
@@ -2217,6 +2328,25 @@ flowChannel
|
|
|
2217
2328
|
let killed = false
|
|
2218
2329
|
for (const [sid, e] of sessions) {
|
|
2219
2330
|
if (e.flowSessionId === payload.flowId && e.flowTaskKey === payload.taskKey && !e.flowDone) {
|
|
2331
|
+
// S4 slice 2 — prepare a CLEAN re-dispatch BEFORE we kill the lane + remove its
|
|
2332
|
+
// worktree. prepareRedispatch heals the transcript (Heal-3 consumer — a dangling
|
|
2333
|
+
// tool_use from a mid-tool-call kill gets a synthetic tool_result so a resume can't
|
|
2334
|
+
// 400) and captures the resume sessionId + the revert target (which revertLane's
|
|
2335
|
+
// branch delete would otherwise destroy). We record it under flowId::taskKey so the
|
|
2336
|
+
// next flow-dispatch wave resumes the healed session. Idempotent: healing an
|
|
2337
|
+
// already-clean transcript is a no-op. No per-lane topic is captured — the resumed
|
|
2338
|
+
// lane reuses the room-wide tpflow broadcast, so there is no H41 collision to guard.
|
|
2339
|
+
try {
|
|
2340
|
+
const prep = prepareRedispatch({
|
|
2341
|
+
lane: { cwd: e.cwd, session: e.session, commitSha: e.commitSha, revertTarget: payload.commitSha ?? e.commitSha ?? null },
|
|
2342
|
+
sanitize: sanitizeSession,
|
|
2343
|
+
})
|
|
2344
|
+
flowRedispatch.set(redispatchKey(payload.flowId, payload.taskKey), {
|
|
2345
|
+
resumeSessionId: prep.resumeSessionId,
|
|
2346
|
+
revertTarget: prep.revertTarget,
|
|
2347
|
+
})
|
|
2348
|
+
if (prep.healed) process.stderr.write(`\n ${A.dim}◆ flow re-dispatch heal — ${payload.taskKey}: ${prep.healed} dangling tool block(s) healed before resume.${A.rst}\n`)
|
|
2349
|
+
} catch (err) { process.stderr.write(`\n ${A.yel}◆ flow re-dispatch prep failed (${payload.taskKey}): ${err?.message || err} — will re-dispatch fresh.${A.rst}\n`) }
|
|
2220
2350
|
e.flowDone = true
|
|
2221
2351
|
try { endStructured(sid) } catch { /* already gone */ }
|
|
2222
2352
|
stopFlowPreviews(payload.flowId, sid) // FL-M9 — drop the reverted lane's preview
|
package/claude-session.mjs
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { randomUUID } from 'node:crypto'
|
|
16
16
|
import { query } from '@anthropic-ai/claude-agent-sdk'
|
|
17
17
|
import { sanitizeSession } from './transcript-sanitize.mjs'
|
|
18
|
+
import { reviewGatePreToolDecision } from './flow-review-gate.mjs'
|
|
18
19
|
|
|
19
20
|
// ── risk classification — the accent/danger tier of the permission card ──
|
|
20
21
|
// low (read-only) · medium (writes/runs) · network (leaves the machine) ·
|
|
@@ -120,7 +121,25 @@ const simplifyBlocks = (blocks = []) => blocks.map((b) => {
|
|
|
120
121
|
*/
|
|
121
122
|
const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
|
|
122
123
|
|
|
123
|
-
|
|
124
|
+
// Per-turn salience reminder. The full ThinkPool Code ruleset lives in
|
|
125
|
+
// `appendSystemPrompt` (baked once at session start), but on a long session — heavy
|
|
126
|
+
// tool output, and especially after auto-compaction — the model's attention drifts
|
|
127
|
+
// off a system prompt that sits behind the large host CLAUDE.md. The classic tell:
|
|
128
|
+
// the agent hands the room a host-local path (`open …`, "it's in ~/claude-shots/")
|
|
129
|
+
// or narrates instead of showing, forgetting it's driven from a phone. So we re-state
|
|
130
|
+
// the highest-drift rules as a compact <system-reminder> appended to EVERY user turn,
|
|
131
|
+
// adjacent to where the model's attention actually is — the same trick the host
|
|
132
|
+
// harness uses to keep CLAUDE.md alive. ~120 tokens/turn; changes nothing but salience.
|
|
133
|
+
const TP_ROOM_REMINDER = [
|
|
134
|
+
'You are Claude in a ThinkPool Code room, driven live from a phone or browser — NOT a local terminal. Keep using the room\'s features.',
|
|
135
|
+
'ARTIFACTS: never hand the room a local path, a file:// URL, a localhost address, an `open …`/Preview reference, or "it\'s in ~/claude-shots/" — the room cannot see the host. Surface every image by reading the PNG into your turn so it renders inline; for HTML use the $TP_MOCKUP_OUTBOX render helper (inline desktop+mobile card) and/or publish to a GitHub Pages URL and post that link.',
|
|
136
|
+
'SHOW, don\'t narrate: a picture of the result beats a wall of text on a phone — err toward more screenshots.',
|
|
137
|
+
'ENSEMBLE: fan parallel work out with spawn_terminal (your own fresh lanes), coordinate via read_terminal / list_sessions; never hijack a busy sibling.',
|
|
138
|
+
'FLOW / PLANS: write plans as normal chat messages as you form them — the room does not surface plan files or ExitPlanMode text.',
|
|
139
|
+
'Verify before claiming done — show runtime evidence you produced, not "should work, go test it".',
|
|
140
|
+
].join(' ')
|
|
141
|
+
|
|
142
|
+
export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null }) {
|
|
124
143
|
const ac = new AbortController()
|
|
125
144
|
let input = makeInputStream() // `let`: auto-restart swaps in a fresh stream
|
|
126
145
|
let sessionId = resume || null
|
|
@@ -297,6 +316,19 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
297
316
|
try { res = (await onLaneDone()) || res } catch (e) { res = { ok: false, message: `done signal failed: ${e?.message || e}` } }
|
|
298
317
|
return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: res.message } }
|
|
299
318
|
}
|
|
319
|
+
// S5 (slice 1b) — REVIEW-LANE WRITE-BLOCK. A review lane is ADVERSARIAL: it reads +
|
|
320
|
+
// runs/tests the slice(s) it reviews and emits a verdict, but it must be STRUCTURALLY
|
|
321
|
+
// unable to Edit/Write/commit the code under review (a reviewer that edits is a second
|
|
322
|
+
// builder → a false PASS). `reviewGate` is set ONLY on review lanes (bridge.mjs wires it
|
|
323
|
+
// to reviewGateDecision over the reviewed slices' worktree roots + the lane's own
|
|
324
|
+
// FLOW_REVIEW.json). It runs AFTER the FLOW_REVIEW.json verdict intercept above, so the
|
|
325
|
+
// reviewer can still emit its verdict, and BEFORE the normal auto-allow path, so the deny
|
|
326
|
+
// is structural even in the lane's bypassPermissions mode. Read + run/test stay allowed
|
|
327
|
+
// (the gate allows read-only tools + non-write Bash — the reviewer's whole method). The
|
|
328
|
+
// real mitigation for Bash-quoting bypasses is worktree isolation (the review lane runs
|
|
329
|
+
// in its OWN worktree, not the slice's) — guaranteed by the spawn path.
|
|
330
|
+
const reviewDeny = reviewGatePreToolDecision({ reviewGate, toolName, toolInput })
|
|
331
|
+
if (reviewDeny) return reviewDeny
|
|
300
332
|
// ThinkPool cross-terminal READ (B2) is read-only + within-room — never
|
|
301
333
|
// prompt, in any mode. It still surfaces as a tool card so the room sees the peek.
|
|
302
334
|
if (toolName === 'mcp__thinkpool__read_terminal') {
|
|
@@ -722,7 +754,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
722
754
|
runQuery()
|
|
723
755
|
|
|
724
756
|
return {
|
|
725
|
-
sendTurn(text) { if (!closed) { turnActive = true; sawSuggestion = false; if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } lastEvtTs = Date.now(); stalledSent = false; input.push(String(text)) } },
|
|
757
|
+
sendTurn(text) { if (!closed) { turnActive = true; sawSuggestion = false; if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } lastEvtTs = Date.now(); stalledSent = false; input.push([{ type: 'text', text: String(text) }, { type: 'text', text: `<system-reminder>\n${TP_ROOM_REMINDER}\n</system-reminder>` }]) } },
|
|
726
758
|
// Set the permission mode — Claude Code's ⇧⇥ cycle. setPermissionMode is a
|
|
727
759
|
// streaming control request (drives plan-mode behaviour SDK-side); the local
|
|
728
760
|
// `mode` drives our PreToolUse auto-approve policy. Echo so the room syncs.
|
package/flow-conductor.mjs
CHANGED
|
@@ -14,6 +14,20 @@
|
|
|
14
14
|
// it drifts from the canonical src module). One logical source, two reachable copies.
|
|
15
15
|
import { FLOW_MODE, FLOW_STATUS, normalizePlanOutput } from './flow-task-graph.mjs'
|
|
16
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
|
+
|
|
17
31
|
// Re-export the shared plan normalizer so bridge callers import Flow pieces from one
|
|
18
32
|
// place. The implementation lives in the shared model (src/lib/flow/taskGraph.js).
|
|
19
33
|
export { normalizePlanOutput }
|
|
@@ -93,3 +107,135 @@ export function buildLaneEnv ({ flowSessionId, taskKey, laneId }) {
|
|
|
93
107
|
}
|
|
94
108
|
}
|
|
95
109
|
|
|
110
|
+
// ── assembleCrossWaveContext ────────────────────────────────────────────────
|
|
111
|
+
// S1 (context-offload) — the conductor's bounded cross-wave context for a dispatch
|
|
112
|
+
// wave. Reads the durable digest store (loadDigest, which enforces the hard CEILING)
|
|
113
|
+
// and renders the closed-slice digests as a compact block a lane can be seeded with:
|
|
114
|
+
// a one-line summary + artifact pointers per closed slice, NOT full transcripts.
|
|
115
|
+
//
|
|
116
|
+
// Before S1, each newly-dispatched lane was told only the NAMES of the slices it
|
|
117
|
+
// depends on (`DEPENDS ON (already built): a, b`) with no content, and the conductor
|
|
118
|
+
// itself would (across many waves) accumulate full lane output in its chat context —
|
|
119
|
+
// the overflow this slice fixes. Now the ordered digest is the ONLY cross-wave carrier,
|
|
120
|
+
// and its size is capped by loadDigest's CEILING guarantee (JSON.stringify(digest)
|
|
121
|
+
// length ≤ CEILING), so the assembled context is bounded across ≥2 waves by construction.
|
|
122
|
+
//
|
|
123
|
+
// Returns { text, digest } — `text` is the ready-to-embed block (empty string when the
|
|
124
|
+
// flow has no closed slices yet), `digest` is the raw bounded array (for tests/assertions).
|
|
125
|
+
// Pure over an injected fs so it's unit-testable against a temp digest store.
|
|
126
|
+
export function assembleCrossWaveContext (flowId, { baseDir, deps = null, fs } = {}) {
|
|
127
|
+
if (!flowId || !baseDir) return { text: '', digest: [] }
|
|
128
|
+
let digest
|
|
129
|
+
try {
|
|
130
|
+
digest = loadDigest(flowId, { baseDir, ...(fs ? { fs } : {}) })
|
|
131
|
+
} catch {
|
|
132
|
+
// A malformed/absent digest store must never wedge dispatch — degrade to no context.
|
|
133
|
+
return { text: '', digest: [] }
|
|
134
|
+
}
|
|
135
|
+
// If the caller named the current slice's deps, prefer them (so a lane sees its
|
|
136
|
+
// dependencies first); otherwise show every closed slice. loadDigest already bounds
|
|
137
|
+
// the whole set under CEILING, so filtering only ever shrinks the block.
|
|
138
|
+
const wanted = Array.isArray(deps) && deps.length ? new Set(deps) : null
|
|
139
|
+
const shown = wanted ? digest.filter((d) => wanted.has(d.taskKey)) : digest
|
|
140
|
+
if (!shown.length) return { text: '', digest }
|
|
141
|
+
const lines = shown.map((d) => {
|
|
142
|
+
const ptrs = (d.pointers || [])
|
|
143
|
+
.map((p) => `${p.name}@${String(p.sha).slice(0, 8)}`)
|
|
144
|
+
.join(', ')
|
|
145
|
+
return ` - ${d.summary}${ptrs ? ` [artifacts: ${ptrs}]` : ''}`
|
|
146
|
+
})
|
|
147
|
+
const text = `CLOSED SLICES (bounded digest — summary + artifact pointers, not full output):\n${lines.join('\n')}`
|
|
148
|
+
return { text, digest }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Progressive skill loading (S2) ───────────────────────────────────────────
|
|
152
|
+
// A lane boots knowing only the skill MANIFEST (a tiny menu: name + one-line
|
|
153
|
+
// description + whenToUse) — never the bodies. It pulls a full SKILL.md body in only
|
|
154
|
+
// when it actually activates that skill. This is the DeerFlow steal: metadata in the
|
|
155
|
+
// base prompt, body on demand. The token win (manifest ≪ Σ bodies) is the whole point.
|
|
156
|
+
|
|
157
|
+
// Resolve the skill directories a Flow lane draws from. publicDir = built-in
|
|
158
|
+
// product-facing skills; customDir = optional user/room-level overrides. Both come from
|
|
159
|
+
// env so the bridge operator can point them at a real skills tree; absent → undefined,
|
|
160
|
+
// which the registry treats as "no skills" (empty manifest, loadBody clean-misses). This
|
|
161
|
+
// is deliberately NOT the .claude/skills dev-side harness — that stays out of scope.
|
|
162
|
+
export function flowSkillDirs (env = process.env) {
|
|
163
|
+
return {
|
|
164
|
+
publicDir: env.TP_FLOW_SKILLS_DIR || undefined,
|
|
165
|
+
customDir: env.TP_FLOW_SKILLS_CUSTOM_DIR || undefined,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── Absolute manifest ceiling (S2 blocker 2) ─────────────────────────────────
|
|
170
|
+
// The token-win invariant (manifest ≪ Σ bodies) is only a RELATIVE ratio — it does
|
|
171
|
+
// nothing to bound the ABSOLUTE size of the manifest block that lands in EVERY lane's
|
|
172
|
+
// base prompt. Custom skills are user/room-uploadable (H1/S6), so a single skill with a
|
|
173
|
+
// pathological description/whenToUse (a 200KB blob) would balloon the base prompt
|
|
174
|
+
// unbounded. These caps give the ceiling teeth: each field is truncated per-entry, and
|
|
175
|
+
// the whole rendered block is hard-capped, with a visible marker where content is cut.
|
|
176
|
+
export const MANIFEST_MAX_CHARS = 4096 // absolute ceiling on the rendered block
|
|
177
|
+
export const MANIFEST_FIELD_MAX = 240 // per-field (description / whenToUse) cap
|
|
178
|
+
const TRUNC_MARK = '…[truncated]'
|
|
179
|
+
|
|
180
|
+
// Truncate a single metadata field to MANIFEST_FIELD_MAX chars with a visible marker.
|
|
181
|
+
function clampField (s) {
|
|
182
|
+
const str = typeof s === 'string' ? s : ''
|
|
183
|
+
if (str.length <= MANIFEST_FIELD_MAX) return str
|
|
184
|
+
return str.slice(0, MANIFEST_FIELD_MAX - TRUNC_MARK.length) + TRUNC_MARK
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Render the compact manifest as a prompt block — METADATA ONLY. Each entry is one line:
|
|
188
|
+
// the skill name, its one-line description, and (if present) whenToUse. There is NO code
|
|
189
|
+
// path here that can emit a SKILL.md body: this function only ever sees manifest entries
|
|
190
|
+
// (which carry no body), so a body cannot leak into the base prompt through it.
|
|
191
|
+
// Returns '' for an empty manifest so the base prompt is unchanged when no skills exist.
|
|
192
|
+
//
|
|
193
|
+
// ABSOLUTE CEILING: every field is clamped to MANIFEST_FIELD_MAX, and the assembled block
|
|
194
|
+
// is hard-capped at MANIFEST_MAX_CHARS — entries that would push past the ceiling are
|
|
195
|
+
// dropped and a visible marker names how many were omitted. A pathological huge-metadata
|
|
196
|
+
// skill therefore cannot grow the base prompt beyond MANIFEST_MAX_CHARS (+ a small header).
|
|
197
|
+
export function renderSkillManifestBlock (entries) {
|
|
198
|
+
if (!Array.isArray(entries) || entries.length === 0) return ''
|
|
199
|
+
const header = [
|
|
200
|
+
'AVAILABLE SKILLS (menu only — names + descriptions, NOT the instructions). You boot',
|
|
201
|
+
'knowing this menu; you do NOT carry any skill\'s full instructions. When a task matches',
|
|
202
|
+
'a skill (or you invoke it slash-style), the room loads that ONE skill\'s full body for',
|
|
203
|
+
'you on demand — never all of them, never up front. The menu:',
|
|
204
|
+
].join('\n')
|
|
205
|
+
|
|
206
|
+
const rendered = []
|
|
207
|
+
let used = header.length
|
|
208
|
+
let dropped = 0
|
|
209
|
+
for (const e of entries) {
|
|
210
|
+
const when = e.whenToUse ? ` — use when: ${clampField(e.whenToUse)}` : ''
|
|
211
|
+
const line = ` • ${e.name}: ${clampField(e.description)}${when}`
|
|
212
|
+
// +1 for the '\n' joining this line to what precedes it.
|
|
213
|
+
if (used + 1 + line.length > MANIFEST_MAX_CHARS) { dropped++; continue }
|
|
214
|
+
rendered.push(line)
|
|
215
|
+
used += 1 + line.length
|
|
216
|
+
}
|
|
217
|
+
if (dropped > 0) {
|
|
218
|
+
rendered.push(` …[${dropped} more skill(s) omitted — manifest ceiling reached]`)
|
|
219
|
+
}
|
|
220
|
+
return [header, ...rendered].join('\n')
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Build a lane's base prompt: the static FLOW_LANE_PROMPT (or FLOW_REVIEWER-style base
|
|
224
|
+
// passed in) PLUS the skill MANIFEST block — metadata only, never bodies. This is what a
|
|
225
|
+
// lane session boots with. The token win vs. inlining bodies is asserted in tests.
|
|
226
|
+
// dirs/fs are injectable for unit-testing against temp fixtures; default to env dirs.
|
|
227
|
+
export function buildLanePrompt ({ base = FLOW_LANE_PROMPT, publicDir, customDir, fs } = {}) {
|
|
228
|
+
const dirs = publicDir === undefined && customDir === undefined ? flowSkillDirs() : { publicDir, customDir }
|
|
229
|
+
const entries = skillManifest({ ...dirs, ...(fs ? { fs } : {}) })
|
|
230
|
+
const block = renderSkillManifestBlock(entries)
|
|
231
|
+
return block ? `${base}\n\n${block}` : base
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Load exactly ONE skill's full body, on demand, when a lane activates it. Returns the
|
|
235
|
+
// SKILL.md body string, or null on a clean miss (unknown / disabled skill — never a
|
|
236
|
+
// throw). Callers inject this into the lane mid-task; it is never part of the base prompt.
|
|
237
|
+
export function activateLaneSkill (name, { publicDir, customDir, fs } = {}) {
|
|
238
|
+
const dirs = publicDir === undefined && customDir === undefined ? flowSkillDirs() : { publicDir, customDir }
|
|
239
|
+
return skillLoadBody(name, { ...dirs, ...(fs ? { fs } : {}) })
|
|
240
|
+
}
|
|
241
|
+
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// Thinkpool Flow — durable per-lane context-offload store.
|
|
2
|
+
//
|
|
3
|
+
// Three design patterns fused (docs/specs/2026-06-30-flow-build-s1-context-offload.md):
|
|
4
|
+
// • DBOS-style durable steps — each artifact write is idempotent + content-addressed by
|
|
5
|
+
// (taskKey, name) + sha256; a re-dispatched lane resumes instead of duplicating work.
|
|
6
|
+
// • Headroom-style digest ceiling — the conductor reads one-line summary + pointers per
|
|
7
|
+
// closed slice, capped at CEILING bytes; never an unbounded transcript.
|
|
8
|
+
// • Statewright-style structural containment — a hard path guard rejects any taskKey or
|
|
9
|
+
// name that would escape <worktreeDir>/.flow/artifacts/; structural, not an instruction.
|
|
10
|
+
// Reuses the approach from bridge/flow-assembly.mjs (inlineSingleHtml resolve) and
|
|
11
|
+
// bridge/flow-preview.mjs (resolveUnderRoot) — two existing containment sites.
|
|
12
|
+
//
|
|
13
|
+
// Pure module — canonical at src/lib/flow/contextStore.js, mirrored byte-identically
|
|
14
|
+
// at bridge/flow-context-store.mjs (same reason as taskGraph / flowBudget: the bridge
|
|
15
|
+
// ships as thinkpool-pair npm package and can't import across the bridge↔src boundary
|
|
16
|
+
// at publish time). Sync: bridge/flow-context-store.sync.test.js.
|
|
17
|
+
//
|
|
18
|
+
// Node built-ins only (node:crypto, node:fs, node:path) — no new deps.
|
|
19
|
+
// All fs calls are injectable ({fs} opts) so logic is unit-testable against temp dirs.
|
|
20
|
+
|
|
21
|
+
import { createHash } from 'node:crypto'
|
|
22
|
+
import nodeFs from 'node:fs'
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
|
|
25
|
+
// Hard ceiling for the conductor's assembled digest context (bytes of serialized JSON).
|
|
26
|
+
// When accumulated size would exceed CEILING, each over-budget entry is truncated to
|
|
27
|
+
// { taskKey, summary, pointers } — never drops a pointer, never silently grows past this.
|
|
28
|
+
export const CEILING = 32 * 1024 // 32 KB
|
|
29
|
+
|
|
30
|
+
// ── containment ──────────────────────────────────────────────────────────────
|
|
31
|
+
// Resolve `segments` under `base`. Returns the absolute path iff it stays strictly
|
|
32
|
+
// inside `base`, otherwise null. Same approach as flow-assembly.mjs (inlineSingleHtml
|
|
33
|
+
// resolve) and flow-preview.mjs (resolveUnderRoot) — reused, not re-invented.
|
|
34
|
+
//
|
|
35
|
+
// STRUCTURAL (not ENOENT-based): every rejection is a pure string-level check that
|
|
36
|
+
// fires before any filesystem call. Do not add fs.exists/stat calls here.
|
|
37
|
+
function containedPath (base, ...segments) {
|
|
38
|
+
for (const seg of segments) {
|
|
39
|
+
if (!seg || typeof seg !== 'string') return null
|
|
40
|
+
if (path.isAbsolute(seg)) return null
|
|
41
|
+
if (seg.includes('\0')) return null
|
|
42
|
+
// STRUCTURAL: reject any segment that contains a path separator character.
|
|
43
|
+
// Prevents 'real/link/escape' and 'a/../../evil' from normalising into a valid
|
|
44
|
+
// sub-path without escaping the per-entry rel-check below. This is the key
|
|
45
|
+
// structural gate — no ENOENT dependency.
|
|
46
|
+
if (seg.includes('/') || seg.includes('\\')) return null
|
|
47
|
+
// STRUCTURAL: reject bare '.' and '..' as full segments (belt-and-suspenders;
|
|
48
|
+
// the rel.startsWith('..') check below would also catch '..' after path.join).
|
|
49
|
+
if (seg === '..' || seg === '.') return null
|
|
50
|
+
// Reject zero-width / Unicode format chars (ZWSP U+200B, ZWNJ U+200C, etc.):
|
|
51
|
+
// trim() does NOT strip them; require at least one real identifier char.
|
|
52
|
+
if (!/[A-Za-z0-9_.-]/.test(seg)) return null
|
|
53
|
+
}
|
|
54
|
+
const joined = path.join(...segments)
|
|
55
|
+
const abs = path.resolve(base, joined)
|
|
56
|
+
const rel = path.relative(base, abs)
|
|
57
|
+
// rel === '' means abs === base (the directory itself) — not a valid write target
|
|
58
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return null
|
|
59
|
+
return abs
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── safeSegmentId ─────────────────────────────────────────────────────────────
|
|
63
|
+
// Validate that `val` is a safe single path-segment identifier (used for flowId).
|
|
64
|
+
// Rejects: null/non-string, empty, whitespace-only, null bytes, absolute paths,
|
|
65
|
+
// path separators (/ or \), bare '.' or '..'. Prevents flowId from escaping the
|
|
66
|
+
// digest base directory when interpolated into path.join calls.
|
|
67
|
+
function safeSegmentId (val) {
|
|
68
|
+
if (!val || typeof val !== 'string') return false
|
|
69
|
+
if (!val.trim()) return false
|
|
70
|
+
if (val.includes('\0')) return false
|
|
71
|
+
if (path.isAbsolute(val)) return false
|
|
72
|
+
if (val.includes('/') || val.includes('\\')) return false
|
|
73
|
+
if (val === '..' || val === '.') return false
|
|
74
|
+
// Reject zero-width / Unicode format chars (ZWSP U+200B, ZWNJ U+200C, etc.):
|
|
75
|
+
// trim() does NOT strip them; require at least one real identifier char.
|
|
76
|
+
if (!/[A-Za-z0-9_.-]/.test(val)) return false
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── assertLeafContained ────────────────────────────────────────────────────────
|
|
81
|
+
// Leaf-level (final-component) containment for a WRITE target — the artifact file or
|
|
82
|
+
// the digest .jsonl. The dir-level realpathSync guards in the callers contain the
|
|
83
|
+
// PARENT directory, but fs.writeFileSync / fs.appendFileSync follow a symlink planted
|
|
84
|
+
// AT the leaf itself, so a symlink at <dest> would let the write escape an
|
|
85
|
+
// already-contained directory. realpathSync(dir) never inspects the leaf's own type —
|
|
86
|
+
// only lstatSync does, because it does NOT follow the final component. It reveals the
|
|
87
|
+
// leaf as a symlink regardless of what the link points at, which is why one check
|
|
88
|
+
// covers all three escape flavors:
|
|
89
|
+
// • ENOENT → no leaf yet; the write will create a regular file → safe, return.
|
|
90
|
+
// • symlink → reject (absolute-, relative-, AND chained-target symlinks alike —
|
|
91
|
+
// lstat stops at the first hop, so a symlink→symlink→victim chain and a
|
|
92
|
+
// relative "../../.." target are both caught here).
|
|
93
|
+
// • regular → realpathSync must still resolve strictly under `canonicalDir`.
|
|
94
|
+
// Must be called BEFORE any idempotency readFileSync of the leaf (which would itself
|
|
95
|
+
// follow a leaf symlink). This is the leaf half of containment; the dir half stays in
|
|
96
|
+
// the callers (writeLaneArtifact / appendDigest). (B5 breaks 1-3.)
|
|
97
|
+
function assertLeafContained (fs, leafPath, canonicalDir) {
|
|
98
|
+
let st
|
|
99
|
+
try {
|
|
100
|
+
st = fs.lstatSync(leafPath)
|
|
101
|
+
} catch (e) {
|
|
102
|
+
if (e && e.code === 'ENOENT') return // leaf absent — write creates a regular file
|
|
103
|
+
throw e
|
|
104
|
+
}
|
|
105
|
+
if (st.isSymbolicLink()) {
|
|
106
|
+
throw new Error(`symlink escape: refusing to write through a symlink at leaf "${leafPath}"`)
|
|
107
|
+
}
|
|
108
|
+
// Non-symlink leaf: re-verify its real path is still strictly inside the canonical dir.
|
|
109
|
+
const real = fs.realpathSync(leafPath)
|
|
110
|
+
if (!real.startsWith(canonicalDir + path.sep)) {
|
|
111
|
+
throw new Error(`symlink escape: resolved leaf "${real}" escapes canonical dir "${canonicalDir}"`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── writeLaneArtifact ─────────────────────────────────────────────────────────
|
|
116
|
+
// Write `content` (string | Buffer) under <worktreeDir>/.flow/artifacts/<taskKey>/<name>.
|
|
117
|
+
// Returns { path, bytes, sha } where sha is the sha256 hex of the content.
|
|
118
|
+
//
|
|
119
|
+
// IDEMPOTENT: if (taskKey, name) already holds byte-identical content, skips the write
|
|
120
|
+
// and returns the same { path, bytes, sha }. Safe to call multiple times on the same
|
|
121
|
+
// artifact — a re-dispatched lane writing what's already there is a no-op.
|
|
122
|
+
//
|
|
123
|
+
// STRUCTURAL PATH GUARD: rejects any taskKey or name that would escape the artifacts
|
|
124
|
+
// base (no '..', no absolute paths, no null bytes, no path separators, no whitespace-only).
|
|
125
|
+
// Throws on violation before any filesystem call is made.
|
|
126
|
+
//
|
|
127
|
+
// REALPATH CONTAINMENT GUARD (directory level): after the structural string checks and
|
|
128
|
+
// mkdirSync, resolves the real path of taskDir via fs.realpathSync and verifies it is
|
|
129
|
+
// still inside the canonical artifacts root (fs.realpathSync(worktreeDir) +
|
|
130
|
+
// '/.flow/artifacts'). Catches symlinks planted at any DIRECTORY level — .flow,
|
|
131
|
+
// .flow/artifacts, or the taskKey dir — in one pass.
|
|
132
|
+
//
|
|
133
|
+
// LEAF CONTAINMENT GUARD (file level): the dir guard cannot see a symlink planted at the
|
|
134
|
+
// dest file itself (realpathSync(dir) never inspects the leaf's type). assertLeafContained
|
|
135
|
+
// lstat's the dest and rejects a leaf symlink — absolute-, relative-, or chained-target —
|
|
136
|
+
// before fs.writeFileSync (or the idempotency readFileSync) can follow it out of the dir.
|
|
137
|
+
export function writeLaneArtifact (flowId, taskKey, name, content, { worktreeDir, fs = nodeFs } = {}) {
|
|
138
|
+
if (!flowId || typeof flowId !== 'string') throw new Error('flowId required (string)')
|
|
139
|
+
if (!taskKey || typeof taskKey !== 'string' || !taskKey.trim()) throw new Error('taskKey required (non-empty string)')
|
|
140
|
+
if (!name || typeof name !== 'string' || !name.trim()) throw new Error('name required (non-empty string)')
|
|
141
|
+
if (!worktreeDir) throw new Error('worktreeDir required')
|
|
142
|
+
|
|
143
|
+
const artifactsBase = path.join(worktreeDir, '.flow', 'artifacts')
|
|
144
|
+
|
|
145
|
+
const taskDir = containedPath(artifactsBase, taskKey)
|
|
146
|
+
if (!taskDir) throw new Error(`taskKey "${taskKey}" rejected — path escapes artifacts base or is invalid`)
|
|
147
|
+
|
|
148
|
+
const dest = containedPath(artifactsBase, taskKey, name)
|
|
149
|
+
if (!dest) throw new Error(`name "${name}" rejected — path escapes artifacts base or is invalid`)
|
|
150
|
+
|
|
151
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(content)
|
|
152
|
+
const sha = createHash('sha256').update(buf).digest('hex')
|
|
153
|
+
|
|
154
|
+
fs.mkdirSync(taskDir, { recursive: true })
|
|
155
|
+
|
|
156
|
+
// REALPATH CONTAINMENT (directory level): after mkdirSync, resolve the real path of
|
|
157
|
+
// taskDir and verify it is still inside the canonical artifacts root.
|
|
158
|
+
// fs.realpathSync(worktreeDir) handles the case where the worktree itself lives under a
|
|
159
|
+
// legitimately-symlinked path; the canonical root is then built as a plain string (no
|
|
160
|
+
// further symlink-following), so a symlink planted at .flow, .flow/artifacts, or the
|
|
161
|
+
// taskKey directory is caught in one structural pass — its resolved path escapes the
|
|
162
|
+
// canonical root, and we throw.
|
|
163
|
+
// Replaces the per-position lstatSync whack-a-mole (was: check taskDir, check dest).
|
|
164
|
+
const canonicalWorktreeDir = fs.realpathSync(worktreeDir)
|
|
165
|
+
const canonicalRoot = path.join(canonicalWorktreeDir, '.flow', 'artifacts')
|
|
166
|
+
const canonicalTaskDir = fs.realpathSync(taskDir)
|
|
167
|
+
if (!canonicalTaskDir.startsWith(canonicalRoot + path.sep)) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`symlink escape: resolved path "${canonicalTaskDir}" escapes ` +
|
|
170
|
+
`canonical artifacts root "${canonicalRoot}"`
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// LEAF CONTAINMENT (file level): the dir guard above contains the directory, but
|
|
175
|
+
// fs.writeFileSync follows a symlink planted AT the dest file itself — a symlink at
|
|
176
|
+
// <taskDir>/<name> (absolute-, relative-, or chained-target) would let the write escape
|
|
177
|
+
// the contained dir. Must run BEFORE the idempotency read below, which also follows a
|
|
178
|
+
// leaf symlink via readFileSync. (B5 breaks 1 & 3.)
|
|
179
|
+
assertLeafContained(fs, dest, canonicalTaskDir)
|
|
180
|
+
|
|
181
|
+
// Idempotency: if file already exists with identical bytes, skip the write.
|
|
182
|
+
// Safe now — assertLeafContained has proven dest is a regular file (or absent).
|
|
183
|
+
if (fs.existsSync(dest)) {
|
|
184
|
+
const existing = fs.readFileSync(dest)
|
|
185
|
+
if (existing.equals(buf)) return { path: dest, bytes: buf.length, sha }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
fs.writeFileSync(dest, buf)
|
|
189
|
+
return { path: dest, bytes: buf.length, sha }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── digestSlice ───────────────────────────────────────────────────────────────
|
|
193
|
+
// Pure (no fs): compute a digest entry for a closed task. Returns { taskKey, summary,
|
|
194
|
+
// acceptanceProof, pointers }. Callers persist via appendDigest; the conductor reads
|
|
195
|
+
// back via loadDigest with the CEILING compression applied.
|
|
196
|
+
//
|
|
197
|
+
// summary: a single line, <= 120 chars: "[taskKey] title" optionally with the first
|
|
198
|
+
// line of acceptanceProof appended. Guaranteed to contain no newlines.
|
|
199
|
+
// pointers: [{ taskKey, name, path, sha }] from the artifacts written during the task.
|
|
200
|
+
// Artifacts whose taskKey/name/path/sha are missing/empty/undefined are
|
|
201
|
+
// silently dropped — prevents String(undefined) = "undefined" from leaking
|
|
202
|
+
// into pointer fields (DG-3).
|
|
203
|
+
export function digestSlice (task, { acceptanceProof = '', artifacts = [] } = {}) {
|
|
204
|
+
if (!task || !task.key) throw new Error('task with .key required')
|
|
205
|
+
const base = `[${task.key}] ${task.title ?? task.key}`
|
|
206
|
+
const firstLine = acceptanceProof ? String(acceptanceProof).split('\n')[0].trim() : ''
|
|
207
|
+
const summary = (firstLine ? `${base} — ${firstLine}` : base).slice(0, 120)
|
|
208
|
+
// DG-3: filter out artifacts with any missing/empty/undefined field before mapping.
|
|
209
|
+
// (artifacts ?? []) handles an explicit null — destructuring default only fires for
|
|
210
|
+
// undefined; passing artifacts:null leaves artifacts===null here, and null.filter throws.
|
|
211
|
+
// String(undefined) = "undefined" would produce bogus pointer field values.
|
|
212
|
+
const pointers = (artifacts ?? [])
|
|
213
|
+
.filter(a =>
|
|
214
|
+
a.taskKey != null && String(a.taskKey).trim() &&
|
|
215
|
+
a.name != null && String(a.name).trim() &&
|
|
216
|
+
a.path != null && String(a.path).trim() &&
|
|
217
|
+
a.sha != null && String(a.sha).trim()
|
|
218
|
+
)
|
|
219
|
+
.map(a => ({
|
|
220
|
+
taskKey: String(a.taskKey),
|
|
221
|
+
name: String(a.name),
|
|
222
|
+
path: String(a.path),
|
|
223
|
+
sha: String(a.sha),
|
|
224
|
+
}))
|
|
225
|
+
return { taskKey: task.key, summary, acceptanceProof: String(acceptanceProof), pointers }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── appendDigest ─────────────────────────────────────────────────────────────
|
|
229
|
+
// Persist a digest entry (from digestSlice) to <baseDir>/.flow/digests/<flowId>.jsonl.
|
|
230
|
+
// Append-only JSONL — insertion order is preserved for loadDigest.
|
|
231
|
+
//
|
|
232
|
+
// FLOWID CONTAINMENT: flowId is validated as a safe single path segment (no traversal,
|
|
233
|
+
// separators, null bytes, or whitespace-only) before being interpolated into the path.
|
|
234
|
+
// Prevents appendDigest('../../../victim', ...) from escaping baseDir (DG-1).
|
|
235
|
+
//
|
|
236
|
+
// LEAF CONTAINMENT: after the dir-level realpath check, assertLeafContained lstat's the
|
|
237
|
+
// <flowId>.jsonl file and rejects a leaf symlink (absolute-, relative-, or chained-target)
|
|
238
|
+
// before fs.appendFileSync can follow it out of the digests dir (B5 break 2).
|
|
239
|
+
//
|
|
240
|
+
// ZERO-POINTER guard: rejects any digest with an empty pointers array. Every persisted
|
|
241
|
+
// entry must have >=1 artifact pointer (invariant d). A task with no artifacts must not
|
|
242
|
+
// call appendDigest — the caller decides whether to skip or synthesize a pointer first.
|
|
243
|
+
//
|
|
244
|
+
// IDEMPOTENT: if the same (flowId, taskKey) is already persisted, this is a no-op.
|
|
245
|
+
// Calling appendDigest twice with the same digest never creates duplicate entries.
|
|
246
|
+
export function appendDigest (flowId, digest, { baseDir, fs = nodeFs } = {}) {
|
|
247
|
+
if (!safeSegmentId(flowId)) throw new Error(`flowId "${flowId}" rejected — must be a non-empty single path segment (no traversal, separators, or null bytes)`)
|
|
248
|
+
if (!baseDir) throw new Error('baseDir required')
|
|
249
|
+
if (!digest || !digest.taskKey) throw new Error('digest.taskKey required')
|
|
250
|
+
// ZERO-POINTER guard: every persisted digest must carry >=1 valid pointer (invariant d).
|
|
251
|
+
if (!digest.pointers || digest.pointers.length === 0) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`digest for "${digest.taskKey}" has no pointers — ` +
|
|
254
|
+
'a closed slice must produce >=1 artifact pointer before persisting'
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
const dir = path.join(baseDir, '.flow', 'digests')
|
|
258
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
259
|
+
|
|
260
|
+
// REALPATH CONTAINMENT: verify the resolved digests directory is still under the
|
|
261
|
+
// canonical base — same one-pass approach as writeLaneArtifact. Catches symlinks
|
|
262
|
+
// planted at .flow or .flow/digests. fs.realpathSync(baseDir) handles a legitimately-
|
|
263
|
+
// symlinked base; canonicalDigestsDir is built as a plain string thereafter.
|
|
264
|
+
const canonicalBase = fs.realpathSync(baseDir)
|
|
265
|
+
const canonicalDigestsDir = path.join(canonicalBase, '.flow', 'digests')
|
|
266
|
+
const canonicalDir = fs.realpathSync(dir)
|
|
267
|
+
if (canonicalDir !== canonicalDigestsDir) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
`symlink escape: resolved digests dir "${canonicalDir}" escapes ` +
|
|
270
|
+
`canonical digests root "${canonicalDigestsDir}"`
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const file = path.join(dir, `${flowId}.jsonl`)
|
|
275
|
+
|
|
276
|
+
// LEAF CONTAINMENT (file level): the dir guard above contains the digests directory, but
|
|
277
|
+
// fs.appendFileSync follows a symlink planted AT the <flowId>.jsonl leaf — a symlink
|
|
278
|
+
// there (absolute-, relative-, or chained-target) would let the append escape the
|
|
279
|
+
// contained dir. Must run BEFORE the idempotency readFileSync below, which also follows
|
|
280
|
+
// a leaf symlink. (B5 break 2.)
|
|
281
|
+
assertLeafContained(fs, file, canonicalDir)
|
|
282
|
+
|
|
283
|
+
// IDEMPOTENCY: skip if the same taskKey is already persisted for this flowId.
|
|
284
|
+
// Re-digesting the same (flowId, taskKey) is a no-op — no duplicate lines.
|
|
285
|
+
if (fs.existsSync(file)) {
|
|
286
|
+
const lines = String(fs.readFileSync(file, 'utf8')).split('\n').filter(l => l.trim())
|
|
287
|
+
for (const line of lines) {
|
|
288
|
+
try {
|
|
289
|
+
if (JSON.parse(line).taskKey === digest.taskKey) return
|
|
290
|
+
} catch { /* skip malformed lines */ }
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
fs.appendFileSync(file, JSON.stringify(digest) + '\n')
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── loadDigest ────────────────────────────────────────────────────────────────
|
|
297
|
+
// Load all closed-slice digests for `flowId` from <baseDir>/.flow/digests/<flowId>.jsonl,
|
|
298
|
+
// applying the hard CEILING compression:
|
|
299
|
+
// • While accumulated serialized size + full-entry length <= CEILING → include full entry
|
|
300
|
+
// • Else if slim (no acceptanceProof) fits → include slim
|
|
301
|
+
// • Else → drop the entry; never exceed CEILING
|
|
302
|
+
//
|
|
303
|
+
// FLOWID CONTAINMENT: flowId is validated as a safe single path segment before use
|
|
304
|
+
// in path construction — same rule as appendDigest (DG-1).
|
|
305
|
+
//
|
|
306
|
+
// CEILING tracking is exact: accumulated == JSON.stringify(result).length at all times,
|
|
307
|
+
// accounting for the outer '['/']' and comma separators between entries.
|
|
308
|
+
//
|
|
309
|
+
// Dedup by taskKey (first occurrence wins) and skips zero-pointer entries for safety
|
|
310
|
+
// against old data that may pre-date the appendDigest zero-pointer guard.
|
|
311
|
+
//
|
|
312
|
+
// Returns digests in insertion order. Total JSON.stringify(result).length <= CEILING.
|
|
313
|
+
export function loadDigest (flowId, { baseDir, fs = nodeFs } = {}) {
|
|
314
|
+
if (!safeSegmentId(flowId)) throw new Error(`flowId "${flowId}" rejected — must be a non-empty single path segment (no traversal, separators, or null bytes)`)
|
|
315
|
+
if (!baseDir) throw new Error('baseDir required')
|
|
316
|
+
const file = path.join(baseDir, '.flow', 'digests', `${flowId}.jsonl`)
|
|
317
|
+
if (!fs.existsSync(file)) return []
|
|
318
|
+
const lines = String(fs.readFileSync(file, 'utf8')).split('\n').filter(l => l.trim())
|
|
319
|
+
const result = []
|
|
320
|
+
const seen = new Set() // dedup by taskKey — first occurrence wins
|
|
321
|
+
// Track exact JSON.stringify(result).length:
|
|
322
|
+
// '[' entries.join(',') ']' = 2 + sum(entryLen) + (n-1 commas)
|
|
323
|
+
// Start at 2 for the outer brackets. Each entry adds (comma if not first) + entryLen.
|
|
324
|
+
// Invariant: accumulated === JSON.stringify(result).length after every push.
|
|
325
|
+
let accumulated = 2 // '[' + ']'
|
|
326
|
+
for (const line of lines) {
|
|
327
|
+
let entry
|
|
328
|
+
try { entry = JSON.parse(line) } catch { continue }
|
|
329
|
+
if (!entry.taskKey) continue
|
|
330
|
+
// Dedup: appendDigest should prevent duplicates, but guard here for old data
|
|
331
|
+
if (seen.has(entry.taskKey)) continue
|
|
332
|
+
// Safety: skip zero-pointer entries (appendDigest now rejects them, but old JSONL may have them)
|
|
333
|
+
if (!entry.pointers || entry.pointers.length === 0) continue
|
|
334
|
+
seen.add(entry.taskKey)
|
|
335
|
+
const slim = { taskKey: entry.taskKey, summary: entry.summary, pointers: entry.pointers }
|
|
336
|
+
const comma = result.length > 0 ? 1 : 0 // comma separator before every entry except the first
|
|
337
|
+
const fullLen = line.length
|
|
338
|
+
const slimLen = JSON.stringify(slim).length
|
|
339
|
+
if (accumulated + comma + fullLen <= CEILING) {
|
|
340
|
+
result.push(entry)
|
|
341
|
+
accumulated += comma + fullLen
|
|
342
|
+
} else if (accumulated + comma + slimLen <= CEILING) {
|
|
343
|
+
// Full would exceed ceiling: emit the slim form (never drop the pointer)
|
|
344
|
+
result.push(slim)
|
|
345
|
+
accumulated += comma + slimLen
|
|
346
|
+
}
|
|
347
|
+
// else: neither full nor slim fits within CEILING — drop this entry.
|
|
348
|
+
// The hard guarantee: JSON.stringify(result).length <= CEILING always holds.
|
|
349
|
+
}
|
|
350
|
+
return result
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── resumeLane ────────────────────────────────────────────────────────────────
|
|
354
|
+
// Re-read a lane's own prior artifacts from <worktreeDir>/.flow/artifacts/.
|
|
355
|
+
// Returns an array of { taskKey, name, path, bytes, sha } — identical to what
|
|
356
|
+
// writeLaneArtifact would have returned for each artifact originally written.
|
|
357
|
+
//
|
|
358
|
+
// IDEMPOTENT: calling multiple times on the same worktree gives the same result.
|
|
359
|
+
// A re-dispatched lane calls this on startup to reconstruct its prior state without
|
|
360
|
+
// redoing any writes. Artifacts whose files are unreadable are silently skipped.
|
|
361
|
+
export function resumeLane (flowId, { worktreeDir, fs = nodeFs } = {}) {
|
|
362
|
+
if (!flowId) throw new Error('flowId required')
|
|
363
|
+
if (!worktreeDir) throw new Error('worktreeDir required')
|
|
364
|
+
const artifactsBase = path.join(worktreeDir, '.flow', 'artifacts')
|
|
365
|
+
if (!fs.existsSync(artifactsBase)) return []
|
|
366
|
+
// [SCOPE: accepted] The read path here is intentionally NOT realpath-checked.
|
|
367
|
+
// resumeLane reads artifacts but does not write; traversing a symlink-redirected path
|
|
368
|
+
// cannot exfiltrate data the caller couldn't already read by other means. The worktree
|
|
369
|
+
// is trusted, non-shared, and single-user by design (git worktree, browser-side flow).
|
|
370
|
+
// The WRITE path in writeLaneArtifact IS realpath-checked — that's where containment
|
|
371
|
+
// matters. If the threat model changes (multi-user, shared fs), add realpath here too.
|
|
372
|
+
const artifacts = []
|
|
373
|
+
for (const taskKey of fs.readdirSync(artifactsBase)) {
|
|
374
|
+
const taskDirPath = path.join(artifactsBase, taskKey)
|
|
375
|
+
let stat
|
|
376
|
+
try { stat = fs.statSync(taskDirPath) } catch { continue }
|
|
377
|
+
if (!stat.isDirectory()) continue
|
|
378
|
+
for (const name of fs.readdirSync(taskDirPath)) {
|
|
379
|
+
const filePath = path.join(taskDirPath, name)
|
|
380
|
+
let buf
|
|
381
|
+
try { buf = fs.readFileSync(filePath) } catch { continue }
|
|
382
|
+
const sha = createHash('sha256').update(buf).digest('hex')
|
|
383
|
+
artifacts.push({ taskKey, name, path: filePath, bytes: buf.length, sha })
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return artifacts
|
|
387
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Thinkpool Flow — S4 clean lane kill + re-dispatch (slice 2, flow-redispatch-clean).
|
|
2
|
+
//
|
|
3
|
+
// When a lane is force-killed mid-tool-call (budget kill-switch, review reject, restart)
|
|
4
|
+
// and re-dispatched, three things must hold before the new lane replays the old work:
|
|
5
|
+
//
|
|
6
|
+
// (a) HEALED TRANSCRIPT — a lane killed mid-tool-call leaves an assistant message with a
|
|
7
|
+
// tool_use block that has NO matching tool_result. On the next SDK replay Anthropic
|
|
8
|
+
// 400s ("tool_use ids were found without tool_result blocks"). sanitizeSession's
|
|
9
|
+
// Heal 3 (bridge/transcript-sanitize.mjs — DONE, do not rewrite) injects a synthetic
|
|
10
|
+
// tool_result placeholder. We CONSUME it here: run it on the dying lane's transcript
|
|
11
|
+
// so a resume is clean. Idempotent — a clean transcript is a no-op.
|
|
12
|
+
//
|
|
13
|
+
// (d) REVERT TARGET PRESERVED — revertLane deletes the lane's branch (`git branch -D`), which
|
|
14
|
+
// destroys the commit the killed lane made. The revert target (commit_sha, recorded
|
|
15
|
+
// room-side on flow_tasks) must survive the re-dispatch so a later reviewer still knows
|
|
16
|
+
// what to roll back to. We carry it in the pending-redispatch record.
|
|
17
|
+
//
|
|
18
|
+
// H41 (realtime collision) — NOT this module's concern, by construction. A re-dispatched lane
|
|
19
|
+
// opens NO realtime channel of its own: the bridge opens exactly two channels ONCE at startup
|
|
20
|
+
// (`tpcode:<room>` presence + `tpflow:<room>` presence-free broadcast), and a re-dispatched
|
|
21
|
+
// lane reuses the existing room-wide `tpflow:<room>` broadcast. With no per-lane subscribe there
|
|
22
|
+
// is no second .subscribe() to race a presence callback (feedback_no_duplicate_realtime_channel,
|
|
23
|
+
// Flow #134/#136), so no duplicate-subscribe collision is possible. Earlier drafts computed a
|
|
24
|
+
// per-lane `flowRedispatchTopic` — it was dead (never read by any subscribe) and was removed so
|
|
25
|
+
// a future reader doesn't mistake it for live realtime coverage.
|
|
26
|
+
//
|
|
27
|
+
// DBOS-idempotent: re-running prepareRedispatch on an already-clean lane heals nothing and
|
|
28
|
+
// re-dispatch of a lane with no pending record resumes nothing — both are no-ops.
|
|
29
|
+
//
|
|
30
|
+
// Pure over an injected `sanitize` so it's unit-testable without touching a real transcript.
|
|
31
|
+
// Spec: docs/specs/2026-06-30-flow-build-s4-clean-redispatch.md.
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Prepare a killed lane for clean re-dispatch. Heals its transcript (so a resume can't 400 on
|
|
35
|
+
* a dangling tool_use) and returns everything the re-dispatch needs to resume cleanly.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} o
|
|
38
|
+
* @param {object} o.lane the killed lane's session entry (needs cwd, session.sessionId, commitSha/flowReviewTarget)
|
|
39
|
+
* @param {function} o.sanitize (cwd, sessionId) => { blocks } — inject sanitizeSession here (the Heal-3 consumer)
|
|
40
|
+
* @returns {{ resumeSessionId: string|null, revertTarget: string|null, healed: number, cwd: string|null }}
|
|
41
|
+
*/
|
|
42
|
+
export function prepareRedispatch ({ lane, sanitize }) {
|
|
43
|
+
const cwd = lane?.cwd || null
|
|
44
|
+
const resumeSessionId = lane?.session?.sessionId || lane?.sessionId || null
|
|
45
|
+
// Revert target survives the branch deletion: prefer an explicitly recorded commit, else
|
|
46
|
+
// any commitSha the lane carried. null when the lane never committed (nothing to preserve).
|
|
47
|
+
const revertTarget = lane?.revertTarget ?? lane?.commitSha ?? null
|
|
48
|
+
|
|
49
|
+
let healed = 0
|
|
50
|
+
// Heal ONLY when there's a transcript to heal (a resumable session). No sessionId → the lane
|
|
51
|
+
// never got a live SDK session, so there is nothing to replay and nothing to heal (no-op).
|
|
52
|
+
if (resumeSessionId && typeof sanitize === 'function') {
|
|
53
|
+
try {
|
|
54
|
+
const r = sanitize(cwd, resumeSessionId)
|
|
55
|
+
healed = (r && typeof r.blocks === 'number') ? r.blocks : 0
|
|
56
|
+
} catch {
|
|
57
|
+
healed = 0 // a sanitize failure must never block re-dispatch — worst case, resume as before
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { resumeSessionId, revertTarget, healed, cwd }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A pending-redispatch registry. When flow-revert kills a lane, it records the prepared
|
|
66
|
+
* re-dispatch (keyed by flowId+taskKey) so the next flow-dispatch wave resumes the healed
|
|
67
|
+
* session on a clean topic with the revert target intact. Idempotent: re-dispatching a task
|
|
68
|
+
* with no pending record (a first, never-killed dispatch) resolves to a fresh lane (no resume).
|
|
69
|
+
*/
|
|
70
|
+
export function redispatchKey (flowId, taskKey) {
|
|
71
|
+
return `${flowId}::${taskKey}`
|
|
72
|
+
}
|
package/flow-review-gate.mjs
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
// reviewer's worktree, not the slice. The wiring layer that provisions the reviewer lane
|
|
41
41
|
// MUST guarantee this worktree isolation — this gate cannot substitute for it.
|
|
42
42
|
|
|
43
|
-
import { realpathSync as nodeRealpathSync } from 'node:fs'
|
|
43
|
+
import { realpathSync as nodeRealpathSync, readlinkSync, lstatSync } from 'node:fs'
|
|
44
44
|
import path from 'node:path'
|
|
45
45
|
|
|
46
46
|
// ── Tool classification ───────────────────────────────────────────────────────
|
|
@@ -175,7 +175,17 @@ function _assessFilePath ({
|
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
177
|
} catch {
|
|
178
|
-
// verdictFile doesn't exist yet —
|
|
178
|
+
// verdictFile doesn't exist yet — realpathSync(verdictFile) threw ENOENT.
|
|
179
|
+
// That does NOT prove "no symlink possible": a symlinked ANCESTOR directory (or a
|
|
180
|
+
// dangling symlink named FLOW_REVIEW.json) can still resolve the write into the slice.
|
|
181
|
+
// Realpath the nearest EXISTING ancestor and re-check containment of the full target.
|
|
182
|
+
const realAncestor = _realpathNearestAncestor(resolvedVerdict, _realpath)
|
|
183
|
+
if (realAncestor && (_isUnder(realAncestor, canonicalSliceRoot) || _isUnder(realAncestor, inputSliceRoot))) {
|
|
184
|
+
return {
|
|
185
|
+
allow: false,
|
|
186
|
+
reason: `verdictFile "${verdictFile}" resolves (via a symlinked path component) to "${realAncestor}" inside sliceRoot — denied`,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
179
189
|
}
|
|
180
190
|
return { allow: true, reason: `write to verdictFile (${path.basename(verdictFile)}) — allowed` }
|
|
181
191
|
}
|
|
@@ -201,12 +211,67 @@ function _assessFilePath ({
|
|
|
201
211
|
}
|
|
202
212
|
}
|
|
203
213
|
} catch {
|
|
204
|
-
// ENOENT or
|
|
214
|
+
// ENOENT — the file (or a component of it) doesn't exist yet, so realpathSync(filePath)
|
|
215
|
+
// threw before it could follow any symlinked ANCESTOR directory. Step 1 only normalized
|
|
216
|
+
// '..' — it did NOT resolve a symlinked path component (e.g. scratch/link → sliceRoot,
|
|
217
|
+
// then Write scratch/link/PWNED.js lands in the slice). Realpath the nearest EXISTING
|
|
218
|
+
// ancestor, re-join the un-created tail, and re-check containment.
|
|
219
|
+
const realAncestor = _realpathNearestAncestor(resolved, _realpath)
|
|
220
|
+
if (realAncestor && (_isUnder(realAncestor, canonicalSliceRoot) || _isUnder(realAncestor, inputSliceRoot))) {
|
|
221
|
+
return {
|
|
222
|
+
allow: false,
|
|
223
|
+
reason: `${toolName} to "${filePath}" resolves (via a symlinked path component) to "${realAncestor}" inside sliceRoot — denied`,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
205
226
|
}
|
|
206
227
|
|
|
207
228
|
return { allow: true, reason: `${toolName} to "${filePath}" is outside sliceRoot — allowed` }
|
|
208
229
|
}
|
|
209
230
|
|
|
231
|
+
// ── _realpathNearestAncestor ────────────────────────────────────────────────────
|
|
232
|
+
// For a target path that does NOT fully exist (realpathSync threw ENOENT), walk UP to the
|
|
233
|
+
// nearest existing ancestor directory, realpath-resolve THAT (following any symlinked
|
|
234
|
+
// component), then re-join the remaining un-created tail. The returned absolute path is what
|
|
235
|
+
// the write would ACTUALLY land on once created — which is what containment must be tested
|
|
236
|
+
// against. Returns null only if even the filesystem root can't be resolved (never expected).
|
|
237
|
+
function _realpathNearestAncestor (resolved, _realpath) {
|
|
238
|
+
// If the LEAF itself is a symlink (existing but DANGLING — its target doesn't exist, so
|
|
239
|
+
// realpathSync(resolved) threw), follow the link one hop and resolve from THERE. This is the
|
|
240
|
+
// dangling-verdict-symlink case: scratch/FLOW_REVIEW.json → <sliceRoot>/evil.js. path.resolve
|
|
241
|
+
// does NOT follow symlinks, so without this the write's true landing spot is invisible.
|
|
242
|
+
try {
|
|
243
|
+
if (lstatSync(resolved).isSymbolicLink()) {
|
|
244
|
+
const linkTarget = readlinkSync(resolved)
|
|
245
|
+
const absTarget = path.isAbsolute(linkTarget)
|
|
246
|
+
? linkTarget
|
|
247
|
+
: path.resolve(path.dirname(resolved), linkTarget)
|
|
248
|
+
// Recurse: the target may itself be non-existent / chained through more symlinks.
|
|
249
|
+
try {
|
|
250
|
+
return _realpath(absTarget)
|
|
251
|
+
} catch {
|
|
252
|
+
return _realpathNearestAncestor(absTarget, _realpath)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} catch {
|
|
256
|
+
// lstat failed — leaf doesn't exist at all; fall through to ancestor walk below.
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let ancestor = path.dirname(resolved)
|
|
260
|
+
const tail = [path.basename(resolved)]
|
|
261
|
+
// Walk up until an ancestor resolves, or we hit the root (dirname is a fixed point).
|
|
262
|
+
while (true) {
|
|
263
|
+
try {
|
|
264
|
+
const realAncestor = _realpath(ancestor)
|
|
265
|
+
return path.join(realAncestor, ...tail.reverse())
|
|
266
|
+
} catch {
|
|
267
|
+
const parent = path.dirname(ancestor)
|
|
268
|
+
if (parent === ancestor) return null // reached root without resolving — give up
|
|
269
|
+
tail.push(path.basename(ancestor))
|
|
270
|
+
ancestor = parent
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
210
275
|
// ── _assessBashCommand ────────────────────────────────────────────────────────
|
|
211
276
|
// HEURISTIC — see BASH LIMITATION comment at top of file.
|
|
212
277
|
|
|
@@ -294,6 +359,41 @@ function _assessBashCommand ({ cmd, inputSliceRoot, canonicalSliceRoot }) {
|
|
|
294
359
|
return { allow: true, reason: 'Bash command allowed (no write-to-slice pattern detected)' }
|
|
295
360
|
}
|
|
296
361
|
|
|
362
|
+
// ── reviewGatePreToolDecision ───────────────────────────────────────────────────
|
|
363
|
+
// S5 (slice 1b) — the LIVE PreToolUse wiring seam. Lives HERE (not in claude-session.mjs)
|
|
364
|
+
// so it stays SDK-free and unit-testable: claude-session.mjs pulls the Claude Agent SDK at
|
|
365
|
+
// module load, which isn't resolvable in the test env. The PreToolUse hook imports and calls
|
|
366
|
+
// this; the integration test imports it directly.
|
|
367
|
+
//
|
|
368
|
+
// `reviewGate` is a per-review-lane closure (bridge.mjs folds reviewGateDecision over the
|
|
369
|
+
// reviewed slices' worktree roots); it is NULL on builder lanes. Returns:
|
|
370
|
+
// • null → fall through to the normal permission path (read / run-test / verdict).
|
|
371
|
+
// • deny payload → a terminal PreToolUse `permissionDecision: 'deny'` the hook returns
|
|
372
|
+
// immediately — BEFORE the auto-allow/bypass path, so the block is
|
|
373
|
+
// structural even in the lane's bypassPermissions mode.
|
|
374
|
+
// A throwing reviewGate fails CLOSED (deny) — never a silent allow. Builder lanes
|
|
375
|
+
// (reviewGate=null) always return null here → their toolset is UNCHANGED.
|
|
376
|
+
export function reviewGatePreToolDecision ({ reviewGate, toolName, toolInput }) {
|
|
377
|
+
if (!reviewGate) return null
|
|
378
|
+
let g = null
|
|
379
|
+
try {
|
|
380
|
+
g = reviewGate({ toolName, input: toolInput })
|
|
381
|
+
} catch (e) {
|
|
382
|
+
g = { allow: false, reason: `review gate error — denied: ${e?.message || e}` }
|
|
383
|
+
}
|
|
384
|
+
if (g && g.allow === false) {
|
|
385
|
+
return {
|
|
386
|
+
continue: true,
|
|
387
|
+
hookSpecificOutput: {
|
|
388
|
+
hookEventName: 'PreToolUse',
|
|
389
|
+
permissionDecision: 'deny',
|
|
390
|
+
permissionDecisionReason: `Blocked (Flow review lane): ${g.reason} You REVIEW the slice — you do not edit it. Read it, run it to reproduce acceptance, then emit your verdict by Writing FLOW_REVIEW.json.`,
|
|
391
|
+
},
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return null
|
|
395
|
+
}
|
|
396
|
+
|
|
297
397
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
298
398
|
|
|
299
399
|
// Returns true if `filePath` equals `dir` OR is strictly inside it.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Thinkpool Flow — progressive skill registry.
|
|
2
|
+
//
|
|
3
|
+
// A Flow lane boots with only skill METADATA (name + description + whenToUse) injected
|
|
4
|
+
// into its base prompt — the compact manifest(). The full SKILL.md body loads ON DEMAND
|
|
5
|
+
// via loadBody(name) only when the lane actually activates that skill. Token win: the
|
|
6
|
+
// manifest is orders of magnitude smaller than the sum of all skill bodies.
|
|
7
|
+
//
|
|
8
|
+
// Format: each skill lives at <dir>/<skill-name>/SKILL.md with agentskills.io YAML
|
|
9
|
+
// frontmatter (name, description, and optional whenToUse). The body is the full SKILL.md
|
|
10
|
+
// file content (frontmatter included) — the complete text a lane sees on activation.
|
|
11
|
+
//
|
|
12
|
+
// Public/custom split: publicDir carries built-in product skills; customDir (optional)
|
|
13
|
+
// carries user/room-level overrides. Custom skills override public ones by `name` field.
|
|
14
|
+
//
|
|
15
|
+
// Pure module — canonical at src/lib/flow/skillRegistry.js, mirrored byte-identically
|
|
16
|
+
// at bridge/flow-skill-registry.mjs (same reason as taskGraph / contextStore: the bridge
|
|
17
|
+
// ships as thinkpool-pair npm package and can't import across the bridge↔src boundary
|
|
18
|
+
// at publish time). Sync: bridge/flow-skill-registry.sync.test.js.
|
|
19
|
+
//
|
|
20
|
+
// Node built-ins only (node:fs, node:path) — no new deps.
|
|
21
|
+
// All fs calls are injectable (fs option) so logic is unit-testable against temp dirs.
|
|
22
|
+
|
|
23
|
+
import nodeFs from 'node:fs'
|
|
24
|
+
import nodePath from 'node:path'
|
|
25
|
+
|
|
26
|
+
// ── parseFrontmatter ──────────────────────────────────────────────────────────
|
|
27
|
+
// Parse agentskills.io YAML-like frontmatter from a SKILL.md string.
|
|
28
|
+
// Returns { meta: { name, description, whenToUse? } } or null if malformed/missing.
|
|
29
|
+
// Required fields: name, description — missing either → null (skill skipped from manifest).
|
|
30
|
+
// Tolerant: unknown YAML fields silently ignored; only scalar key:value pairs parsed.
|
|
31
|
+
function parseFrontmatter (content) {
|
|
32
|
+
if (typeof content !== 'string') return null
|
|
33
|
+
// Strip BOM if present
|
|
34
|
+
const s = content.charCodeAt(0) === 0xFEFF ? content.slice(1) : content
|
|
35
|
+
if (!s.startsWith('---')) return null
|
|
36
|
+
const afterOpen = s.slice(3)
|
|
37
|
+
// Closing delimiter: a line that is exactly '---' (accept \r\n or \n line endings)
|
|
38
|
+
const closeMatch = afterOpen.match(/\r?\n---(?:\r?\n|$)/)
|
|
39
|
+
if (!closeMatch) return null
|
|
40
|
+
const yamlBlock = afterOpen.slice(0, closeMatch.index)
|
|
41
|
+
|
|
42
|
+
// Scalar key: value extraction only — not a full YAML parser; lists/nested maps silently mis-parsed.
|
|
43
|
+
const meta = {}
|
|
44
|
+
for (const line of yamlBlock.split(/\r?\n/)) {
|
|
45
|
+
const colonIdx = line.indexOf(':')
|
|
46
|
+
if (colonIdx === -1) continue
|
|
47
|
+
const key = line.slice(0, colonIdx).trim()
|
|
48
|
+
const val = line.slice(colonIdx + 1).trim()
|
|
49
|
+
if (key) meta[key] = val
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Both name and description are required for a valid skill entry
|
|
53
|
+
if (!meta.name || !meta.description) return null
|
|
54
|
+
return { meta }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── scanDir ───────────────────────────────────────────────────────────────────
|
|
58
|
+
// Scan `dir` for skill entries. Each valid skill is a subdirectory containing a SKILL.md
|
|
59
|
+
// with parseable frontmatter (name + description required). Malformed SKILL.md → skipped
|
|
60
|
+
// without throwing. Missing or inaccessible dir → returns [].
|
|
61
|
+
// Returns [{ name, description, whenToUse?, _path }] where _path is the absolute SKILL.md
|
|
62
|
+
// path (internal — stripped from manifest() output, used by loadBody).
|
|
63
|
+
function scanDir (dir, { fs = nodeFs } = {}) {
|
|
64
|
+
if (!dir) return []
|
|
65
|
+
let entries
|
|
66
|
+
try {
|
|
67
|
+
entries = fs.readdirSync(dir)
|
|
68
|
+
} catch (e) {
|
|
69
|
+
if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) return []
|
|
70
|
+
throw e
|
|
71
|
+
}
|
|
72
|
+
const skills = []
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
const skillDir = nodePath.join(dir, entry)
|
|
75
|
+
let stat
|
|
76
|
+
// lstatSync does NOT follow symlinks — a symlinked entry returns isSymbolicLink()=true
|
|
77
|
+
// and isDirectory()=false, so it is skipped. Using statSync would follow the link and
|
|
78
|
+
// allow scanning a directory outside publicDir/customDir (symlink escape vector).
|
|
79
|
+
try { stat = fs.lstatSync(skillDir) } catch { continue }
|
|
80
|
+
if (!stat.isDirectory()) continue
|
|
81
|
+
const skillMdPath = nodePath.join(skillDir, 'SKILL.md')
|
|
82
|
+
// Belt-and-suspenders: lstat the SKILL.md too — skip if it is itself a symlink.
|
|
83
|
+
// Covers a file-level symlink inside a real (non-symlinked) skill dir.
|
|
84
|
+
let skillMdStat
|
|
85
|
+
try { skillMdStat = fs.lstatSync(skillMdPath) } catch { continue }
|
|
86
|
+
if (skillMdStat.isSymbolicLink()) continue
|
|
87
|
+
let content
|
|
88
|
+
try { content = fs.readFileSync(skillMdPath, 'utf8') } catch { continue }
|
|
89
|
+
const parsed = parseFrontmatter(content)
|
|
90
|
+
if (!parsed) continue // malformed — skip silently, do not throw
|
|
91
|
+
const { meta } = parsed
|
|
92
|
+
const skill = { name: meta.name, description: meta.description, _path: skillMdPath }
|
|
93
|
+
if (meta.whenToUse) skill.whenToUse = meta.whenToUse
|
|
94
|
+
skills.push(skill)
|
|
95
|
+
}
|
|
96
|
+
return skills
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── manifest ──────────────────────────────────────────────────────────────────
|
|
100
|
+
// Build the compact skill manifest: an array of { name, description, whenToUse? } per
|
|
101
|
+
// skill — METADATA ONLY, no SKILL.md body. This is what a lane injects into its base
|
|
102
|
+
// prompt to know which skills are available without loading any body.
|
|
103
|
+
//
|
|
104
|
+
// Merge rule: publicDir skills load first; customDir skills then override/extend by
|
|
105
|
+
// `name`. A custom skill with the same name as a public skill replaces it in the result.
|
|
106
|
+
//
|
|
107
|
+
// Returns [] if both dirs are absent or contain no valid skills.
|
|
108
|
+
export function manifest ({ publicDir, customDir, fs = nodeFs } = {}) {
|
|
109
|
+
const publicSkills = scanDir(publicDir, { fs })
|
|
110
|
+
const customSkills = scanDir(customDir, { fs })
|
|
111
|
+
// Merge: public first, custom overrides by name
|
|
112
|
+
const byName = new Map()
|
|
113
|
+
for (const s of publicSkills) byName.set(s.name, s)
|
|
114
|
+
for (const s of customSkills) byName.set(s.name, s)
|
|
115
|
+
// Return compact entries — strip internal _path
|
|
116
|
+
return [...byName.values()].map(({ name, description, whenToUse }) => {
|
|
117
|
+
const entry = { name, description }
|
|
118
|
+
if (whenToUse !== undefined) entry.whenToUse = whenToUse
|
|
119
|
+
return entry
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── loadBody ──────────────────────────────────────────────────────────────────
|
|
124
|
+
// Load the full SKILL.md content for the named skill, on demand.
|
|
125
|
+
// customDir is checked before publicDir (a custom skill overrides a public of the same name).
|
|
126
|
+
// Unknown name / skill not found in either dir → returns null (clean miss, never throws).
|
|
127
|
+
export function loadBody (name, { publicDir, customDir, fs = nodeFs } = {}) {
|
|
128
|
+
if (!name || typeof name !== 'string') return null
|
|
129
|
+
// Custom takes priority over public — check custom first
|
|
130
|
+
for (const dir of [customDir, publicDir]) {
|
|
131
|
+
const skills = scanDir(dir, { fs })
|
|
132
|
+
const found = skills.find(s => s.name === name)
|
|
133
|
+
if (found) {
|
|
134
|
+
try {
|
|
135
|
+
return fs.readFileSync(found._path, 'utf8')
|
|
136
|
+
} catch { /* skill file disappeared — try next dir */ }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return null // clean miss — not found in either dir
|
|
140
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.114",
|
|
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": {
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
"flow-review-gate.mjs",
|
|
25
25
|
"flow-assembly.mjs",
|
|
26
26
|
"flow-budget.mjs",
|
|
27
|
+
"flow-context-store.mjs",
|
|
28
|
+
"flow-redispatch.mjs",
|
|
29
|
+
"flow-skill-registry.mjs",
|
|
27
30
|
"serve-dir.mjs",
|
|
28
31
|
"service.mjs",
|
|
29
32
|
"presence.mjs",
|