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
@@ -1,562 +1 @@
1
- // Thinkpool Flow — task-graph data model.
2
- //
3
- // A Flow run = one FlowSession (the "build me X" prompt + mode + status) decomposed
4
- // by the conductor into FlowTasks (RUNNABLE slices + dependencies). The plan lives
5
- // HERE, in the store, not in the conductor's chat context — research move #1
6
- // (docs/specs/2026-06-29-thinkpool-flow.md): "move the plan out of the context
7
- // window into a task-graph." Pure module — shared by the bridge (conductor) and the
8
- // client (room UI), and mirrored by CHECK constraints in the
9
- // 20260629120000_flow_sessions_tasks migration (one source of truth).
10
-
11
- export const FLOW_STATUS = {
12
- planning: 'planning', // conductor is decomposing
13
- awaiting_approval: 'awaiting_approval', // plan submitted, waiting on the human
14
- approved: 'approved', // human approved; lanes may dispatch (Step 2+)
15
- building: 'building', // lanes are running
16
- reviewing: 'reviewing', // adversarial-review phase
17
- assembling: 'assembling', // merging worktrees
18
- done: 'done',
19
- failed: 'failed',
20
- cancelled: 'cancelled',
21
- }
22
-
23
- export const TASK_STATUS = {
24
- pending: 'pending',
25
- ready: 'ready', // deps satisfied, awaiting dispatch
26
- dispatched: 'dispatched', // a lane is running it
27
- reviewing: 'reviewing', // adversarial-review lane is checking it
28
- done: 'done',
29
- failed: 'failed',
30
- reverted: 'reverted', // rolled back by the atomic-revert net
31
- }
32
-
33
- export const FLOW_MODE = {
34
- steer: 'steer', // human watches every lane, live
35
- guide: 'guide', // phase-gated (DEFAULT): approve plan + phase reviews
36
- autopilot: 'autopilot', // full-auto, adaptive escalation + budget cap
37
- }
38
-
39
- export const SLICE_TYPE = {
40
- feature: 'feature',
41
- scaffold: 'scaffold', // project bootstrap / shared foundation
42
- review: 'review', // adversarial-review lane
43
- fix: 'fix',
44
- }
45
-
46
- // Gate-first Flow contracts are stored in flow_tasks.contract (JSONB). Keep every
47
- // free-text field bounded and single-line: this payload crosses the terminal MCP
48
- // boundary and is later injected into builder/reviewer prompts.
49
- export const FLOW_CONTRACT_LIMITS = Object.freeze({
50
- maxNonGoals: 6,
51
- maxNonGoalChars: 240,
52
- maxBaselineGateChars: 600,
53
- maxBaselineEvidenceChars: 1400,
54
- maxScopePaths: 32,
55
- maxScopePathChars: 180,
56
- })
57
-
58
- const BASELINE_NEGATIVE_SIGNAL = /\b(fail(?:s|ed|ing)?|absent|missing|not\s+(?:present|implemented|available|found)|does\s+not|404|empty)\b/i
59
- const CONTRACT_DIGEST = /^[a-f0-9]{64}$/
60
- const SHA256_K = [
61
- 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
62
- 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
63
- 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
64
- 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
65
- 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
66
- 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
67
- 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
68
- 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
69
- ]
70
- const rotateRight = (value, bits) => (value >>> bits) | (value << (32 - bits))
71
- const hasDisallowedControl = (value) => {
72
- for (const char of value) {
73
- const code = char.charCodeAt(0)
74
- if (code === 0x7f || (code < 0x20 && code !== 0x09)) return true
75
- }
76
- return false
77
- }
78
-
79
- function boundedLine (value, label, max, { required = false } = {}) {
80
- if (value == null || value === '') {
81
- if (required) throw new Error(`${label} is required`)
82
- return ''
83
- }
84
- if (typeof value !== 'string') throw new Error(`${label} must be a string`)
85
- const text = value.trim()
86
- if (!text) {
87
- if (required) throw new Error(`${label} is required`)
88
- return ''
89
- }
90
- if (text.length > max) throw new Error(`${label} exceeds ${max} characters`)
91
- if (hasDisallowedControl(text)) throw new Error(`${label} must be safe single-line text`)
92
- return text
93
- }
94
-
95
- function normalizeScopePath (value) {
96
- if (typeof value !== 'string') return null
97
- const text = value.trim().replaceAll('\\', '/')
98
- if (!text || text.length > FLOW_CONTRACT_LIMITS.maxScopePathChars || text.startsWith('/') || /^[A-Za-z]:\//.test(text)) return null
99
- const directoryPattern = text.endsWith('/**')
100
- if (text.includes('*') && !directoryPattern) return null
101
- const bare = directoryPattern ? text.slice(0, -3) : text
102
- if (!bare || bare.endsWith('/') || bare.split('/').some((part) => !part || part === '.' || part === '..')) return null
103
- if (/(?:^|\/)(?:\.env(?:\.|$)|\.git(?:\/|$)|\.thinkpool(?:\/|$)|node_modules(?:\/|$))/.test(bare)) return null
104
- const last = bare.split('/').at(-1)
105
- if (!directoryPattern && !last.includes('.') && !/^(?:Dockerfile|Makefile|LICENSE|README)$/i.test(last)) return null
106
- return directoryPattern ? `${bare}/**` : bare
107
- }
108
-
109
- export function normalizeTaskScopePaths (value, { fallbackScope = '', required = true } = {}) {
110
- let values = value
111
- if (values == null && fallbackScope) {
112
- const inferred = normalizeScopePath(fallbackScope)
113
- if (inferred) values = [inferred]
114
- }
115
- if (values == null) values = []
116
- if (!Array.isArray(values)) throw new Error('scopePaths must be an array')
117
- if (values.length > FLOW_CONTRACT_LIMITS.maxScopePaths) throw new Error(`scopePaths exceeds ${FLOW_CONTRACT_LIMITS.maxScopePaths} entries`)
118
- const out = []
119
- for (const item of values) {
120
- const safe = normalizeScopePath(item)
121
- if (!safe) throw new Error(`invalid scope path: ${JSON.stringify(item)}`)
122
- if (!out.includes(safe)) out.push(safe)
123
- }
124
- if (required && out.length === 0) throw new Error('machine-readable scopePaths are required for builder/fix/scaffold tasks')
125
- return out.sort()
126
- }
127
-
128
- export function normalizeBaselineEvidence (value) {
129
- return boundedLine(value, 'baseline evidence receipt', FLOW_CONTRACT_LIMITS.maxBaselineEvidenceChars, { required: true })
130
- }
131
-
132
- // This is deliberately a small synchronous SHA-256 implementation instead of a
133
- // Node crypto import or WebCrypto: the task graph must stay byte-identical and run
134
- // in both the bridge's Node runtime and the browser bundle.
135
- function sha256Hex (message) {
136
- const source = new TextEncoder().encode(message)
137
- const totalLength = Math.ceil((source.length + 9) / 64) * 64
138
- const bytes = new Uint8Array(totalLength)
139
- bytes.set(source)
140
- bytes[source.length] = 0x80
141
- let bitLength = BigInt(source.length) * 8n
142
- for (let index = totalLength - 1; index >= totalLength - 8; index--) {
143
- bytes[index] = Number(bitLength & 0xffn)
144
- bitLength >>= 8n
145
- }
146
-
147
- const hash = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]
148
- const words = new Uint32Array(64)
149
- for (let offset = 0; offset < bytes.length; offset += 64) {
150
- for (let index = 0; index < 16; index++) {
151
- const start = offset + (index * 4)
152
- words[index] = ((bytes[start] << 24) | (bytes[start + 1] << 16) | (bytes[start + 2] << 8) | bytes[start + 3]) >>> 0
153
- }
154
- for (let index = 16; index < 64; index++) {
155
- const low = rotateRight(words[index - 15], 7) ^ rotateRight(words[index - 15], 18) ^ (words[index - 15] >>> 3)
156
- const high = rotateRight(words[index - 2], 17) ^ rotateRight(words[index - 2], 19) ^ (words[index - 2] >>> 10)
157
- words[index] = (words[index - 16] + low + words[index - 7] + high) >>> 0
158
- }
159
-
160
- let [a, b, c, d, e, f, g, h] = hash
161
- for (let index = 0; index < 64; index++) {
162
- const sigma1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25)
163
- const choose = (e & f) ^ (~e & g)
164
- const temp1 = (h + sigma1 + choose + SHA256_K[index] + words[index]) >>> 0
165
- const sigma0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22)
166
- const majority = (a & b) ^ (a & c) ^ (b & c)
167
- const temp2 = (sigma0 + majority) >>> 0
168
- h = g
169
- g = f
170
- f = e
171
- e = (d + temp1) >>> 0
172
- d = c
173
- c = b
174
- b = a
175
- a = (temp1 + temp2) >>> 0
176
- }
177
- hash[0] = (hash[0] + a) >>> 0
178
- hash[1] = (hash[1] + b) >>> 0
179
- hash[2] = (hash[2] + c) >>> 0
180
- hash[3] = (hash[3] + d) >>> 0
181
- hash[4] = (hash[4] + e) >>> 0
182
- hash[5] = (hash[5] + f) >>> 0
183
- hash[6] = (hash[6] + g) >>> 0
184
- hash[7] = (hash[7] + h) >>> 0
185
- }
186
- return hash.map((word) => word.toString(16).padStart(8, '0')).join('')
187
- }
188
-
189
- // The acceptance pack belongs to the builder target. A review task copies that
190
- // target's digest, so its inherited target is bound without re-hashing reviewer
191
- // prose or later baseline evidence receipts.
192
- function acceptancePack ({ key, title, scope, scopePaths, acceptance, sliceType, nonGoals, baselineGate, inheritedReviewerTarget = key, version = 2 }) {
193
- const pack = {
194
- version,
195
- task: { key, title, scope, acceptance, sliceType },
196
- nonGoals: [...nonGoals].sort(),
197
- baselineGate,
198
- inheritedReviewerTarget,
199
- }
200
- if (version >= 2) pack.task.scopePaths = normalizeTaskScopePaths(scopePaths, { fallbackScope: scope })
201
- return pack
202
- }
203
-
204
- export function acceptancePackDigest (input) {
205
- return sha256Hex(JSON.stringify(acceptancePack(input)))
206
- }
207
-
208
- function contractSource (task) {
209
- const source = task?.contract && typeof task.contract === 'object' && !Array.isArray(task.contract)
210
- ? { ...task.contract }
211
- : {}
212
- if (source.nonGoals === undefined) source.nonGoals = task?.nonGoals ?? task?.non_goals
213
- if (source.baseline === undefined) source.baseline = task?.baseline ?? task?.baselineGate ?? task?.baseline_gate
214
- if (source.scopePaths === undefined) source.scopePaths = task?.scopePaths ?? task?.scope_paths
215
- return source
216
- }
217
-
218
- function normalizeProvidedContractDigest (source) {
219
- if (source.digest === undefined) return null
220
- if (typeof source.digest !== 'string' || !CONTRACT_DIGEST.test(source.digest)) {
221
- throw new Error('contract digest must be a lowercase SHA-256 hex string')
222
- }
223
- return source.digest
224
- }
225
-
226
- function normalizeProvidedAcceptancePack (source) {
227
- if (source.acceptancePack === undefined) return null
228
- if (!source.acceptancePack || typeof source.acceptancePack !== 'object' || Array.isArray(source.acceptancePack)) {
229
- throw new Error('contract acceptance pack must be an object')
230
- }
231
- return source.acceptancePack
232
- }
233
-
234
- // Strict for new terminal submissions. `legacy:true` is restore-only: old persisted
235
- // plans predate this contract and must remain runnable, but they can never be used to
236
- // submit a new incomplete plan.
237
- export function normalizeTaskContract (task, { sliceType, legacy = false } = {}) {
238
- const source = contractSource(task)
239
- const digest = normalizeProvidedContractDigest(source)
240
- const sealedPack = normalizeProvidedAcceptancePack(source)
241
- if (sliceType === SLICE_TYPE.review) return legacy ? null : { digest, acceptancePack: sealedPack }
242
- const required = !legacy
243
- const rawNonGoals = source.nonGoals
244
- const values = rawNonGoals == null ? [] : (Array.isArray(rawNonGoals) ? rawNonGoals : [rawNonGoals])
245
- if (values.length > FLOW_CONTRACT_LIMITS.maxNonGoals) throw new Error(`non-goals exceeds ${FLOW_CONTRACT_LIMITS.maxNonGoals} items`)
246
- const nonGoals = values.map((value) => boundedLine(value, 'non-goal', FLOW_CONTRACT_LIMITS.maxNonGoalChars, { required: true }))
247
- if (required && !nonGoals.length) throw new Error('explicit non-goals are required for builder/fix/scaffold tasks')
248
-
249
- const rawBaseline = source.baseline
250
- const baselineObject = typeof rawBaseline === 'string' ? { gate: rawBaseline } : rawBaseline
251
- if (baselineObject != null && (typeof baselineObject !== 'object' || Array.isArray(baselineObject))) throw new Error('baseline gate must be a string or object')
252
- const gate = boundedLine(baselineObject?.gate ?? baselineObject?.expectation, 'baseline gate', FLOW_CONTRACT_LIMITS.maxBaselineGateChars, { required })
253
- if (gate && !BASELINE_NEGATIVE_SIGNAL.test(gate)) throw new Error('baseline gate must state what fails or is absent before implementation')
254
- const evidence = baselineObject?.evidence == null || baselineObject?.evidence === ''
255
- ? ''
256
- : normalizeBaselineEvidence(baselineObject.evidence)
257
- const scopePaths = normalizeTaskScopePaths(source.scopePaths, { fallbackScope: task?.scope ?? task?.description ?? '', required })
258
- return { nonGoals, baseline: gate ? { gate, evidence } : null, scopePaths, digest, acceptancePack: sealedPack }
259
- }
260
-
261
- function bindContractDigest (task, { legacy = false } = {}) {
262
- const sealedPack = acceptancePack({
263
- key: task.key,
264
- title: task.title,
265
- scope: task.scope,
266
- scopePaths: task.contract.scopePaths,
267
- acceptance: task.acceptance,
268
- sliceType: task.sliceType,
269
- nonGoals: task.contract.nonGoals,
270
- baselineGate: task.contract.baseline?.gate || '',
271
- inheritedReviewerTarget: task.key,
272
- version: legacy ? 1 : 2,
273
- })
274
- const digest = sha256Hex(JSON.stringify(sealedPack))
275
- if (task.contract.digest && task.contract.digest !== digest) throw new Error(`task "${task.key}" contract digest does not match its acceptance pack`)
276
- if (task.contract.acceptancePack && JSON.stringify(task.contract.acceptancePack) !== JSON.stringify(sealedPack)) {
277
- throw new Error(`task "${task.key}" sealed acceptance pack does not match its task fields`)
278
- }
279
- task.contract.digest = digest
280
- task.contract.acceptancePack = sealedPack
281
- }
282
-
283
- // The browser persists normalized task rows before approval, then later sends a
284
- // ready row back to the bridge for dispatch. Recompute the seal at that final
285
- // boundary so changed acceptance/scope/non-goals can never ride beside an old hash.
286
- export function validateTaskContractSeal (task, { legacy = false } = {}) {
287
- const contract = task?.contract
288
- if (!contract?.digest && legacy) return true
289
- if (!contract || typeof contract !== 'object' || !contract.digest || !contract.acceptancePack) {
290
- throw new Error('task is missing its sealed acceptance contract')
291
- }
292
- const pack = contract.acceptancePack
293
- const packTask = pack?.task
294
- if (!packTask || typeof packTask !== 'object' || !Array.isArray(pack.nonGoals)) {
295
- throw new Error('task sealed acceptance pack is malformed')
296
- }
297
- const canonicalPack = acceptancePack({
298
- key: packTask.key,
299
- title: packTask.title,
300
- scope: packTask.scope,
301
- scopePaths: packTask.scopePaths,
302
- acceptance: packTask.acceptance,
303
- sliceType: packTask.sliceType,
304
- nonGoals: pack.nonGoals,
305
- baselineGate: pack.baselineGate,
306
- inheritedReviewerTarget: pack.inheritedReviewerTarget,
307
- version: pack.version === 1 ? 1 : 2,
308
- })
309
- if (JSON.stringify(pack) !== JSON.stringify(canonicalPack)) throw new Error('task sealed acceptance pack is not canonical')
310
- if (acceptancePackDigest({
311
- key: packTask.key,
312
- title: packTask.title,
313
- scope: packTask.scope,
314
- scopePaths: packTask.scopePaths,
315
- acceptance: packTask.acceptance,
316
- sliceType: packTask.sliceType,
317
- nonGoals: pack.nonGoals,
318
- baselineGate: pack.baselineGate,
319
- inheritedReviewerTarget: pack.inheritedReviewerTarget,
320
- version: pack.version === 1 ? 1 : 2,
321
- }) !== contract.digest) throw new Error('task acceptance digest does not match its sealed pack')
322
-
323
- const sliceType = task.sliceType ?? task.slice_type
324
- const key = task.key ?? task.task_key
325
- const deps = Array.isArray(task.deps) ? task.deps : []
326
- if (sliceType === SLICE_TYPE.review) {
327
- if (contract.inheritedFrom !== packTask.key || deps.length !== 1 || deps[0] !== packTask.key) {
328
- throw new Error('review task no longer targets its sealed builder')
329
- }
330
- if (pack.inheritedReviewerTarget !== packTask.key || task.acceptance !== packTask.acceptance) {
331
- throw new Error(`review task "${key}" changed after its builder acceptance pack was sealed`)
332
- }
333
- return true
334
- }
335
- const livePack = acceptancePack({
336
- key,
337
- title: task.title,
338
- scope: task.scope ?? '',
339
- scopePaths: contract.scopePaths,
340
- acceptance: task.acceptance ?? '',
341
- sliceType,
342
- nonGoals: contract.nonGoals || [],
343
- baselineGate: contract.baseline?.gate || '',
344
- inheritedReviewerTarget: key,
345
- version: pack.version === 1 ? 1 : 2,
346
- })
347
- if (JSON.stringify(livePack) !== JSON.stringify(canonicalPack)) {
348
- throw new Error(`task "${key}" changed after its acceptance pack was sealed`)
349
- }
350
- return true
351
- }
352
-
353
- // A FlowTask slice. Each is RUNNABLE — a lane can build + run + self-correct it in
354
- // its own WebContainer (research: the runtime is the moat). Partition by file
355
- // ownership (scope) so two lanes never edit the same file (a branch is NOT isolation).
356
- export function makeTask ({ key, title, scope = '', acceptance = '', deps = [], sliceType = SLICE_TYPE.feature, contract = null }) {
357
- if (!key || typeof key !== 'string') throw new Error('task key required (string)')
358
- if (!title) throw new Error('task title required')
359
- if (!Array.isArray(deps)) throw new Error('deps must be an array of task keys')
360
- if (!Object.values(SLICE_TYPE).includes(sliceType)) throw new Error(`unknown sliceType: ${sliceType}`)
361
- return {
362
- key,
363
- title,
364
- scope, // files/components this lane OWNS (disjoint from other slices)
365
- acceptance, // runnable proof the lane + reviewer use to call it done
366
- contract, // bounded gate-first contract; null only for legacy/restored rows
367
- deps, // task keys this depends on (must form a DAG)
368
- sliceType,
369
- status: TASK_STATUS.pending,
370
- laneId: null, // the spawned lane's terminal id, once dispatched
371
- commitSha: null, // per-lane atomic commit (revert target)
372
- failCount: 0, // adaptive-escalation counter (Autopilot pulls human in at N)
373
- }
374
- }
375
-
376
- // Validate that tasks form a real DAG: unique keys, every dep references an existing
377
- // key, no self-deps, no cycles. Throws on invalid. Returns the tasks (chainable).
378
- export function validateDag (tasks) {
379
- if (!Array.isArray(tasks) || tasks.length === 0) throw new Error('at least one task required')
380
- const keys = new Set(tasks.map(t => t.key))
381
- if (keys.size !== tasks.length) throw new Error('duplicate task keys')
382
- const byKey = new Map(tasks.map(t => [t.key, t]))
383
- for (const t of tasks) {
384
- if (t.deps.includes(t.key)) throw new Error(`task "${t.key}" depends on itself`)
385
- for (const d of t.deps) {
386
- if (!keys.has(d)) throw new Error(`task "${t.key}" depends on unknown task "${d}"`)
387
- }
388
- }
389
- // Cycle detection (DFS, three-color). byKey guaranteed complete by the checks above.
390
- const WHITE = 0, GRAY = 1, BLACK = 2
391
- const color = new Map(tasks.map(t => [t.key, WHITE]))
392
- function visit (k) {
393
- color.set(k, GRAY)
394
- for (const d of byKey.get(k).deps) {
395
- const c = color.get(d)
396
- if (c === GRAY) throw new Error(`cycle detected at "${k}" → "${d}"`)
397
- if (c === WHITE) visit(d)
398
- }
399
- color.set(k, BLACK)
400
- }
401
- for (const t of tasks) if (color.get(t.key) === WHITE) visit(t.key)
402
- return tasks
403
- }
404
-
405
- // Tasks whose deps are all DONE → ready to dispatch. Pure.
406
- export function readyTasks (tasks) {
407
- const done = new Set(tasks.filter(t => t.status === TASK_STATUS.done).map(t => t.key))
408
- return tasks.filter(t =>
409
- t.status === TASK_STATUS.pending &&
410
- t.deps.every(d => done.has(d))
411
- )
412
- }
413
-
414
- // Topological order (Kahn's algorithm). Returns keys. Throws if not a DAG.
415
- export function topoOrder (tasks) {
416
- validateDag(tasks)
417
- const indeg = new Map(tasks.map(t => [t.key, t.deps.length]))
418
- const dependents = new Map(tasks.map(t => [t.key, []])) // dep-key → keys that depend on it
419
- for (const t of tasks) for (const d of t.deps) dependents.get(d).push(t.key)
420
- const queue = tasks.filter(t => indeg.get(t.key) === 0).map(t => t.key)
421
- const order = []
422
- while (queue.length) {
423
- const k = queue.shift()
424
- order.push(k)
425
- for (const m of dependents.get(k)) {
426
- indeg.set(m, indeg.get(m) - 1)
427
- if (indeg.get(m) === 0) queue.push(m)
428
- }
429
- }
430
- if (order.length !== tasks.length) throw new Error('cycle detected (topo incomplete)')
431
- return order
432
- }
433
-
434
- // A FlowSession (the run). `tasks` is the decomposed plan (FlowTask[]).
435
- export function makeFlowSession ({ prompt, mode = FLOW_MODE.guide, sessionRef = null, budgetCapAutopilot = null }) {
436
- if (!prompt) throw new Error('prompt required')
437
- if (!Object.values(FLOW_MODE).includes(mode)) throw new Error(`unknown mode: ${mode}`)
438
- return {
439
- prompt,
440
- mode,
441
- sessionRef, // the /code room session id this Flow runs in
442
- status: FLOW_STATUS.planning,
443
- budgetCapAutopilot, // token/step cap (Autopilot only); null in guide/steer
444
- createdAt: null, // stamped by the DB
445
- tasks: [],
446
- }
447
- }
448
-
449
- // Normalize the conductor's raw plan output into a validated { summary, tasks }.
450
- // Accepts a parsed object OR a JSON string (optionally ```json-fenced). Throws if the
451
- // tasks don't form a real DAG. Lives in this shared module so neither the bridge nor
452
- // the client has to import across the bridge↔src boundary.
453
- export function normalizePlanOutput (raw, { legacy = false } = {}) {
454
- let obj = raw
455
- if (typeof raw === 'string') {
456
- let s = raw.trim()
457
- const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i)
458
- if (fence) s = fence[1].trim()
459
- obj = JSON.parse(s)
460
- }
461
- if (!obj || typeof obj !== 'object') throw new Error('plan output is not an object')
462
- const summary = typeof obj.summary === 'string' && obj.summary.trim() ? obj.summary.trim() : ''
463
- if (!Array.isArray(obj.tasks) || obj.tasks.length === 0) throw new Error('plan has no tasks')
464
- const tasks = obj.tasks.map((t) => {
465
- // Accept `id` as an alias for `key` and `description` for `scope` — conductors emit
466
- // either naming. A task with NO key is rejected (was silently String(undefined) →
467
- // task_key "undefined", which broke worktree/dispatch); throwing makes it re-emit.
468
- const key = t.key ?? t.id
469
- if (key == null || !String(key).trim()) throw new Error('a task is missing its "key"')
470
- const sliceType = t.sliceType ?? t.slice_type
471
- return makeTask({
472
- key: String(key),
473
- title: String(t.title ?? key),
474
- scope: String(t.scope ?? t.description ?? ''),
475
- acceptance: String(t.acceptance ?? ''),
476
- deps: Array.isArray(t.deps) ? t.deps.map(String) : [],
477
- sliceType: Object.values(SLICE_TYPE).includes(sliceType) ? sliceType : SLICE_TYPE.feature,
478
- contract: normalizeTaskContract(t, { sliceType: Object.values(SLICE_TYPE).includes(sliceType) ? sliceType : SLICE_TYPE.feature, legacy }),
479
- })
480
- })
481
- validateDag(tasks)
482
- for (const task of tasks) {
483
- if (task.sliceType !== SLICE_TYPE.review) bindContractDigest(task, { legacy })
484
- }
485
- for (const task of tasks) {
486
- if (task.sliceType !== SLICE_TYPE.review) continue
487
- // New review tasks verify exactly one builder contract. Legacy persisted reviews
488
- // keep their prior multi-target shape for restore compatibility only.
489
- if (legacy) continue
490
- if (task.deps.length !== 1) throw new Error(`review task "${task.key}" must depend on exactly one builder task to inherit its contract`)
491
- const target = tasks.find((candidate) => candidate.key === task.deps[0])
492
- if (!target || target.sliceType === SLICE_TYPE.review) throw new Error(`review task "${task.key}" must target a builder/fix/scaffold task`)
493
- if (task.contract.digest && task.contract.digest !== target.contract.digest) {
494
- throw new Error(`review task "${task.key}" contract digest does not match target "${target.key}"`)
495
- }
496
- task.contract = {
497
- nonGoals: [...(target.contract?.nonGoals || [])],
498
- baseline: target.contract?.baseline ? { ...target.contract.baseline } : null,
499
- scopePaths: [...(target.contract?.scopePaths || [])],
500
- inheritedFrom: target.key,
501
- digest: target.contract.digest,
502
- acceptancePack: target.contract.acceptancePack,
503
- }
504
- // Reviewer-authored prose is not an authority boundary. Normalize the live
505
- // review row to the builder's sealed acceptance so dispatch and the prompt
506
- // cannot silently substitute a weaker criterion.
507
- task.acceptance = target.contract.acceptancePack.task.acceptance
508
- }
509
- return { summary, tasks }
510
- }
511
-
512
- // Codex review protocol deliberately maps one reviewer lane to one builder target.
513
- // Claude's legacy Flow plans may review several deps and remain unchanged.
514
- export function validatePlanForRuntime (plan, runtime = 'claude') {
515
- if (runtime !== 'codex' && runtime !== 'hermes') return plan
516
- for (const task of plan?.tasks || []) {
517
- if (task.sliceType === SLICE_TYPE.review && task.deps.length !== 1) {
518
- throw new Error(`${runtime === 'hermes' ? 'Hermes' : 'Codex'} review task "${task.key}" must depend on exactly one builder task`)
519
- }
520
- }
521
- return plan
522
- }
523
-
524
- export function validReviewTargetShape (task, runtime = 'claude') {
525
- if (!task || task.slice_type !== SLICE_TYPE.review) return true
526
- return (runtime !== 'codex' && runtime !== 'hermes') || (Array.isArray(task.deps) && task.deps.length === 1)
527
- }
528
-
529
- export function legacyBuilderCompletionAllowed ({ runtime, flowRole, eventKind, eventSubtype, interrupted = false } = {}) {
530
- return runtime === 'claude' && flowRole === 'builder' && !interrupted && eventKind === 'result' && eventSubtype === 'success'
531
- }
532
-
533
- // ── Flow lane model tiers ───────────────────────────────────────────────────
534
- // Spec: docs/specs/2026-07-03-flow-lane-model-tiers.md
535
- // The conductor thinks; the worker lanes type. Running every lane on the same
536
- // expensive brain is wasteful — the tier map picks a model per slice_type so
537
- // intelligence matches the job:
538
- // scaffold → sonnet (boilerplate / project bootstrap — mechanical, cheap)
539
- // feature → opus (real building — needs the smart brain)
540
- // fix → opus (root-cause debugging — needs the smart brain)
541
- // review → opus (adversarial verification is intelligence-SENSITIVE; never cheapen it)
542
- // Unknown/missing slice types fall to opus (safe default — never silently cheapen).
543
- // Dev-only, no product gating. The conductor's own model is untouched by this map
544
- // (bridge reads TP_FLOW_CONDUCTOR_MODEL at the conductor spawn site instead).
545
- //
546
- // Env overrides (checked FIRST, in order):
547
- // TP_FLOW_LANE_MODEL='inherit' → return undefined (no model key passed → today's exact behavior)
548
- // TP_FLOW_LANE_MODEL=<model> → blanket-override every lane to that model
549
- // (empty-string / unset) → fall through to the tier map
550
- const LANE_MODEL_TIERS = {
551
- scaffold: 'sonnet',
552
- feature: 'opus',
553
- fix: 'opus',
554
- review: 'opus',
555
- }
556
-
557
- export function laneModelFor (sliceType, env = process.env) {
558
- const override = env.TP_FLOW_LANE_MODEL
559
- if (override === 'inherit') return undefined // sentinel — restore today's behavior exactly
560
- if (override) return override // blanket override for all lanes (non-empty string)
561
- return LANE_MODEL_TIERS[sliceType] || 'opus' // tier map; unknown/missing → opus (never cheapen by accident)
562
- }
1
+ export const FLOW_STATUS={planning:"planning",awaiting_approval:"awaiting_approval",approved:"approved",building:"building",reviewing:"reviewing",assembling:"assembling",done:"done",failed:"failed",cancelled:"cancelled"};export const TASK_STATUS={pending:"pending",ready:"ready",dispatched:"dispatched",reviewing:"reviewing",done:"done",failed:"failed",reverted:"reverted"};export const FLOW_MODE={steer:"steer",guide:"guide",autopilot:"autopilot"};export const SLICE_TYPE={feature:"feature",scaffold:"scaffold",review:"review",fix:"fix"};export const FLOW_CONTRACT_LIMITS=Object.freeze({maxNonGoals:6,maxNonGoalChars:240,maxBaselineGateChars:600,maxBaselineEvidenceChars:1400,maxScopePaths:32,maxScopePathChars:180});const e=/\b(fail(?:s|ed|ing)?|absent|missing|not\s+(?:present|implemented|available|found)|does\s+not|404|empty)\b/i,t=/^[a-f0-9]{64}$/,n=[1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298],r=(e,t)=>e>>>t|e<<32-t;function a(e,t,n,{required:r=!1}={}){if(null==e||""===e){if(r)throw new Error(`${t} is required`);return""}if("string"!=typeof e)throw new Error(`${t} must be a string`);const a=e.trim();if(!a){if(r)throw new Error(`${t} is required`);return""}if(a.length>n)throw new Error(`${t} exceeds ${n} characters`);if((e=>{for(const t of e){const e=t.charCodeAt(0);if(127===e||e<32&&9!==e)return!0}return!1})(a))throw new Error(`${t} must be safe single-line text`);return a}function s(e){if("string"!=typeof e)return null;const t=e.trim().replaceAll("\\","/");if(!t||t.length>FLOW_CONTRACT_LIMITS.maxScopePathChars||t.startsWith("/")||/^[A-Za-z]:\//.test(t))return null;const n=t.endsWith("/**");if(t.includes("*")&&!n)return null;const r=n?t.slice(0,-3):t;if(!r||r.endsWith("/")||r.split("/").some(e=>!e||"."===e||".."===e))return null;if(/(?:^|\/)(?:\.env(?:\.|$)|\.git(?:\/|$)|\.thinkpool(?:\/|$)|node_modules(?:\/|$))/.test(r))return null;const a=r.split("/").at(-1);return n||a.includes(".")||/^(?:Dockerfile|Makefile|LICENSE|README)$/i.test(a)?n?`${r}/**`:r:null}export function normalizeTaskScopePaths(e,{fallbackScope:t="",required:n=!0}={}){let r=e;if(null==r&&t){const e=s(t);e&&(r=[e])}if(null==r&&(r=[]),!Array.isArray(r))throw new Error("scopePaths must be an array");if(r.length>FLOW_CONTRACT_LIMITS.maxScopePaths)throw new Error(`scopePaths exceeds ${FLOW_CONTRACT_LIMITS.maxScopePaths} entries`);const a=[];for(const e of r){const t=s(e);if(!t)throw new Error(`invalid scope path: ${JSON.stringify(e)}`);a.includes(t)||a.push(t)}if(n&&0===a.length)throw new Error("machine-readable scopePaths are required for builder/fix/scaffold tasks");return a.sort()}export function normalizeBaselineEvidence(e){return a(e,"baseline evidence receipt",FLOW_CONTRACT_LIMITS.maxBaselineEvidenceChars,{required:!0})}function o(e){const t=(new TextEncoder).encode(e),a=64*Math.ceil((t.length+9)/64),s=new Uint8Array(a);s.set(t),s[t.length]=128;let o=8n*BigInt(t.length);for(let e=a-1;e>=a-8;e--)s[e]=Number(0xffn&o),o>>=8n;const c=[1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225],i=new Uint32Array(64);for(let e=0;e<s.length;e+=64){for(let t=0;t<16;t++){const n=e+4*t;i[t]=(s[n]<<24|s[n+1]<<16|s[n+2]<<8|s[n+3])>>>0}for(let e=16;e<64;e++){const t=r(i[e-15],7)^r(i[e-15],18)^i[e-15]>>>3,n=r(i[e-2],17)^r(i[e-2],19)^i[e-2]>>>10;i[e]=i[e-16]+t+i[e-7]+n>>>0}let[t,a,o,l,p,d,f,u]=c;for(let e=0;e<64;e++){const s=u+(r(p,6)^r(p,11)^r(p,25))+(p&d^~p&f)+n[e]+i[e]>>>0,c=t&a^t&o^a&o;u=f,f=d,d=p,p=l+s>>>0,l=o,o=a,a=t,t=s+((r(t,2)^r(t,13)^r(t,22))+c>>>0)>>>0}c[0]=c[0]+t>>>0,c[1]=c[1]+a>>>0,c[2]=c[2]+o>>>0,c[3]=c[3]+l>>>0,c[4]=c[4]+p>>>0,c[5]=c[5]+d>>>0,c[6]=c[6]+f>>>0,c[7]=c[7]+u>>>0}return c.map(e=>e.toString(16).padStart(8,"0")).join("")}function c({key:e,title:t,scope:n,scopePaths:r,acceptance:a,sliceType:s,nonGoals:o,baselineGate:c,inheritedReviewerTarget:i=e,version:l=2}){const p={version:l,task:{key:e,title:t,scope:n,acceptance:a,sliceType:s},nonGoals:[...o].sort(),baselineGate:c,inheritedReviewerTarget:i};return l>=2&&(p.task.scopePaths=normalizeTaskScopePaths(r,{fallbackScope:n})),p}export function acceptancePackDigest(e){return o(JSON.stringify(c(e)))}export function normalizeTaskContract(n,{sliceType:r,legacy:s=!1}={}){const o=function(e){const t=e?.contract&&"object"==typeof e.contract&&!Array.isArray(e.contract)?{...e.contract}:{};return void 0===t.nonGoals&&(t.nonGoals=e?.nonGoals??e?.non_goals),void 0===t.baseline&&(t.baseline=e?.baseline??e?.baselineGate??e?.baseline_gate),void 0===t.scopePaths&&(t.scopePaths=e?.scopePaths??e?.scope_paths),t}(n),c=function(e){if(void 0===e.digest)return null;if("string"!=typeof e.digest||!t.test(e.digest))throw new Error("contract digest must be a lowercase SHA-256 hex string");return e.digest}(o),i=function(e){if(void 0===e.acceptancePack)return null;if(!e.acceptancePack||"object"!=typeof e.acceptancePack||Array.isArray(e.acceptancePack))throw new Error("contract acceptance pack must be an object");return e.acceptancePack}(o);if(r===SLICE_TYPE.review)return s?null:{digest:c,acceptancePack:i};const l=!s,p=o.nonGoals,d=null==p?[]:Array.isArray(p)?p:[p];if(d.length>FLOW_CONTRACT_LIMITS.maxNonGoals)throw new Error(`non-goals exceeds ${FLOW_CONTRACT_LIMITS.maxNonGoals} items`);const f=d.map(e=>a(e,"non-goal",FLOW_CONTRACT_LIMITS.maxNonGoalChars,{required:!0}));if(l&&!f.length)throw new Error("explicit non-goals are required for builder/fix/scaffold tasks");const u=o.baseline,g="string"==typeof u?{gate:u}:u;if(null!=g&&("object"!=typeof g||Array.isArray(g)))throw new Error("baseline gate must be a string or object");const h=a(g?.gate??g?.expectation,"baseline gate",FLOW_CONTRACT_LIMITS.maxBaselineGateChars,{required:l});if(h&&!e.test(h))throw new Error("baseline gate must state what fails or is absent before implementation");const y=null==g?.evidence||""===g?.evidence?"":normalizeBaselineEvidence(g.evidence);return{nonGoals:f,baseline:h?{gate:h,evidence:y}:null,scopePaths:normalizeTaskScopePaths(o.scopePaths,{fallbackScope:n?.scope??n?.description??"",required:l}),digest:c,acceptancePack:i}}function i(e,{legacy:t=!1}={}){const n=c({key:e.key,title:e.title,scope:e.scope,scopePaths:e.contract.scopePaths,acceptance:e.acceptance,sliceType:e.sliceType,nonGoals:e.contract.nonGoals,baselineGate:e.contract.baseline?.gate||"",inheritedReviewerTarget:e.key,version:t?1:2}),r=o(JSON.stringify(n));if(e.contract.digest&&e.contract.digest!==r)throw new Error(`task "${e.key}" contract digest does not match its acceptance pack`);if(e.contract.acceptancePack&&JSON.stringify(e.contract.acceptancePack)!==JSON.stringify(n))throw new Error(`task "${e.key}" sealed acceptance pack does not match its task fields`);e.contract.digest=r,e.contract.acceptancePack=n}export function validateTaskContractSeal(e,{legacy:t=!1}={}){const n=e?.contract;if(!n?.digest&&t)return!0;if(!n||"object"!=typeof n||!n.digest||!n.acceptancePack)throw new Error("task is missing its sealed acceptance contract");const r=n.acceptancePack,a=r?.task;if(!a||"object"!=typeof a||!Array.isArray(r.nonGoals))throw new Error("task sealed acceptance pack is malformed");const s=c({key:a.key,title:a.title,scope:a.scope,scopePaths:a.scopePaths,acceptance:a.acceptance,sliceType:a.sliceType,nonGoals:r.nonGoals,baselineGate:r.baselineGate,inheritedReviewerTarget:r.inheritedReviewerTarget,version:1===r.version?1:2});if(JSON.stringify(r)!==JSON.stringify(s))throw new Error("task sealed acceptance pack is not canonical");if(acceptancePackDigest({key:a.key,title:a.title,scope:a.scope,scopePaths:a.scopePaths,acceptance:a.acceptance,sliceType:a.sliceType,nonGoals:r.nonGoals,baselineGate:r.baselineGate,inheritedReviewerTarget:r.inheritedReviewerTarget,version:1===r.version?1:2})!==n.digest)throw new Error("task acceptance digest does not match its sealed pack");const o=e.sliceType??e.slice_type,i=e.key??e.task_key,l=Array.isArray(e.deps)?e.deps:[];if(o===SLICE_TYPE.review){if(n.inheritedFrom!==a.key||1!==l.length||l[0]!==a.key)throw new Error("review task no longer targets its sealed builder");if(r.inheritedReviewerTarget!==a.key||e.acceptance!==a.acceptance)throw new Error(`review task "${i}" changed after its builder acceptance pack was sealed`);return!0}const p=c({key:i,title:e.title,scope:e.scope??"",scopePaths:n.scopePaths,acceptance:e.acceptance??"",sliceType:o,nonGoals:n.nonGoals||[],baselineGate:n.baseline?.gate||"",inheritedReviewerTarget:i,version:1===r.version?1:2});if(JSON.stringify(p)!==JSON.stringify(s))throw new Error(`task "${i}" changed after its acceptance pack was sealed`);return!0}export function makeTask({key:e,title:t,scope:n="",acceptance:r="",deps:a=[],sliceType:s=SLICE_TYPE.feature,contract:o=null}){if(!e||"string"!=typeof e)throw new Error("task key required (string)");if(!t)throw new Error("task title required");if(!Array.isArray(a))throw new Error("deps must be an array of task keys");if(!Object.values(SLICE_TYPE).includes(s))throw new Error(`unknown sliceType: ${s}`);return{key:e,title:t,scope:n,acceptance:r,contract:o,deps:a,sliceType:s,status:TASK_STATUS.pending,laneId:null,commitSha:null,failCount:0}}export function validateDag(e){if(!Array.isArray(e)||0===e.length)throw new Error("at least one task required");const t=new Set(e.map(e=>e.key));if(t.size!==e.length)throw new Error("duplicate task keys");const n=new Map(e.map(e=>[e.key,e]));for(const n of e){if(n.deps.includes(n.key))throw new Error(`task "${n.key}" depends on itself`);for(const e of n.deps)if(!t.has(e))throw new Error(`task "${n.key}" depends on unknown task "${e}"`)}const r=new Map(e.map(e=>[e.key,0]));function a(e){r.set(e,1);for(const t of n.get(e).deps){const n=r.get(t);if(1===n)throw new Error(`cycle detected at "${e}" → "${t}"`);0===n&&a(t)}r.set(e,2)}for(const t of e)0===r.get(t.key)&&a(t.key);return e}export function readyTasks(e){const t=new Set(e.filter(e=>e.status===TASK_STATUS.done).map(e=>e.key));return e.filter(e=>e.status===TASK_STATUS.pending&&e.deps.every(e=>t.has(e)))}export function topoOrder(e){validateDag(e);const t=new Map(e.map(e=>[e.key,e.deps.length])),n=new Map(e.map(e=>[e.key,[]]));for(const t of e)for(const e of t.deps)n.get(e).push(t.key);const r=e.filter(e=>0===t.get(e.key)).map(e=>e.key),a=[];for(;r.length;){const e=r.shift();a.push(e);for(const a of n.get(e))t.set(a,t.get(a)-1),0===t.get(a)&&r.push(a)}if(a.length!==e.length)throw new Error("cycle detected (topo incomplete)");return a}export function makeFlowSession({prompt:e,mode:t=FLOW_MODE.guide,sessionRef:n=null,budgetCapAutopilot:r=null}){if(!e)throw new Error("prompt required");if(!Object.values(FLOW_MODE).includes(t))throw new Error(`unknown mode: ${t}`);return{prompt:e,mode:t,sessionRef:n,status:FLOW_STATUS.planning,budgetCapAutopilot:r,createdAt:null,tasks:[]}}export function normalizePlanOutput(e,{legacy:t=!1}={}){let n=e;if("string"==typeof e){let t=e.trim();const r=t.match(/```(?:json)?\s*([\s\S]*?)```/i);r&&(t=r[1].trim()),n=JSON.parse(t)}if(!n||"object"!=typeof n)throw new Error("plan output is not an object");const r="string"==typeof n.summary&&n.summary.trim()?n.summary.trim():"";if(!Array.isArray(n.tasks)||0===n.tasks.length)throw new Error("plan has no tasks");const a=n.tasks.map(e=>{const n=e.key??e.id;if(null==n||!String(n).trim())throw new Error('a task is missing its "key"');const r=e.sliceType??e.slice_type;return makeTask({key:String(n),title:String(e.title??n),scope:String(e.scope??e.description??""),acceptance:String(e.acceptance??""),deps:Array.isArray(e.deps)?e.deps.map(String):[],sliceType:Object.values(SLICE_TYPE).includes(r)?r:SLICE_TYPE.feature,contract:normalizeTaskContract(e,{sliceType:Object.values(SLICE_TYPE).includes(r)?r:SLICE_TYPE.feature,legacy:t})})});validateDag(a);for(const e of a)e.sliceType!==SLICE_TYPE.review&&i(e,{legacy:t});for(const e of a){if(e.sliceType!==SLICE_TYPE.review)continue;if(t)continue;if(1!==e.deps.length)throw new Error(`review task "${e.key}" must depend on exactly one builder task to inherit its contract`);const n=a.find(t=>t.key===e.deps[0]);if(!n||n.sliceType===SLICE_TYPE.review)throw new Error(`review task "${e.key}" must target a builder/fix/scaffold task`);if(e.contract.digest&&e.contract.digest!==n.contract.digest)throw new Error(`review task "${e.key}" contract digest does not match target "${n.key}"`);e.contract={nonGoals:[...n.contract?.nonGoals||[]],baseline:n.contract?.baseline?{...n.contract.baseline}:null,scopePaths:[...n.contract?.scopePaths||[]],inheritedFrom:n.key,digest:n.contract.digest,acceptancePack:n.contract.acceptancePack},e.acceptance=n.contract.acceptancePack.task.acceptance}return{summary:r,tasks:a}}export function validatePlanForRuntime(e,t="claude"){if("codex"!==t&&"hermes"!==t)return e;for(const n of e?.tasks||[])if(n.sliceType===SLICE_TYPE.review&&1!==n.deps.length)throw new Error(`${"hermes"===t?"Hermes":"Codex"} review task "${n.key}" must depend on exactly one builder task`);return e}export function validReviewTargetShape(e,t="claude"){return!e||e.slice_type!==SLICE_TYPE.review||"codex"!==t&&"hermes"!==t||Array.isArray(e.deps)&&1===e.deps.length}export function legacyBuilderCompletionAllowed({runtime:e,flowRole:t,eventKind:n,eventSubtype:r,interrupted:a=!1}={}){return"claude"===e&&"builder"===t&&!a&&"result"===n&&"success"===r}const l={scaffold:"sonnet",feature:"opus",fix:"opus",review:"opus"};export function laneModelFor(e,t=process.env){const n=t.TP_FLOW_LANE_MODEL;if("inherit"!==n)return n||l[e]||"opus"}