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.
Files changed (116) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +20 -0
  3. package/abort-turn-barrier.mjs +1 -23
  4. package/account.mjs +1 -1437
  5. package/acp-client.mjs +1 -140
  6. package/agent-detect.mjs +1 -28
  7. package/agent-notify.mjs +1 -142
  8. package/agent-visibility.mjs +1 -67
  9. package/auth-store.mjs +1 -120
  10. package/bridge.mjs +1 -6266
  11. package/byok-detect.mjs +1 -126
  12. package/claude-command-catalog.mjs +1 -91
  13. package/claude-session.mjs +1 -1519
  14. package/code-event-contract.mjs +1 -118
  15. package/codex-app-server.mjs +1 -340
  16. package/codex-commands.mjs +1 -83
  17. package/codex-event-mapper.mjs +1 -232
  18. package/codex-images.mjs +1 -69
  19. package/codex-mcp-http.mjs +1 -131
  20. package/codex-session.mjs +1 -1295
  21. package/command-catalog.mjs +1 -118
  22. package/command-guidance.mjs +1 -8
  23. package/context-contract.mjs +1 -95
  24. package/context-windows.mjs +1 -107
  25. package/cross-terminal.mjs +1 -789
  26. package/cumulative-event-relay.mjs +1 -53
  27. package/design-edit.mjs +1 -424
  28. package/design-source-contract.mjs +1 -4
  29. package/direct-pair-room.mjs +1 -57
  30. package/dispatch-lease.mjs +1 -37
  31. package/dispatch-permission-cleanup.mjs +1 -86
  32. package/edit-diff.mjs +1 -136
  33. package/error-recovery.mjs +1 -50
  34. package/event-bounds.mjs +1 -121
  35. package/event-delivery-queue.mjs +1 -60
  36. package/event-id.mjs +1 -549
  37. package/evidence-citations.mjs +1 -50
  38. package/evidence-compact.mjs +1 -11
  39. package/flow-assembly.mjs +1 -196
  40. package/flow-budget.mjs +1 -84
  41. package/flow-conductor.mjs +1 -259
  42. package/flow-context-store.mjs +1 -387
  43. package/flow-host-revert.mjs +1 -42
  44. package/flow-models.mjs +1 -139
  45. package/flow-preview.mjs +1 -148
  46. package/flow-receipt.mjs +1 -122
  47. package/flow-redispatch.mjs +1 -72
  48. package/flow-review-gate.mjs +1 -402
  49. package/flow-review-reflect.mjs +1 -115
  50. package/flow-review.mjs +1 -152
  51. package/flow-scope-evidence.mjs +1 -117
  52. package/flow-skill-registry.mjs +1 -140
  53. package/flow-task-graph.mjs +1 -562
  54. package/flow-worktree.mjs +1 -71
  55. package/git-diff-report.mjs +1 -121
  56. package/hermes-delegation-guard.mjs +1 -14
  57. package/hermes-event-mapper.mjs +1 -194
  58. package/hermes-isolation.mjs +1 -61
  59. package/hermes-model-cache.mjs +1 -54
  60. package/hermes-policy.mjs +1 -92
  61. package/hermes-probe.mjs +1 -57
  62. package/hermes-session.mjs +1 -683
  63. package/hermes-setup.mjs +1 -167
  64. package/host-memory.mjs +1 -116
  65. package/interrupted-resume.mjs +1 -95
  66. package/keep-awake.mjs +1 -148
  67. package/key-shape.mjs +1 -49
  68. package/lane-continuation.mjs +1 -83
  69. package/lane-lifecycle.mjs +1 -189
  70. package/lane-worktree.mjs +1 -77
  71. package/launcher.mjs +1 -354
  72. package/mcp-flight-recorder.mjs +1 -79
  73. package/mockup-delivery.mjs +1 -57
  74. package/model-prices.mjs +1 -113
  75. package/package.json +13 -4
  76. package/pair-bus.mjs +1 -98
  77. package/pair-control-authority.mjs +1 -89
  78. package/past-work-search.mjs +1 -105
  79. package/plan-meters.mjs +1 -144
  80. package/presence.mjs +1 -191
  81. package/privacy-report.mjs +1 -108
  82. package/provider-resilience.mjs +1 -356
  83. package/provider.mjs +1 -133
  84. package/providers.mjs +1 -491
  85. package/publish-guard.mjs +2 -0
  86. package/publish-manifest.json +129 -0
  87. package/question-response.mjs +1 -58
  88. package/reap-terminal.mjs +1 -68
  89. package/recap.mjs +1 -297
  90. package/replay-transport.mjs +1 -64
  91. package/repo-search.mjs +1 -190
  92. package/review-check.mjs +1 -182
  93. package/runtime-contract.mjs +1 -93
  94. package/runtime-registry.mjs +1 -64
  95. package/runtime-session.mjs +1 -20
  96. package/scheduled-run-admission.mjs +1 -364
  97. package/scheduled-runs.mjs +1 -268
  98. package/sdk-admission.mjs +1 -9
  99. package/sdk-smoke.mjs +1 -61
  100. package/serve-consent.mjs +1 -118
  101. package/serve-dir.mjs +1 -40
  102. package/service.mjs +1 -877
  103. package/session-store.mjs +1 -484
  104. package/side-lane.mjs +1 -63
  105. package/supabase-key.mjs +1 -176
  106. package/supervisor-ready.mjs +1 -57
  107. package/switch-provider.mjs +1 -84
  108. package/terminal-name.mjs +1 -359
  109. package/terminal-row-reconcile.mjs +1 -70
  110. package/thinkpool-prompt-contracts.mjs +1 -85
  111. package/thinkpool-room-prompt.mjs +1 -187
  112. package/transcript-sanitize.mjs +1 -332
  113. package/turn-stall.mjs +1 -61
  114. package/update-gate.mjs +1 -53
  115. package/viewport.mjs +1 -810
  116. package/worker-completion.mjs +1 -57
package/flow-preview.mjs CHANGED
@@ -1,148 +1 @@
1
- // Thinkpool Flow per-lane preview runtime.
2
- //
3
- // Each Flow lane builds its slice in its own git worktree (flow-worktree.mjs).
4
- // The room UI iframes a LIVE preview of that slice, so the worktree dir needs to be
5
- // reachable over localhost. The MVP artifact is a self-contained index.html (no build
6
- // step), so a static file server is the universal case — Node http + fs + path, no deps
7
- // (this is published as the `thinkpool-pair` npm package; new deps are not on the table).
8
- //
9
- // Spec: docs/specs/2026-06-29-thinkpool-flow.md (Step 3).
10
-
11
- import http from 'node:http'
12
- import fs from 'node:fs'
13
- import fsp from 'node:fs/promises'
14
- import path from 'node:path'
15
-
16
- // Module-level registry of live previews so cleanup (flow cancel / shutdown) can sweep
17
- // every server without each caller tracking handles. id → { server, url, stop }.
18
- export const previews = new Map()
19
-
20
- // Minimal extension → MIME map. Covers the self-contained-app surface + common assets.
21
- // Unknown extensions fall back to application/octet-stream (the browser downloads it).
22
- const MIME = {
23
- '.html': 'text/html; charset=utf-8',
24
- '.htm': 'text/html; charset=utf-8',
25
- '.js': 'text/javascript; charset=utf-8',
26
- '.mjs': 'text/javascript; charset=utf-8',
27
- '.css': 'text/css; charset=utf-8',
28
- '.json': 'application/json; charset=utf-8',
29
- '.png': 'image/png',
30
- '.jpg': 'image/jpeg',
31
- '.jpeg': 'image/jpeg',
32
- '.svg': 'image/svg+xml',
33
- '.wav': 'audio/wav',
34
- '.mp3': 'audio/mpeg',
35
- '.ico': 'image/x-icon'
36
- }
37
-
38
- function contentType (filePath) {
39
- return MIME[path.extname(filePath).toLowerCase()] || 'application/octet-stream'
40
- }
41
-
42
- // Map a request URL path to a real file under `root`, or null if it would escape `root`
43
- // (path traversal). Decode + strip query/hash, normalize, then verify the resolved path
44
- // is still inside root — the relative path between them must not climb out with `..`.
45
- function resolveUnderRoot (root, urlPath) {
46
- let pathname
47
- try {
48
- pathname = decodeURIComponent(new URL(urlPath, 'http://x').pathname)
49
- } catch {
50
- return null // malformed percent-encoding
51
- }
52
- const resolved = path.resolve(root, '.' + pathname) // leading '.' keeps it relative to root
53
- const rel = path.relative(root, resolved)
54
- if (rel.startsWith('..') || path.isAbsolute(rel)) return null
55
- return resolved
56
- }
57
-
58
- // Is this path a regular file we can read? (false for dirs / missing / errors)
59
- async function isFile (p) {
60
- try {
61
- return (await fsp.stat(p)).isFile()
62
- } catch {
63
- return false
64
- }
65
- }
66
-
67
- async function send (res, status, type, body) {
68
- res.writeHead(status, {
69
- 'Content-Type': type,
70
- 'Access-Control-Allow-Origin': '*' // local preview only; deliberately NO COOP/COEP
71
- })
72
- res.end(body)
73
- }
74
-
75
- // Build the request handler bound to a worktree dir. Routing:
76
- // - traversal outside dir → 403
77
- // - existing file → that file
78
- // - '/' or a directory → index.html
79
- // - unknown path → index.html if present (SPA fallback), else 404
80
- function makeHandler (dir) {
81
- const indexPath = path.join(dir, 'index.html')
82
- return async (req, res) => {
83
- const resolved = resolveUnderRoot(dir, req.url || '/')
84
- if (resolved === null) return send(res, 403, 'text/plain; charset=utf-8', 'Forbidden')
85
- const relative = path.relative(dir, resolved)
86
- if (relative.split(path.sep)[0] === '.thinkpool-design') {
87
- return send(res, 404, 'text/plain; charset=utf-8', 'Not found')
88
- }
89
-
90
- // A concrete file hit (and still a real file, not a dir) → serve it.
91
- if (await isFile(resolved)) {
92
- try {
93
- return send(res, 200, contentType(resolved), await fsp.readFile(resolved))
94
- } catch {
95
- return send(res, 500, 'text/plain; charset=utf-8', 'Read error')
96
- }
97
- }
98
-
99
- // Directory, root, or unknown path → fall back to index.html (SPA-friendly).
100
- if (await isFile(indexPath)) {
101
- try {
102
- return send(res, 200, contentType(indexPath), await fsp.readFile(indexPath))
103
- } catch {
104
- return send(res, 500, 'text/plain; charset=utf-8', 'Read error')
105
- }
106
- }
107
-
108
- return send(res, 404, 'text/plain; charset=utf-8', 'Not found')
109
- }
110
- }
111
-
112
- // Start a static preview server rooted at `dir`. port 0 → OS assigns a free port; the
113
- // actual bound port is read back from server.address(). Resolves once listening.
114
- // Returns { url, port, stop } — stop() is idempotent and async-safe.
115
- export function startPreview ({ dir, port = 0, host = '127.0.0.1', id } = {}) {
116
- if (!dir) throw new Error('startPreview: dir is required')
117
-
118
- const server = http.createServer(makeHandler(dir))
119
-
120
- // Idempotent stop: close once, no-op on subsequent calls; resolve when fully closed.
121
- let stopped = false
122
- const stop = () =>
123
- new Promise((resolve) => {
124
- if (id !== undefined) previews.delete(id)
125
- if (stopped) return resolve()
126
- stopped = true
127
- server.close(() => resolve())
128
- })
129
-
130
- return new Promise((resolve, reject) => {
131
- server.once('error', reject)
132
- server.listen(port, host, () => {
133
- server.removeListener('error', reject)
134
- const bound = server.address().port
135
- const url = `http://${host}:${bound}/`
136
- const handle = { server, url, stop, dir }
137
- if (id !== undefined) previews.set(id, handle)
138
- resolve({ url, port: bound, stop })
139
- })
140
- })
141
- }
142
-
143
- // Stop + deregister every live preview. Best-effort; awaits all closes.
144
- export async function stopAllPreviews () {
145
- const handles = [...previews.values()]
146
- previews.clear()
147
- await Promise.all(handles.map((h) => h.stop()))
148
- }
1
+ import t from"node:http";import e from"node:fs";import r from"node:fs/promises";import n from"node:path";export const previews=new Map;const i={".html":"text/html; charset=utf-8",".htm":"text/html; charset=utf-8",".js":"text/javascript; charset=utf-8",".mjs":"text/javascript; charset=utf-8",".css":"text/css; charset=utf-8",".json":"application/json; charset=utf-8",".png":"image/png",".jpg":"image/jpeg",".jpeg":"image/jpeg",".svg":"image/svg+xml",".wav":"audio/wav",".mp3":"audio/mpeg",".ico":"image/x-icon"};function o(t){return i[n.extname(t).toLowerCase()]||"application/octet-stream"}async function s(t){try{return(await r.stat(t)).isFile()}catch{return!1}}async function a(t,e,r,n){t.writeHead(e,{"Content-Type":r,"Access-Control-Allow-Origin":"*"}),t.end(n)}export function startPreview({dir:e,port:i=0,host:c="127.0.0.1",id:p}={}){if(!e)throw new Error("startPreview: dir is required");const u=t.createServer(function(t){const e=n.join(t,"index.html");return async(i,c)=>{const p=function(t,e){let r;try{r=decodeURIComponent(new URL(e,"http://x").pathname)}catch{return null}const i=n.resolve(t,"."+r),o=n.relative(t,i);return o.startsWith("..")||n.isAbsolute(o)?null:i}(t,i.url||"/");if(null===p)return a(c,403,"text/plain; charset=utf-8","Forbidden");if(".thinkpool-design"===n.relative(t,p).split(n.sep)[0])return a(c,404,"text/plain; charset=utf-8","Not found");if(await s(p))try{return a(c,200,o(p),await r.readFile(p))}catch{return a(c,500,"text/plain; charset=utf-8","Read error")}if(await s(e))try{return a(c,200,o(e),await r.readFile(e))}catch{return a(c,500,"text/plain; charset=utf-8","Read error")}return a(c,404,"text/plain; charset=utf-8","Not found")}}(e));let l=!1;const f=()=>new Promise(t=>{if(void 0!==p&&previews.delete(p),l)return t();l=!0,u.close(()=>t())});return new Promise((t,r)=>{u.once("error",r),u.listen(i,c,()=>{u.removeListener("error",r);const n=u.address().port,i=`http://${c}:${n}/`,o={server:u,url:i,stop:f,dir:e};void 0!==p&&previews.set(p,o),t({url:i,port:n,stop:f})})})}export async function stopAllPreviews(){const t=[...previews.values()];previews.clear(),await Promise.all(t.map(t=>t.stop()))}
package/flow-receipt.mjs CHANGED
@@ -1,122 +1 @@
1
- // Flow review receipts are deliberately smaller than a review verdict. They cross
2
- // from a private reviewer lane into a shared transcript, so this module only
3
- // admits the evidence a pair can act on. Prompts, environment, paths, logs, and
4
- // hidden reasoning never have a field in the public shape.
5
-
6
- import { normalizeFlowScopeEvidence } from './flow-scope-evidence.mjs'
7
-
8
- export const FLOW_REVIEW_RECEIPT_KIND = 'flow-review-receipt'
9
-
10
- const MAX_TASK_KEY = 120
11
- const MAX_LINE = 180
12
- const MAX_LINES = 4
13
- const OUTCOMES = new Set(['pass', 'reject', 'held'])
14
- const DIGEST = /^[a-f0-9]{64}$/
15
- const SECRET_VALUE = /(?:sk[_-](?:proj[_-])?[a-z0-9_-]{8,}|gsk_[a-z0-9_-]{8,}|AIza[a-z0-9_-]{8,}|gh[pousr]_[a-z0-9_-]{8,}|github_pat_[a-z0-9_]{20,}|glpat-[a-z0-9_-]{8,}|x(?:ox[baprsce]|app)-[a-z0-9_-]{8,}|(?:AKIA|ASIA)[0-9A-Z]{16}|sbp_[a-z0-9_-]{20,}|sb_secret_[a-z0-9_-]{8,}|npm_[a-z0-9]{24,}|(?:sk|rk)_(?:live|test)_[a-z0-9]{8,}|whsec_[a-z0-9]{8,}|eyJ[a-z0-9_-]{8,}\.[a-z0-9_-]{8,}\.[a-z0-9_-]{8,}|bearer\s+[a-z0-9._-]{8,}|hooks\.slack\.com\/services\/[a-z0-9/_-]{8,})/i
16
- const SECRET_OR_PRIVATE = /(?:api[_ -]?key|access[_ -]?token|\btoken\s*[=:]|auth(?:orization)?|bearer\s+|secret|password|private[_ -]?key|system\s+prompt|raw\s+prompt|hidden\s+reasoning|chain[ -]of[ -]thought|\benv(?:ironment)?\b|\.env\b)/i
17
- const HOST_PATH = /(?:^~[\\/]|^\.?[\\/]|(?:^|\s)\/[\w.-]+|[A-Za-z]:\\|\\\\|(?:^|[\\/])(?:Users|home|var|tmp|private|workspace)(?:[\\/]|$)|\.thinkpool[\\/])/i
18
- const PATH_TRAVERSAL = /(?:^|[^a-z0-9.])\.\.(?:[\\/]|$)/i
19
-
20
- function cleanText(value, max) {
21
- if (typeof value !== 'string' || value.length === 0 || value.length > max) return null
22
- if (/\r|\n/.test(value) || SECRET_VALUE.test(value) || SECRET_OR_PRIVATE.test(value) || HOST_PATH.test(value) || PATH_TRAVERSAL.test(value)) return null
23
- const text = value.replace(/\s+/g, ' ').trim()
24
- return text && text.length <= max ? text : null
25
- }
26
-
27
- function cleanLines(value) {
28
- if (!Array.isArray(value)) return []
29
- const out = []
30
- for (const line of value) {
31
- const safe = cleanText(line, MAX_LINE)
32
- if (safe && !out.includes(safe)) out.push(safe)
33
- if (out.length === MAX_LINES) break
34
- }
35
- return out
36
- }
37
-
38
- function cleanTaskKey(value) {
39
- const taskKey = cleanText(value, MAX_TASK_KEY)
40
- if (!taskKey || !/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(taskKey) || taskKey.includes('..')) return null
41
- return taskKey
42
- }
43
-
44
- function cleanSha(value) {
45
- if (typeof value !== 'string') return null
46
- const sha = value.trim().toLowerCase()
47
- return /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(sha) ? sha : null
48
- }
49
-
50
- function cleanRounds(value) {
51
- return Number.isInteger(value) && value >= 1 && value <= 99 ? value : null
52
- }
53
-
54
- function cleanDigest(value) {
55
- if (typeof value !== 'string') return null
56
- const digest = value.trim().toLowerCase()
57
- return DIGEST.test(digest) ? digest : null
58
- }
59
-
60
- // Build the only wire shape the renderer accepts. Optional fields are omitted
61
- // rather than represented as null, making absence honest (notably deployment).
62
- export function createFlowReviewReceipt(input = {}) {
63
- const taskKey = cleanTaskKey(input.taskKey)
64
- const outcome = typeof input.outcome === 'string' ? input.outcome.toLowerCase() : ''
65
- const rounds = cleanRounds(input.rounds)
66
- if (!taskKey || !OUTCOMES.has(outcome) || rounds == null) {
67
- throw new TypeError('Invalid flow review receipt identity')
68
- }
69
-
70
- const receipt = {
71
- kind: FLOW_REVIEW_RECEIPT_KIND,
72
- taskKey,
73
- outcome,
74
- rounds,
75
- checks: cleanLines(input.checks),
76
- reasons: cleanLines(input.reasons),
77
- }
78
- const candidateSha = cleanSha(input.candidateSha)
79
- const acceptanceDigest = cleanDigest(input.acceptanceDigest)
80
- const scope = normalizeFlowScopeEvidence(input.scope)
81
- // Presence, not truthiness, is intentional: deployments are never inferred
82
- // from a pass/reject outcome or from a candidate SHA.
83
- const deployment = Object.hasOwn(input, 'deployment') ? cleanText(input.deployment, MAX_LINE) : null
84
- if (candidateSha) receipt.candidateSha = candidateSha
85
- if (acceptanceDigest) receipt.acceptanceDigest = acceptanceDigest
86
- receipt.verified = Boolean(candidateSha && acceptanceDigest)
87
- if (outcome === 'pass' && !receipt.verified) {
88
- throw new TypeError('Passing flow review receipt requires an exact candidate and acceptance digest')
89
- }
90
- if (scope) receipt.scope = scope
91
- if (outcome === 'pass' && !scope?.held) {
92
- throw new TypeError('Passing flow review receipt requires a held scope footprint')
93
- }
94
- if (deployment) receipt.deployment = deployment
95
- return receipt
96
- }
97
-
98
- export function isFlowReviewReceipt(value) {
99
- try {
100
- const receipt = createFlowReviewReceipt(value)
101
- return receipt.kind === value?.kind
102
- } catch {
103
- return false
104
- }
105
- }
106
-
107
- // A review lane may receive the same terminal submission twice (tool retry,
108
- // double click, or concurrent provider delivery). Serialize the whole verdict
109
- // transaction—not just receipt emission—so an awaited revert cannot run twice.
110
- export function createFlowReviewSingleFlight() {
111
- let pending = null
112
- return async function runFlowReviewVerdict(operation) {
113
- if (pending) return pending
114
- const current = (async () => operation())()
115
- pending = current
116
- try {
117
- return await current
118
- } finally {
119
- if (pending === current) pending = null
120
- }
121
- }
122
- }
1
+ import{normalizeFlowScopeEvidence as e}from"./flow-scope-evidence.mjs";export const FLOW_REVIEW_RECEIPT_KIND="flow-review-receipt";const t=new Set(["pass","reject","held"]),r=/^[a-f0-9]{64}$/,n=/(?:sk[_-](?:proj[_-])?[a-z0-9_-]{8,}|gsk_[a-z0-9_-]{8,}|AIza[a-z0-9_-]{8,}|gh[pousr]_[a-z0-9_-]{8,}|github_pat_[a-z0-9_]{20,}|glpat-[a-z0-9_-]{8,}|x(?:ox[baprsce]|app)-[a-z0-9_-]{8,}|(?:AKIA|ASIA)[0-9A-Z]{16}|sbp_[a-z0-9_-]{20,}|sb_secret_[a-z0-9_-]{8,}|npm_[a-z0-9]{24,}|(?:sk|rk)_(?:live|test)_[a-z0-9]{8,}|whsec_[a-z0-9]{8,}|eyJ[a-z0-9_-]{8,}\.[a-z0-9_-]{8,}\.[a-z0-9_-]{8,}|bearer\s+[a-z0-9._-]{8,}|hooks\.slack\.com\/services\/[a-z0-9/_-]{8,})/i,s=/(?:api[_ -]?key|access[_ -]?token|\btoken\s*[=:]|auth(?:orization)?|bearer\s+|secret|password|private[_ -]?key|system\s+prompt|raw\s+prompt|hidden\s+reasoning|chain[ -]of[ -]thought|\benv(?:ironment)?\b|\.env\b)/i,o=/(?:^~[\\/]|^\.?[\\/]|(?:^|\s)\/[\w.-]+|[A-Za-z]:\\|\\\\|(?:^|[\\/])(?:Users|home|var|tmp|private|workspace)(?:[\\/]|$)|\.thinkpool[\\/])/i,a=/(?:^|[^a-z0-9.])\.\.(?:[\\/]|$)/i;function i(e,t){if("string"!=typeof e||0===e.length||e.length>t)return null;if(/\r|\n/.test(e)||n.test(e)||s.test(e)||o.test(e)||a.test(e))return null;const r=e.replace(/\s+/g," ").trim();return r&&r.length<=t?r:null}function c(e){if(!Array.isArray(e))return[];const t=[];for(const r of e){const e=i(r,180);if(e&&!t.includes(e)&&t.push(e),4===t.length)break}return t}export function createFlowReviewReceipt(n={}){const s=function(e){const t=i(e,120);return t&&/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(t)&&!t.includes("..")?t:null}(n.taskKey),o="string"==typeof n.outcome?n.outcome.toLowerCase():"",a=(l=n.rounds,Number.isInteger(l)&&l>=1&&l<=99?l:null);var l;if(!s||!t.has(o)||null==a)throw new TypeError("Invalid flow review receipt identity");const p={kind:"flow-review-receipt",taskKey:s,outcome:o,rounds:a,checks:c(n.checks),reasons:c(n.reasons)},u=function(e){if("string"!=typeof e)return null;const t=e.trim().toLowerCase();return/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(t)?t:null}(n.candidateSha),f=function(e){if("string"!=typeof e)return null;const t=e.trim().toLowerCase();return r.test(t)?t:null}(n.acceptanceDigest),w=e(n.scope),_=Object.hasOwn(n,"deployment")?i(n.deployment,180):null;if(u&&(p.candidateSha=u),f&&(p.acceptanceDigest=f),p.verified=Boolean(u&&f),"pass"===o&&!p.verified)throw new TypeError("Passing flow review receipt requires an exact candidate and acceptance digest");if(w&&(p.scope=w),"pass"===o&&!w?.held)throw new TypeError("Passing flow review receipt requires a held scope footprint");return _&&(p.deployment=_),p}export function isFlowReviewReceipt(e){try{return createFlowReviewReceipt(e).kind===e?.kind}catch{return!1}}export function createFlowReviewSingleFlight(){let e=null;return async function(t){if(e)return e;const r=(async()=>t())();e=r;try{return await r}finally{e===r&&(e=null)}}}
@@ -1,72 +1 @@
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
- }
1
+ export function prepareRedispatch({lane:e,sanitize:t}){const n=e?.cwd||null,s=e?.session?.sessionId||e?.sessionId||null,r=e?.revertTarget??e?.commitSha??null;let o=0;if(s&&"function"==typeof t)try{const e=t(n,s);o=e&&"number"==typeof e.blocks?e.blocks:0}catch{o=0}return{resumeSessionId:s,revertTarget:r,healed:o,cwd:n}}export function redispatchKey(e,t){return`${e}::${t}`}