@ashlr/hub 2.2.0

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 (477) hide show
  1. package/CHANGELOG.md +1158 -0
  2. package/LICENSE +21 -0
  3. package/README.md +833 -0
  4. package/bin/ashlr +8 -0
  5. package/dist/api/core.d.ts +28 -0
  6. package/dist/api/core.js +42 -0
  7. package/dist/api/core.js.map +1 -0
  8. package/dist/api/index.d.ts +9 -0
  9. package/dist/api/index.js +9 -0
  10. package/dist/api/index.js.map +1 -0
  11. package/dist/api/plugin.d.ts +12 -0
  12. package/dist/api/plugin.js +13 -0
  13. package/dist/api/plugin.js.map +1 -0
  14. package/dist/api/types.d.ts +9 -0
  15. package/dist/api/types.js +9 -0
  16. package/dist/api/types.js.map +1 -0
  17. package/dist/cli/args.d.ts +20 -0
  18. package/dist/cli/args.js +23 -0
  19. package/dist/cli/args.js.map +1 -0
  20. package/dist/cli/ask.d.ts +22 -0
  21. package/dist/cli/ask.js +240 -0
  22. package/dist/cli/ask.js.map +1 -0
  23. package/dist/cli/audit.d.ts +26 -0
  24. package/dist/cli/audit.js +199 -0
  25. package/dist/cli/audit.js.map +1 -0
  26. package/dist/cli/backlog.d.ts +26 -0
  27. package/dist/cli/backlog.js +322 -0
  28. package/dist/cli/backlog.js.map +1 -0
  29. package/dist/cli/completions.d.ts +18 -0
  30. package/dist/cli/completions.js +156 -0
  31. package/dist/cli/completions.js.map +1 -0
  32. package/dist/cli/daemon.d.ts +31 -0
  33. package/dist/cli/daemon.js +334 -0
  34. package/dist/cli/daemon.js.map +1 -0
  35. package/dist/cli/demo-sandbox.d.ts +78 -0
  36. package/dist/cli/demo-sandbox.js +227 -0
  37. package/dist/cli/demo-sandbox.js.map +1 -0
  38. package/dist/cli/demo.d.ts +76 -0
  39. package/dist/cli/demo.js +285 -0
  40. package/dist/cli/demo.js.map +1 -0
  41. package/dist/cli/digest.d.ts +46 -0
  42. package/dist/cli/digest.js +352 -0
  43. package/dist/cli/digest.js.map +1 -0
  44. package/dist/cli/doctor-init.d.ts +28 -0
  45. package/dist/cli/doctor-init.js +335 -0
  46. package/dist/cli/doctor-init.js.map +1 -0
  47. package/dist/cli/genome.d.ts +62 -0
  48. package/dist/cli/genome.js +1061 -0
  49. package/dist/cli/genome.js.map +1 -0
  50. package/dist/cli/gh.d.ts +27 -0
  51. package/dist/cli/gh.js +322 -0
  52. package/dist/cli/gh.js.map +1 -0
  53. package/dist/cli/goals.d.ts +55 -0
  54. package/dist/cli/goals.js +667 -0
  55. package/dist/cli/goals.js.map +1 -0
  56. package/dist/cli/health.d.ts +56 -0
  57. package/dist/cli/health.js +407 -0
  58. package/dist/cli/health.js.map +1 -0
  59. package/dist/cli/help.d.ts +51 -0
  60. package/dist/cli/help.js +436 -0
  61. package/dist/cli/help.js.map +1 -0
  62. package/dist/cli/inbox.d.ts +28 -0
  63. package/dist/cli/inbox.js +589 -0
  64. package/dist/cli/inbox.js.map +1 -0
  65. package/dist/cli/index.d.ts +41 -0
  66. package/dist/cli/index.js +1208 -0
  67. package/dist/cli/index.js.map +1 -0
  68. package/dist/cli/knowledge.d.ts +33 -0
  69. package/dist/cli/knowledge.js +575 -0
  70. package/dist/cli/knowledge.js.map +1 -0
  71. package/dist/cli/mcp.d.ts +26 -0
  72. package/dist/cli/mcp.js +487 -0
  73. package/dist/cli/mcp.js.map +1 -0
  74. package/dist/cli/models.d.ts +26 -0
  75. package/dist/cli/models.js +435 -0
  76. package/dist/cli/models.js.map +1 -0
  77. package/dist/cli/new.d.ts +32 -0
  78. package/dist/cli/new.js +565 -0
  79. package/dist/cli/new.js.map +1 -0
  80. package/dist/cli/notify.d.ts +20 -0
  81. package/dist/cli/notify.js +188 -0
  82. package/dist/cli/notify.js.map +1 -0
  83. package/dist/cli/onboard.d.ts +85 -0
  84. package/dist/cli/onboard.js +305 -0
  85. package/dist/cli/onboard.js.map +1 -0
  86. package/dist/cli/open.d.ts +38 -0
  87. package/dist/cli/open.js +86 -0
  88. package/dist/cli/open.js.map +1 -0
  89. package/dist/cli/orient.d.ts +14 -0
  90. package/dist/cli/orient.js +110 -0
  91. package/dist/cli/orient.js.map +1 -0
  92. package/dist/cli/picker.d.ts +21 -0
  93. package/dist/cli/picker.js +177 -0
  94. package/dist/cli/picker.js.map +1 -0
  95. package/dist/cli/plugins.d.ts +21 -0
  96. package/dist/cli/plugins.js +394 -0
  97. package/dist/cli/plugins.js.map +1 -0
  98. package/dist/cli/preflight.d.ts +29 -0
  99. package/dist/cli/preflight.js +131 -0
  100. package/dist/cli/preflight.js.map +1 -0
  101. package/dist/cli/pulse.d.ts +26 -0
  102. package/dist/cli/pulse.js +482 -0
  103. package/dist/cli/pulse.js.map +1 -0
  104. package/dist/cli/reflect.d.ts +42 -0
  105. package/dist/cli/reflect.js +381 -0
  106. package/dist/cli/reflect.js.map +1 -0
  107. package/dist/cli/run.d.ts +29 -0
  108. package/dist/cli/run.js +687 -0
  109. package/dist/cli/run.js.map +1 -0
  110. package/dist/cli/sandbox.d.ts +20 -0
  111. package/dist/cli/sandbox.js +345 -0
  112. package/dist/cli/sandbox.js.map +1 -0
  113. package/dist/cli/seams.d.ts +41 -0
  114. package/dist/cli/seams.js +167 -0
  115. package/dist/cli/seams.js.map +1 -0
  116. package/dist/cli/serve.d.ts +25 -0
  117. package/dist/cli/serve.js +249 -0
  118. package/dist/cli/serve.js.map +1 -0
  119. package/dist/cli/ship.d.ts +33 -0
  120. package/dist/cli/ship.js +433 -0
  121. package/dist/cli/ship.js.map +1 -0
  122. package/dist/cli/spec.d.ts +19 -0
  123. package/dist/cli/spec.js +478 -0
  124. package/dist/cli/spec.js.map +1 -0
  125. package/dist/cli/swarm.d.ts +42 -0
  126. package/dist/cli/swarm.js +1365 -0
  127. package/dist/cli/swarm.js.map +1 -0
  128. package/dist/cli/telemetry.d.ts +26 -0
  129. package/dist/cli/telemetry.js +364 -0
  130. package/dist/cli/telemetry.js.map +1 -0
  131. package/dist/cli/tui.d.ts +17 -0
  132. package/dist/cli/tui.js +52 -0
  133. package/dist/cli/tui.js.map +1 -0
  134. package/dist/cli/ui.d.ts +53 -0
  135. package/dist/cli/ui.js +66 -0
  136. package/dist/cli/ui.js.map +1 -0
  137. package/dist/cli/update.d.ts +38 -0
  138. package/dist/cli/update.js +682 -0
  139. package/dist/cli/update.js.map +1 -0
  140. package/dist/cli/vercel.d.ts +20 -0
  141. package/dist/cli/vercel.js +193 -0
  142. package/dist/cli/vercel.js.map +1 -0
  143. package/dist/cli/verify-safety.d.ts +96 -0
  144. package/dist/cli/verify-safety.js +509 -0
  145. package/dist/cli/verify-safety.js.map +1 -0
  146. package/dist/cli/wire.d.ts +22 -0
  147. package/dist/cli/wire.js +205 -0
  148. package/dist/cli/wire.js.map +1 -0
  149. package/dist/core/classify.d.ts +51 -0
  150. package/dist/core/classify.js +450 -0
  151. package/dist/core/classify.js.map +1 -0
  152. package/dist/core/config.d.ts +63 -0
  153. package/dist/core/config.js +474 -0
  154. package/dist/core/config.js.map +1 -0
  155. package/dist/core/daemon/loop.d.ts +74 -0
  156. package/dist/core/daemon/loop.js +618 -0
  157. package/dist/core/daemon/loop.js.map +1 -0
  158. package/dist/core/daemon/state.d.ts +66 -0
  159. package/dist/core/daemon/state.js +197 -0
  160. package/dist/core/daemon/state.js.map +1 -0
  161. package/dist/core/dashboard.d.ts +40 -0
  162. package/dist/core/dashboard.js +463 -0
  163. package/dist/core/dashboard.js.map +1 -0
  164. package/dist/core/digest/build.d.ts +51 -0
  165. package/dist/core/digest/build.js +230 -0
  166. package/dist/core/digest/build.js.map +1 -0
  167. package/dist/core/digest/deliver.d.ts +47 -0
  168. package/dist/core/digest/deliver.js +230 -0
  169. package/dist/core/digest/deliver.js.map +1 -0
  170. package/dist/core/digest/store.d.ts +57 -0
  171. package/dist/core/digest/store.js +223 -0
  172. package/dist/core/digest/store.js.map +1 -0
  173. package/dist/core/doctor-fix.d.ts +21 -0
  174. package/dist/core/doctor-fix.js +440 -0
  175. package/dist/core/doctor-fix.js.map +1 -0
  176. package/dist/core/doctor.d.ts +18 -0
  177. package/dist/core/doctor.js +806 -0
  178. package/dist/core/doctor.js.map +1 -0
  179. package/dist/core/env-bridge.d.ts +64 -0
  180. package/dist/core/env-bridge.js +103 -0
  181. package/dist/core/env-bridge.js.map +1 -0
  182. package/dist/core/genome/capture.d.ts +49 -0
  183. package/dist/core/genome/capture.js +352 -0
  184. package/dist/core/genome/capture.js.map +1 -0
  185. package/dist/core/genome/consolidate.d.ts +38 -0
  186. package/dist/core/genome/consolidate.js +426 -0
  187. package/dist/core/genome/consolidate.js.map +1 -0
  188. package/dist/core/genome/export.d.ts +29 -0
  189. package/dist/core/genome/export.js +102 -0
  190. package/dist/core/genome/export.js.map +1 -0
  191. package/dist/core/genome/playbook.d.ts +33 -0
  192. package/dist/core/genome/playbook.js +320 -0
  193. package/dist/core/genome/playbook.js.map +1 -0
  194. package/dist/core/genome/recall.d.ts +45 -0
  195. package/dist/core/genome/recall.js +293 -0
  196. package/dist/core/genome/recall.js.map +1 -0
  197. package/dist/core/genome/store.d.ts +62 -0
  198. package/dist/core/genome/store.js +711 -0
  199. package/dist/core/genome/store.js.map +1 -0
  200. package/dist/core/git.d.ts +34 -0
  201. package/dist/core/git.js +115 -0
  202. package/dist/core/git.js.map +1 -0
  203. package/dist/core/goals/advance.d.ts +82 -0
  204. package/dist/core/goals/advance.js +263 -0
  205. package/dist/core/goals/advance.js.map +1 -0
  206. package/dist/core/goals/planner.d.ts +58 -0
  207. package/dist/core/goals/planner.js +233 -0
  208. package/dist/core/goals/planner.js.map +1 -0
  209. package/dist/core/goals/store.d.ts +128 -0
  210. package/dist/core/goals/store.js +442 -0
  211. package/dist/core/goals/store.js.map +1 -0
  212. package/dist/core/inbox/apply.d.ts +36 -0
  213. package/dist/core/inbox/apply.js +407 -0
  214. package/dist/core/inbox/apply.js.map +1 -0
  215. package/dist/core/inbox/notify-proposal.d.ts +13 -0
  216. package/dist/core/inbox/notify-proposal.js +21 -0
  217. package/dist/core/inbox/notify-proposal.js.map +1 -0
  218. package/dist/core/inbox/store.d.ts +67 -0
  219. package/dist/core/inbox/store.js +258 -0
  220. package/dist/core/inbox/store.js.map +1 -0
  221. package/dist/core/index-engine.d.ts +62 -0
  222. package/dist/core/index-engine.js +486 -0
  223. package/dist/core/index-engine.js.map +1 -0
  224. package/dist/core/integrations/desktop-notify.d.ts +18 -0
  225. package/dist/core/integrations/desktop-notify.js +42 -0
  226. package/dist/core/integrations/desktop-notify.js.map +1 -0
  227. package/dist/core/integrations/editors.d.ts +50 -0
  228. package/dist/core/integrations/editors.js +216 -0
  229. package/dist/core/integrations/editors.js.map +1 -0
  230. package/dist/core/integrations/github.d.ts +66 -0
  231. package/dist/core/integrations/github.js +332 -0
  232. package/dist/core/integrations/github.js.map +1 -0
  233. package/dist/core/integrations/identity.d.ts +29 -0
  234. package/dist/core/integrations/identity.js +359 -0
  235. package/dist/core/integrations/identity.js.map +1 -0
  236. package/dist/core/integrations/notify.d.ts +23 -0
  237. package/dist/core/integrations/notify.js +96 -0
  238. package/dist/core/integrations/notify.js.map +1 -0
  239. package/dist/core/integrations/vercel.d.ts +37 -0
  240. package/dist/core/integrations/vercel.js +192 -0
  241. package/dist/core/integrations/vercel.js.map +1 -0
  242. package/dist/core/knowledge/ask.d.ts +34 -0
  243. package/dist/core/knowledge/ask.js +325 -0
  244. package/dist/core/knowledge/ask.js.map +1 -0
  245. package/dist/core/knowledge/graph.d.ts +51 -0
  246. package/dist/core/knowledge/graph.js +564 -0
  247. package/dist/core/knowledge/graph.js.map +1 -0
  248. package/dist/core/knowledge/index.d.ts +74 -0
  249. package/dist/core/knowledge/index.js +586 -0
  250. package/dist/core/knowledge/index.js.map +1 -0
  251. package/dist/core/learn/playbooks.d.ts +84 -0
  252. package/dist/core/learn/playbooks.js +241 -0
  253. package/dist/core/learn/playbooks.js.map +1 -0
  254. package/dist/core/learn/reflect.d.ts +87 -0
  255. package/dist/core/learn/reflect.js +435 -0
  256. package/dist/core/learn/reflect.js.map +1 -0
  257. package/dist/core/learn/store.d.ts +51 -0
  258. package/dist/core/learn/store.js +165 -0
  259. package/dist/core/learn/store.js.map +1 -0
  260. package/dist/core/learn/tuning.d.ts +48 -0
  261. package/dist/core/learn/tuning.js +201 -0
  262. package/dist/core/learn/tuning.js.map +1 -0
  263. package/dist/core/lifecycle/scaffold.d.ts +43 -0
  264. package/dist/core/lifecycle/scaffold.js +260 -0
  265. package/dist/core/lifecycle/scaffold.js.map +1 -0
  266. package/dist/core/lifecycle/ship.d.ts +48 -0
  267. package/dist/core/lifecycle/ship.js +513 -0
  268. package/dist/core/lifecycle/ship.js.map +1 -0
  269. package/dist/core/lifecycle/templates.d.ts +20 -0
  270. package/dist/core/lifecycle/templates.js +605 -0
  271. package/dist/core/lifecycle/templates.js.map +1 -0
  272. package/dist/core/mcp-gateway.d.ts +59 -0
  273. package/dist/core/mcp-gateway.js +385 -0
  274. package/dist/core/mcp-gateway.js.map +1 -0
  275. package/dist/core/mcp-native.d.ts +53 -0
  276. package/dist/core/mcp-native.js +507 -0
  277. package/dist/core/mcp-native.js.map +1 -0
  278. package/dist/core/mcp-registry.d.ts +36 -0
  279. package/dist/core/mcp-registry.js +180 -0
  280. package/dist/core/mcp-registry.js.map +1 -0
  281. package/dist/core/observability/budget-alert.d.ts +18 -0
  282. package/dist/core/observability/budget-alert.js +90 -0
  283. package/dist/core/observability/budget-alert.js.map +1 -0
  284. package/dist/core/observability/estimate.d.ts +27 -0
  285. package/dist/core/observability/estimate.js +188 -0
  286. package/dist/core/observability/estimate.js.map +1 -0
  287. package/dist/core/observability/forecast.d.ts +19 -0
  288. package/dist/core/observability/forecast.js +101 -0
  289. package/dist/core/observability/forecast.js.map +1 -0
  290. package/dist/core/observability/governance.d.ts +28 -0
  291. package/dist/core/observability/governance.js +101 -0
  292. package/dist/core/observability/governance.js.map +1 -0
  293. package/dist/core/observability/otlp.d.ts +88 -0
  294. package/dist/core/observability/otlp.js +217 -0
  295. package/dist/core/observability/otlp.js.map +1 -0
  296. package/dist/core/observability/rollup.d.ts +35 -0
  297. package/dist/core/observability/rollup.js +311 -0
  298. package/dist/core/observability/rollup.js.map +1 -0
  299. package/dist/core/observability/telemetry-sink.d.ts +63 -0
  300. package/dist/core/observability/telemetry-sink.js +350 -0
  301. package/dist/core/observability/telemetry-sink.js.map +1 -0
  302. package/dist/core/observability/usage-source.d.ts +60 -0
  303. package/dist/core/observability/usage-source.js +347 -0
  304. package/dist/core/observability/usage-source.js.map +1 -0
  305. package/dist/core/onboard.d.ts +27 -0
  306. package/dist/core/onboard.js +288 -0
  307. package/dist/core/onboard.js.map +1 -0
  308. package/dist/core/orient.d.ts +23 -0
  309. package/dist/core/orient.js +135 -0
  310. package/dist/core/orient.js.map +1 -0
  311. package/dist/core/phantom.d.ts +23 -0
  312. package/dist/core/phantom.js +279 -0
  313. package/dist/core/phantom.js.map +1 -0
  314. package/dist/core/plugins/host-api.d.ts +29 -0
  315. package/dist/core/plugins/host-api.js +113 -0
  316. package/dist/core/plugins/host-api.js.map +1 -0
  317. package/dist/core/plugins/integrity.d.ts +36 -0
  318. package/dist/core/plugins/integrity.js +73 -0
  319. package/dist/core/plugins/integrity.js.map +1 -0
  320. package/dist/core/plugins/manifest.d.ts +31 -0
  321. package/dist/core/plugins/manifest.js +316 -0
  322. package/dist/core/plugins/manifest.js.map +1 -0
  323. package/dist/core/plugins/registry.d.ts +87 -0
  324. package/dist/core/plugins/registry.js +415 -0
  325. package/dist/core/plugins/registry.js.map +1 -0
  326. package/dist/core/plugins/types.d.ts +182 -0
  327. package/dist/core/plugins/types.js +40 -0
  328. package/dist/core/plugins/types.js.map +1 -0
  329. package/dist/core/plugins/wrappers.d.ts +40 -0
  330. package/dist/core/plugins/wrappers.js +229 -0
  331. package/dist/core/plugins/wrappers.js.map +1 -0
  332. package/dist/core/portfolio/backlog.d.ts +40 -0
  333. package/dist/core/portfolio/backlog.js +177 -0
  334. package/dist/core/portfolio/backlog.js.map +1 -0
  335. package/dist/core/portfolio/scanners.d.ts +21 -0
  336. package/dist/core/portfolio/scanners.js +600 -0
  337. package/dist/core/portfolio/scanners.js.map +1 -0
  338. package/dist/core/providers.d.ts +34 -0
  339. package/dist/core/providers.js +250 -0
  340. package/dist/core/providers.js.map +1 -0
  341. package/dist/core/quality/conventions.d.ts +35 -0
  342. package/dist/core/quality/conventions.js +267 -0
  343. package/dist/core/quality/conventions.js.map +1 -0
  344. package/dist/core/quality/fixes.d.ts +56 -0
  345. package/dist/core/quality/fixes.js +209 -0
  346. package/dist/core/quality/fixes.js.map +1 -0
  347. package/dist/core/quality/health.d.ts +69 -0
  348. package/dist/core/quality/health.js +350 -0
  349. package/dist/core/quality/health.js.map +1 -0
  350. package/dist/core/quality/store.d.ts +56 -0
  351. package/dist/core/quality/store.js +195 -0
  352. package/dist/core/quality/store.js.map +1 -0
  353. package/dist/core/readiness.d.ts +112 -0
  354. package/dist/core/readiness.js +431 -0
  355. package/dist/core/readiness.js.map +1 -0
  356. package/dist/core/run/agent-loop.d.ts +40 -0
  357. package/dist/core/run/agent-loop.js +291 -0
  358. package/dist/core/run/agent-loop.js.map +1 -0
  359. package/dist/core/run/budget.d.ts +47 -0
  360. package/dist/core/run/budget.js +113 -0
  361. package/dist/core/run/budget.js.map +1 -0
  362. package/dist/core/run/engines.d.ts +75 -0
  363. package/dist/core/run/engines.js +199 -0
  364. package/dist/core/run/engines.js.map +1 -0
  365. package/dist/core/run/model-manager.d.ts +64 -0
  366. package/dist/core/run/model-manager.js +339 -0
  367. package/dist/core/run/model-manager.js.map +1 -0
  368. package/dist/core/run/orchestrator.d.ts +100 -0
  369. package/dist/core/run/orchestrator.js +1515 -0
  370. package/dist/core/run/orchestrator.js.map +1 -0
  371. package/dist/core/run/provider-client.d.ts +46 -0
  372. package/dist/core/run/provider-client.js +796 -0
  373. package/dist/core/run/provider-client.js.map +1 -0
  374. package/dist/core/run/retry.d.ts +19 -0
  375. package/dist/core/run/retry.js +68 -0
  376. package/dist/core/run/retry.js.map +1 -0
  377. package/dist/core/run/router.d.ts +50 -0
  378. package/dist/core/run/router.js +257 -0
  379. package/dist/core/run/router.js.map +1 -0
  380. package/dist/core/run/self-heal.d.ts +52 -0
  381. package/dist/core/run/self-heal.js +181 -0
  382. package/dist/core/run/self-heal.js.map +1 -0
  383. package/dist/core/run/streaming.d.ts +31 -0
  384. package/dist/core/run/streaming.js +122 -0
  385. package/dist/core/run/streaming.js.map +1 -0
  386. package/dist/core/run/verify.d.ts +30 -0
  387. package/dist/core/run/verify.js +204 -0
  388. package/dist/core/run/verify.js.map +1 -0
  389. package/dist/core/sandbox/audit.d.ts +31 -0
  390. package/dist/core/sandbox/audit.js +169 -0
  391. package/dist/core/sandbox/audit.js.map +1 -0
  392. package/dist/core/sandbox/policy.d.ts +61 -0
  393. package/dist/core/sandbox/policy.js +211 -0
  394. package/dist/core/sandbox/policy.js.map +1 -0
  395. package/dist/core/sandbox/worktree.d.ts +175 -0
  396. package/dist/core/sandbox/worktree.js +673 -0
  397. package/dist/core/sandbox/worktree.js.map +1 -0
  398. package/dist/core/seams/backlog.d.ts +47 -0
  399. package/dist/core/seams/backlog.js +47 -0
  400. package/dist/core/seams/backlog.js.map +1 -0
  401. package/dist/core/seams/daemon-coordinator.d.ts +72 -0
  402. package/dist/core/seams/daemon-coordinator.js +76 -0
  403. package/dist/core/seams/daemon-coordinator.js.map +1 -0
  404. package/dist/core/seams/genome.d.ts +45 -0
  405. package/dist/core/seams/genome.js +53 -0
  406. package/dist/core/seams/genome.js.map +1 -0
  407. package/dist/core/seams/identity.d.ts +40 -0
  408. package/dist/core/seams/identity.js +44 -0
  409. package/dist/core/seams/identity.js.map +1 -0
  410. package/dist/core/seams/inbox.d.ts +60 -0
  411. package/dist/core/seams/inbox.js +66 -0
  412. package/dist/core/seams/inbox.js.map +1 -0
  413. package/dist/core/seams/index.d.ts +20 -0
  414. package/dist/core/seams/index.js +21 -0
  415. package/dist/core/seams/index.js.map +1 -0
  416. package/dist/core/seams/portfolio.d.ts +50 -0
  417. package/dist/core/seams/portfolio.js +61 -0
  418. package/dist/core/seams/portfolio.js.map +1 -0
  419. package/dist/core/seams/registry.d.ts +42 -0
  420. package/dist/core/seams/registry.js +128 -0
  421. package/dist/core/seams/registry.js.map +1 -0
  422. package/dist/core/seams/run-swarm.d.ts +66 -0
  423. package/dist/core/seams/run-swarm.js +74 -0
  424. package/dist/core/seams/run-swarm.js.map +1 -0
  425. package/dist/core/seams/types.d.ts +123 -0
  426. package/dist/core/seams/types.js +35 -0
  427. package/dist/core/seams/types.js.map +1 -0
  428. package/dist/core/spec/spec-store.d.ts +62 -0
  429. package/dist/core/spec/spec-store.js +359 -0
  430. package/dist/core/spec/spec-store.js.map +1 -0
  431. package/dist/core/swarm/gate.d.ts +45 -0
  432. package/dist/core/swarm/gate.js +114 -0
  433. package/dist/core/swarm/gate.js.map +1 -0
  434. package/dist/core/swarm/planner.d.ts +32 -0
  435. package/dist/core/swarm/planner.js +293 -0
  436. package/dist/core/swarm/planner.js.map +1 -0
  437. package/dist/core/swarm/rollback.d.ts +56 -0
  438. package/dist/core/swarm/rollback.js +266 -0
  439. package/dist/core/swarm/rollback.js.map +1 -0
  440. package/dist/core/swarm/runner.d.ts +62 -0
  441. package/dist/core/swarm/runner.js +1263 -0
  442. package/dist/core/swarm/runner.js.map +1 -0
  443. package/dist/core/swarm/sign.d.ts +71 -0
  444. package/dist/core/swarm/sign.js +362 -0
  445. package/dist/core/swarm/sign.js.map +1 -0
  446. package/dist/core/swarm/store.d.ts +52 -0
  447. package/dist/core/swarm/store.js +195 -0
  448. package/dist/core/swarm/store.js.map +1 -0
  449. package/dist/core/tidy.d.ts +32 -0
  450. package/dist/core/tidy.js +354 -0
  451. package/dist/core/tidy.js.map +1 -0
  452. package/dist/core/tools-registry.d.ts +16 -0
  453. package/dist/core/tools-registry.js +308 -0
  454. package/dist/core/tools-registry.js.map +1 -0
  455. package/dist/core/types.d.ts +2545 -0
  456. package/dist/core/types.js +9 -0
  457. package/dist/core/types.js.map +1 -0
  458. package/dist/core/web/api.d.ts +53 -0
  459. package/dist/core/web/api.js +698 -0
  460. package/dist/core/web/api.js.map +1 -0
  461. package/dist/core/web/public/app.js +1906 -0
  462. package/dist/core/web/public/index.html +721 -0
  463. package/dist/core/web/public/styles.css +2007 -0
  464. package/dist/core/web/server.d.ts +18 -0
  465. package/dist/core/web/server.js +122 -0
  466. package/dist/core/web/server.js.map +1 -0
  467. package/dist/core/web/static.d.ts +18 -0
  468. package/dist/core/web/static.js +122 -0
  469. package/dist/core/web/static.js.map +1 -0
  470. package/dist/tui/app.d.ts +33 -0
  471. package/dist/tui/app.js +350 -0
  472. package/dist/tui/app.js.map +1 -0
  473. package/dist/tui/render.d.ts +20 -0
  474. package/dist/tui/render.js +558 -0
  475. package/dist/tui/render.js.map +1 -0
  476. package/package.json +80 -0
  477. package/schema/config.schema.json +223 -0
@@ -0,0 +1,1515 @@
1
+ /**
2
+ * core/run/orchestrator.ts — M4/M11/M15 local-first agent orchestrator.
3
+ *
4
+ * Responsibilities:
5
+ * - planGoal: single chat call -> RunTask[] DAG (1-6 tasks, deps valid).
6
+ * - runGoal: resolve client, plan/resume, execute DAG (parallel up to opts.parallel),
7
+ * enforce HARD budget, persist RunState after every step, synthesize
8
+ * final answer, best-effort Pulse POST.
9
+ * - loadRun / listRuns / saveRun: JSON persistence under ~/.ashlr/runs/.
10
+ *
11
+ * M7 addition: genome-aware injection. Before planning, runGoal calls
12
+ * recall(goal, cfg) from src/core/genome/recall.ts (dynamic import, best-effort)
13
+ * and prepends a bounded "Relevant project memory:" block to the planning system
14
+ * prompt so the planner starts with relevant cross-project context.
15
+ * Gated on cfg.genome?.injectOnRun (default true) and opts.noMemory (opt-out).
16
+ * Never throws — if recall fails or is empty, the run proceeds unchanged.
17
+ *
18
+ * M16 addition: playbook injection + auto-capture.
19
+ * - Planning injection: when cfg.genome?.playbookOnRun !== false and !noMemory,
20
+ * builds a synthesized playbook via genome/playbook.buildPlaybook (dynamic import,
21
+ * best-effort) and injects playbookText(...) instead of raw recall. Falls back to
22
+ * the existing raw-recall block on any playbook failure.
23
+ * - Auto-capture: after final state is persisted, calls captureFromRun (fire-and-
24
+ * forget) from genome/capture.ts. Disabled via opts.noCapture or
25
+ * cfg.genome?.autoCapture === false. Never throws, never blocks.
26
+ *
27
+ * M11 additions:
28
+ * - HARDENED ENGINE DELEGATION: buildEngineCommand + spawnEngine (engines.ts)
29
+ * replace the guessed ['--goal',goal] spawn. Per-engine adapters produce
30
+ * correct argv; phantom-exec wraps when cfg.phantom?.enabled.
31
+ * - STREAMING: StreamSink threaded from CLI (__sink on opts) through runGoal
32
+ * → runTask → agent loop. Events: task-start/model-delta/tool-call/task-done/
33
+ * retry/verify/log. nullSink used when absent.
34
+ * - RETRY: per-task withRetry (bounded, budget-aware) on tool/transient failures.
35
+ * - VERIFY: verifyTask after each builtin task; one retry on !ok if budget allows;
36
+ * else annotates result with [needs-attention].
37
+ *
38
+ * M15 additions:
39
+ * - PER-TASK ROUTING: before each task attempt, chooseRoute() selects the best
40
+ * LOCAL provider+model (or cloud when allowCloud + key + escalation reason).
41
+ * Dynamic import of router.ts — best-effort; falls back to getActiveClient when
42
+ * the module is absent (preserves pre-M15 behavior in the build pipeline).
43
+ * - AUTO-ESCALATE: on task failure or verify !ok, if allowCloud is set AND a cloud
44
+ * key is present, ONE escalated routed retry is attempted. Otherwise stays local
45
+ * and marks needs-attention. Gated exactly by chooseRoute's guardrails.
46
+ * - COST ATTRIBUTION: estCostUsd uses the per-task RouteDecision.provider so local
47
+ * tasks always cost $0 and cloud escalations are estimated correctly.
48
+ *
49
+ * Safety guardrails (binding):
50
+ * - Never writes outside ~/.ashlr/runs/ — no repos/Desktop, no git.
51
+ * - Budget is a HARD ceiling (aborts with partial results preserved).
52
+ * - Cloud endpoints require explicit allowCloud + key present (delegated to
53
+ * getActiveClient / chooseRoute). NO SILENT CLOUD SPEND.
54
+ * - Zero new runtime deps (Node builtins + @modelcontextprotocol/sdk only).
55
+ * - Genome recall is local-only (keyword/TF-IDF, optional local Ollama embeddings).
56
+ * - Engine delegation is a single bounded spawn — never recursive.
57
+ * - NO AUTO-DOWNLOAD: ollama pull is never called from routing or runs.
58
+ */
59
+ import * as fs from 'node:fs';
60
+ import * as os from 'node:os';
61
+ import * as path from 'node:path';
62
+ import { execFileSync } from 'node:child_process';
63
+ import { getActiveClient } from './provider-client.js';
64
+ import { newUsage, overBudget, estCostUsd } from './budget.js';
65
+ import { runTask } from './agent-loop.js';
66
+ import { withToolEnv } from '../env-bridge.js';
67
+ import { buildEngineCommand, engineInstalled, spawnEngine } from './engines.js';
68
+ import { nullSink } from './streaming.js';
69
+ import { withRetry } from './retry.js';
70
+ import { verifyTask } from './verify.js';
71
+ import { withHeal, defaultHealPolicy } from './self-heal.js';
72
+ // ---------------------------------------------------------------------------
73
+ // Constants / defaults
74
+ // ---------------------------------------------------------------------------
75
+ /** Default token budget per run. */
76
+ export const DEFAULT_MAX_TOKENS = 50_000;
77
+ /** Default step budget per run. */
78
+ export const DEFAULT_MAX_STEPS = 40;
79
+ /** Default parallel task execution limit. */
80
+ export const DEFAULT_PARALLEL = 2;
81
+ /** Directory for persisted run state. */
82
+ /** Re-resolved at call time so tests can relocate HOME (matches swarmsDir()). */
83
+ function runsDir() {
84
+ return path.join(os.homedir(), '.ashlr', 'runs');
85
+ }
86
+ /**
87
+ * Sentinel error attached to tasks that were force-failed by a budget abort
88
+ * (as opposed to a genuine model failure). On --resume we reset tasks bearing
89
+ * this exact error back to 'pending' so they re-run under the new budget.
90
+ */
91
+ const ABORT_TASK_ERROR = 'Aborted: run budget exceeded';
92
+ /**
93
+ * Maximum characters of genome memory injected into the planning prompt.
94
+ * Keeps the injection bounded regardless of entry size.
95
+ */
96
+ const GENOME_INJECT_CHAR_CAP = 1500;
97
+ // ---------------------------------------------------------------------------
98
+ // Persistence helpers
99
+ // ---------------------------------------------------------------------------
100
+ /**
101
+ * Ensure the runs directory exists (mkdir -p).
102
+ * Only creates entries under ~/.ashlr/runs — never repos/Desktop.
103
+ */
104
+ function ensureRunsDir() {
105
+ fs.mkdirSync(runsDir(), { recursive: true });
106
+ }
107
+ /**
108
+ * Compute the absolute path for a run file.
109
+ * Validates the id contains only safe characters to prevent path traversal.
110
+ */
111
+ function runFilePath(id) {
112
+ // Only allow alphanumeric, hyphens, underscores, dots — no slashes or traversal
113
+ if (!/^[\w.-]+$/.test(id)) {
114
+ throw new Error(`Invalid run id: ${JSON.stringify(id)}`);
115
+ }
116
+ return path.join(runsDir(), `${id}.json`);
117
+ }
118
+ /**
119
+ * Load a persisted RunState by id. Returns null if absent, unreadable, or invalid JSON.
120
+ */
121
+ export function loadRun(id) {
122
+ try {
123
+ const file = runFilePath(id);
124
+ const raw = fs.readFileSync(file, 'utf8');
125
+ return JSON.parse(raw);
126
+ }
127
+ catch {
128
+ return null;
129
+ }
130
+ }
131
+ /**
132
+ * List all persisted runs, newest first by createdAt.
133
+ */
134
+ export function listRuns() {
135
+ try {
136
+ ensureRunsDir();
137
+ const files = fs.readdirSync(runsDir()).filter((f) => f.endsWith('.json'));
138
+ const runs = [];
139
+ for (const file of files) {
140
+ try {
141
+ const raw = fs.readFileSync(path.join(runsDir(), file), 'utf8');
142
+ const state = JSON.parse(raw);
143
+ runs.push(state);
144
+ }
145
+ catch {
146
+ // Skip corrupt/unreadable files silently
147
+ }
148
+ }
149
+ return runs.sort((a, b) => (b.createdAt > a.createdAt ? 1 : -1));
150
+ }
151
+ catch {
152
+ return [];
153
+ }
154
+ }
155
+ /**
156
+ * Atomically persist a RunState to ~/.ashlr/runs/<id>.json (write-then-rename).
157
+ * ONLY writes under runsDir() — never touches repos or Desktop.
158
+ */
159
+ export function saveRun(s) {
160
+ ensureRunsDir();
161
+ const dest = runFilePath(s.id);
162
+ const tmp = dest + '.tmp';
163
+ const payload = JSON.stringify(s, null, 2);
164
+ fs.writeFileSync(tmp, payload, 'utf8');
165
+ fs.renameSync(tmp, dest);
166
+ }
167
+ // ---------------------------------------------------------------------------
168
+ // Run id generation
169
+ // ---------------------------------------------------------------------------
170
+ /**
171
+ * Generate a unique run id from the wall clock (format: run-<timestamp>-<random>).
172
+ * Callers may inject an id for test determinism.
173
+ */
174
+ function generateRunId() {
175
+ const ts = Date.now();
176
+ const rand = Math.random().toString(36).slice(2, 7);
177
+ return `run-${ts}-${rand}`;
178
+ }
179
+ // ---------------------------------------------------------------------------
180
+ // M7: Genome recall injection (best-effort, local-only)
181
+ // ---------------------------------------------------------------------------
182
+ /**
183
+ * Attempt to recall relevant genome entries for the goal and format them as a
184
+ * bounded context block suitable for prepending to a planning system prompt.
185
+ *
186
+ * Rules:
187
+ * - Dynamic import of ../genome/recall.js — if the module does not exist yet
188
+ * (other M7 agents have not shipped it), returns '' gracefully.
189
+ * - Total injected text is capped at GENOME_INJECT_CHAR_CAP characters.
190
+ * - Never throws — any error returns '' so the run proceeds unchanged.
191
+ * - Local-only: embeddings via local Ollama only, never cloud.
192
+ */
193
+ async function buildMemoryBlock(goal, cfg) {
194
+ try {
195
+ // Dynamic import: tolerates the module being absent (pre-M7 build).
196
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
197
+ const recallMod = await import('../genome/recall.js');
198
+ if (typeof recallMod.recall !== 'function')
199
+ return '';
200
+ const limit = cfg.genome?.maxRecall ?? 3;
201
+ const hits = await recallMod.recall(goal, cfg, { limit });
202
+ if (!Array.isArray(hits) || hits.length === 0)
203
+ return '';
204
+ const lines = ['Relevant project memory:'];
205
+ let charCount = lines[0].length + 1;
206
+ for (const hit of hits) {
207
+ if (!hit?.entry)
208
+ continue;
209
+ const project = hit.entry.project ? ` [${hit.entry.project}]` : '';
210
+ const header = `- ${hit.entry.title ?? 'note'}${project}:`;
211
+ const body = String(hit.entry.text ?? '').replace(/\s+/g, ' ').trim();
212
+ const fragment = `${header} ${body}`;
213
+ // Stop if adding this entry would exceed the character cap
214
+ if (charCount + fragment.length + 1 > GENOME_INJECT_CHAR_CAP) {
215
+ // Attempt a truncated version (at least 20 chars of body are worth showing)
216
+ const remaining = GENOME_INJECT_CHAR_CAP - charCount - header.length - 4;
217
+ if (remaining > 20) {
218
+ lines.push(`${header} ${body.slice(0, remaining)}…`);
219
+ }
220
+ break;
221
+ }
222
+ lines.push(fragment);
223
+ charCount += fragment.length + 1;
224
+ }
225
+ // Only return the block if we actually added at least one entry beyond header
226
+ if (lines.length <= 1)
227
+ return '';
228
+ return lines.join('\n');
229
+ }
230
+ catch {
231
+ // Module absent, recall failed, or any other error — proceed without memory
232
+ return '';
233
+ }
234
+ }
235
+ /** Cached router module reference (loaded once, null when unavailable). */
236
+ let _routerMod = undefined; // undefined = not yet tried
237
+ /**
238
+ * Load the router module (core/run/router.ts) exactly once, best-effort.
239
+ * Returns null when the module is not yet present in the build (pre-M15).
240
+ * Never throws.
241
+ */
242
+ async function loadRouter() {
243
+ if (_routerMod !== undefined)
244
+ return _routerMod;
245
+ try {
246
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
247
+ const mod = await import('./router.js');
248
+ if (typeof mod.chooseRoute === 'function' && typeof mod.cloudKeyAvailable === 'function') {
249
+ _routerMod = mod;
250
+ }
251
+ else {
252
+ _routerMod = null;
253
+ }
254
+ }
255
+ catch {
256
+ // Module not present or failed to load — fall back to getActiveClient.
257
+ _routerMod = null;
258
+ }
259
+ return _routerMod;
260
+ }
261
+ /**
262
+ * Build a ProviderClient for a given RouteDecision.
263
+ *
264
+ * Provider-aware (M15): the routed provider+model are passed EXPLICITLY into
265
+ * getActiveClient (no process.env mutation), so a cloud RouteDecision actually
266
+ * targets the routed cloud provider instead of silently re-running on the local
267
+ * active provider. This also removes the global ASHLR_MODEL env race that would
268
+ * misroute concurrent tasks resolving to different per-task models.
269
+ *
270
+ * For local routes (tier='local'): getActiveClient(provider, model, allowCloud=false).
271
+ * For cloud routes (tier='cloud'): getActiveClient(provider, model, allowCloud=true) —
272
+ * which enforces the key check and (until cloud completions are implemented)
273
+ * throws; on ANY failure we fall back to the default local client.
274
+ *
275
+ * Never throws — on failure, falls back to the default client. The CALLER must
276
+ * attribute cost using the returned client's `.id` (not the decision's intended
277
+ * provider), because a cloud decision that fails to build falls back to local
278
+ * and must be charged at $0, not at cloud rates.
279
+ */
280
+ async function buildRoutedClient(decision, cfg, allowCloud) {
281
+ const routedModel = decision.model && decision.model !== 'default' ? decision.model : undefined;
282
+ try {
283
+ const cloudOk = decision.tier === 'cloud' && allowCloud;
284
+ return await getActiveClient(cfg, {
285
+ allowCloud: cloudOk,
286
+ provider: decision.provider,
287
+ model: routedModel,
288
+ });
289
+ }
290
+ catch {
291
+ // Route failed (e.g. provider down, cloud key missing, cloud completions
292
+ // not implemented) — fall back to the default local-first client. The
293
+ // returned client's .id reflects the LOCAL provider, so the caller charges
294
+ // local rates ($0) for this attempt rather than the unbuilt cloud provider.
295
+ return await getActiveClient(cfg, { allowCloud, model: routedModel });
296
+ }
297
+ }
298
+ /**
299
+ * Choose a route for a task attempt and build the appropriate ProviderClient.
300
+ *
301
+ * On success: returns {client, decision}.
302
+ * On any error (router absent, provider down): falls back to the run-level
303
+ * client and returns a synthetic local RouteDecision with reason 'fallback'.
304
+ *
305
+ * GUARDRAIL: cloud routes only when allowCloud && lastReason !== 'none' && key present.
306
+ * This is enforced by chooseRoute itself; we never bypass it.
307
+ */
308
+ async function routeTask(taskGoal, cfg, opts, fallbackClient) {
309
+ const router = await loadRouter();
310
+ if (!router) {
311
+ // Pre-M15 build or router unavailable — use the run-level client as-is.
312
+ return {
313
+ client: fallbackClient,
314
+ decision: {
315
+ provider: fallbackClient.id,
316
+ model: process.env['ASHLR_MODEL'] ?? 'default',
317
+ tier: 'local',
318
+ reason: 'router unavailable — local-first fallback',
319
+ },
320
+ };
321
+ }
322
+ try {
323
+ const decision = await router.chooseRoute(taskGoal, cfg, opts);
324
+ const client = await buildRoutedClient(decision, cfg, opts.allowCloud);
325
+ return { client, decision };
326
+ }
327
+ catch {
328
+ // chooseRoute or buildRoutedClient failed — use fallback client.
329
+ return {
330
+ client: fallbackClient,
331
+ decision: {
332
+ provider: fallbackClient.id,
333
+ model: process.env['ASHLR_MODEL'] ?? 'default',
334
+ tier: 'local',
335
+ reason: 'route error — local-first fallback',
336
+ },
337
+ };
338
+ }
339
+ }
340
+ // ---------------------------------------------------------------------------
341
+ // Planning
342
+ // ---------------------------------------------------------------------------
343
+ /** Prompt template for decomposing a goal into a task DAG. */
344
+ const PLANNING_SYSTEM = `You are a task planner. Decompose the user's goal into 1-6 subtasks that together accomplish it.
345
+ Respond ONLY with a JSON array. Each element must have:
346
+ "id": string (unique short slug, e.g. "t1", "t2"),
347
+ "goal": string (clear sub-goal for this task),
348
+ "deps": string[] (ids of tasks that must complete before this one; empty for root tasks)
349
+
350
+ Rules:
351
+ - deps must reference earlier ids only (no cycles).
352
+ - Keep tasks focused and independently executable.
353
+ - Use a minimal number of tasks (don't over-decompose).
354
+
355
+ Example:
356
+ [
357
+ {"id":"t1","goal":"Research the topic","deps":[]},
358
+ {"id":"t2","goal":"Summarize findings","deps":["t1"]}
359
+ ]
360
+
361
+ Return ONLY the JSON array — no prose, no markdown fences.`;
362
+ /**
363
+ * Parse a RunTask[] from model output, tolerating prose wrapped around JSON.
364
+ * Returns null if no valid JSON array of tasks is found.
365
+ */
366
+ function parseTaskList(text) {
367
+ // Try to find a JSON array in the output (tolerate leading/trailing prose)
368
+ const match = text.match(/\[[\s\S]*\]/);
369
+ if (!match)
370
+ return null;
371
+ let parsed;
372
+ try {
373
+ parsed = JSON.parse(match[0]);
374
+ }
375
+ catch {
376
+ return null;
377
+ }
378
+ if (!Array.isArray(parsed) || parsed.length === 0)
379
+ return null;
380
+ const tasks = [];
381
+ const seenIds = new Set();
382
+ for (const item of parsed) {
383
+ if (typeof item !== 'object' || item === null)
384
+ return null;
385
+ const obj = item;
386
+ const id = typeof obj['id'] === 'string' ? obj['id'].trim() : null;
387
+ const goal = typeof obj['goal'] === 'string' ? obj['goal'].trim() : null;
388
+ if (!id || !goal)
389
+ return null;
390
+ if (seenIds.has(id))
391
+ return null; // duplicate id
392
+ seenIds.add(id);
393
+ const rawDeps = Array.isArray(obj['deps']) ? obj['deps'] : [];
394
+ const deps = rawDeps.filter((d) => typeof d === 'string');
395
+ tasks.push({
396
+ id,
397
+ goal,
398
+ deps,
399
+ status: 'pending',
400
+ });
401
+ }
402
+ // Validate deps reference only known ids (no forward deps that are cycles)
403
+ const taskIds = new Set(tasks.map((t) => t.id));
404
+ for (const task of tasks) {
405
+ for (const dep of task.deps) {
406
+ if (!taskIds.has(dep))
407
+ return null; // unknown dep
408
+ if (dep === task.id)
409
+ return null; // self-dep
410
+ }
411
+ }
412
+ // Reject multi-node cycles (e.g. t1->t2->t1). A cycle is a broken plan: at
413
+ // runtime it would otherwise be silently swallowed as 'skipped' tasks after
414
+ // the planning call was already charged. Returning null surfaces it as a
415
+ // plan-parse failure so planGoal falls back to the single-task plan.
416
+ if (hasCycle(tasks))
417
+ return null;
418
+ return tasks.length > 0 ? tasks : null;
419
+ }
420
+ /**
421
+ * DFS-based cycle detection over the task DAG (deps are edges dep -> task).
422
+ * Returns true if any cycle exists.
423
+ */
424
+ function hasCycle(tasks) {
425
+ const byId = new Map(tasks.map((t) => [t.id, t]));
426
+ const VISITING = 1;
427
+ const DONE = 2;
428
+ const mark = new Map();
429
+ const visit = (id) => {
430
+ const cur = mark.get(id);
431
+ if (cur === VISITING)
432
+ return true; // back-edge -> cycle
433
+ if (cur === DONE)
434
+ return false;
435
+ mark.set(id, VISITING);
436
+ const task = byId.get(id);
437
+ if (task) {
438
+ for (const dep of task.deps) {
439
+ if (byId.has(dep) && visit(dep))
440
+ return true;
441
+ }
442
+ }
443
+ mark.set(id, DONE);
444
+ return false;
445
+ };
446
+ for (const t of tasks) {
447
+ if (visit(t.id))
448
+ return true;
449
+ }
450
+ return false;
451
+ }
452
+ /**
453
+ * Planning call: ask the model to decompose `goal` into a RunTask[] DAG.
454
+ * Falls back to a single task whose goal is the original goal on parse failure.
455
+ *
456
+ * @param memoryContext Optional genome memory block to prepend to the system prompt.
457
+ * When non-empty, injects "Relevant project memory:" context so the planner
458
+ * benefits from cross-project knowledge. Kept bounded upstream (GENOME_INJECT_CHAR_CAP).
459
+ */
460
+ export async function planGoal(goal, client, onUsage, memoryContext) {
461
+ // Prepend memory block when present (bounded by caller)
462
+ const systemContent = memoryContext && memoryContext.length > 0
463
+ ? `${memoryContext}\n\n${PLANNING_SYSTEM}`
464
+ : PLANNING_SYSTEM;
465
+ const messages = [
466
+ { role: 'system', content: systemContent },
467
+ { role: 'user', content: goal },
468
+ ];
469
+ let result;
470
+ try {
471
+ result = await client.chat(messages);
472
+ }
473
+ catch (err) {
474
+ const msg = err instanceof Error ? err.message : String(err);
475
+ process.stderr.write(`[ashlr run] planning call failed: ${msg} — using single-task fallback\n`);
476
+ return [{ id: 't1', goal, deps: [], status: 'pending' }];
477
+ }
478
+ // Report planning-call usage so the orchestrator can charge it to the budget
479
+ // and the cost summary. Best-effort: a failed plan call (handled above)
480
+ // reports nothing.
481
+ if (onUsage)
482
+ onUsage({ tokensIn: result.usage.tokensIn, tokensOut: result.usage.tokensOut });
483
+ const parsed = parseTaskList(result.content);
484
+ if (!parsed) {
485
+ process.stderr.write(`[ashlr run] could not parse task list from planning response — using single-task fallback\n`);
486
+ return [{ id: 't1', goal, deps: [], status: 'pending' }];
487
+ }
488
+ return parsed;
489
+ }
490
+ // ---------------------------------------------------------------------------
491
+ // Synthesis
492
+ // ---------------------------------------------------------------------------
493
+ const SYNTHESIS_SYSTEM = `You are a helpful assistant. The user asked a goal and several subtasks were executed to answer it.
494
+ Combine the results into a single, coherent final answer. Be concise and accurate.`;
495
+ /**
496
+ * Synthesize a final answer from completed task results.
497
+ * Returns a best-effort string even if the model call fails.
498
+ */
499
+ async function synthesize(goal, tasks, client) {
500
+ const doneTasks = tasks.filter((t) => t.status === 'done' && t.result);
501
+ if (doneTasks.length === 0) {
502
+ return {
503
+ content: 'No tasks completed successfully — no result to synthesize.',
504
+ usage: { tokensIn: 0, tokensOut: 0 },
505
+ };
506
+ }
507
+ const taskSummary = doneTasks
508
+ .map((t) => `### ${t.id}: ${t.goal}\n${t.result ?? '(no result)'}`)
509
+ .join('\n\n');
510
+ const messages = [
511
+ { role: 'system', content: SYNTHESIS_SYSTEM },
512
+ {
513
+ role: 'user',
514
+ content: `Goal: ${goal}\n\nTask results:\n\n${taskSummary}\n\nPlease synthesize a final answer.`,
515
+ },
516
+ ];
517
+ try {
518
+ const res = await client.chat(messages);
519
+ return { content: res.content, usage: res.usage };
520
+ }
521
+ catch (err) {
522
+ const msg = err instanceof Error ? err.message : String(err);
523
+ // Best-effort fallback: concatenate task results
524
+ const fallback = doneTasks.map((t) => `[${t.id}] ${t.result ?? ''}`).join('\n');
525
+ process.stderr.write(`[ashlr run] synthesis call failed: ${msg} — using concatenated fallback\n`);
526
+ return { content: fallback, usage: { tokensIn: 0, tokensOut: 0 } };
527
+ }
528
+ }
529
+ // ---------------------------------------------------------------------------
530
+ // DAG execution helpers
531
+ // ---------------------------------------------------------------------------
532
+ /**
533
+ * Returns all tasks that are ready to run (pending + all deps done).
534
+ */
535
+ function readyTasks(tasks) {
536
+ const doneIds = new Set(tasks.filter((t) => t.status === 'done').map((t) => t.id));
537
+ return tasks.filter((t) => t.status === 'pending' && t.deps.every((dep) => doneIds.has(dep)));
538
+ }
539
+ /**
540
+ * Returns true when all tasks are in a terminal state (done/failed/skipped/aborted).
541
+ */
542
+ function allTerminal(tasks) {
543
+ const terminal = ['done', 'failed', 'skipped'];
544
+ return tasks.every((t) => terminal.includes(t.status));
545
+ }
546
+ // ---------------------------------------------------------------------------
547
+ // M19: Telemetry emit + governance (best-effort, fire-and-forget, opt-in)
548
+ // ---------------------------------------------------------------------------
549
+ /**
550
+ * Fire-and-forget OTLP/local telemetry emit for a completed run.
551
+ *
552
+ * Dynamically imports core/observability/telemetry-sink.ts and
553
+ * core/observability/otlp.ts so this file compiles even before those modules
554
+ * exist in the build. Only emits when both modules are available; all failures
555
+ * are logged to stderr and never thrown to the caller. Never blocks the run.
556
+ * METADATA ONLY — spans carry model/token/cost/ids/status; never prompts,
557
+ * completions, tool args, file contents, or secrets.
558
+ */
559
+ async function fireEmitRun(state, cfg) {
560
+ await (async () => {
561
+ try {
562
+ // Lazy-import the telemetry seam so the orchestrator core has no hard
563
+ // dependency on it at module-load time (keeps the hot path lean and the
564
+ // emit fully best-effort). Both modules are real and fully typed.
565
+ const [sinkMod, otlpMod] = await Promise.all([
566
+ import('../observability/telemetry-sink.js'),
567
+ import('../observability/otlp.js'),
568
+ ]);
569
+ if (typeof sinkMod.getSink !== 'function' ||
570
+ typeof otlpMod.spansFromRun !== 'function') {
571
+ return;
572
+ }
573
+ // allowPhantomProbe:false — never run a blocking spawnSync phantom probe
574
+ // on the run completion path; OtlpHttpSink resolves the PAT async/bounded.
575
+ const telSink = sinkMod.getSink(cfg, false);
576
+ const spans = otlpMod.spansFromRun(state);
577
+ const result = await telSink.emit(spans);
578
+ if (!result.ok) {
579
+ process.stderr.write(`[ashlr run] telemetry: emit failed — ${result.detail ?? 'unknown'}\n`);
580
+ }
581
+ }
582
+ catch (err) {
583
+ const msg = err instanceof Error ? err.message : String(err);
584
+ process.stderr.write(`[ashlr run] telemetry: best-effort emit failed — ${msg}\n`);
585
+ }
586
+ })();
587
+ }
588
+ /**
589
+ * Evaluate spend governance and return a blocking reason string when
590
+ * govAction==='block' AND level==='over' AND --over-budget was not passed.
591
+ * Prints a prominent advisory when level is 'warn' or 'over'. Never throws.
592
+ * Returns null to proceed normally.
593
+ */
594
+ async function checkGovernance(cfg, overBudgetFlag) {
595
+ try {
596
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
597
+ const govMod = await import('../observability/governance.js');
598
+ if (typeof govMod.evalGovernance !== 'function')
599
+ return null;
600
+ const verdict = govMod.evalGovernance(cfg);
601
+ if (verdict.level === 'over') {
602
+ process.stderr.write(`\n[ashlr run] SPEND GOVERNANCE OVER-CAP: ${verdict.message}\n\n`);
603
+ if (cfg.telemetry?.govAction === 'block' && !overBudgetFlag) {
604
+ return (`Run blocked by spend governance: ${verdict.message} ` +
605
+ `Pass --over-budget to proceed.`);
606
+ }
607
+ }
608
+ else if (verdict.level === 'warn') {
609
+ process.stderr.write(`\n[ashlr run] SPEND GOVERNANCE WARNING: ${verdict.message}\n\n`);
610
+ }
611
+ return null;
612
+ }
613
+ catch {
614
+ // Governance must never block a run on error.
615
+ return null;
616
+ }
617
+ }
618
+ // ---------------------------------------------------------------------------
619
+ // Engine delegation helpers
620
+ // ---------------------------------------------------------------------------
621
+ /**
622
+ * Check if a binary is installed by probing PATH via `which`.
623
+ * Uses the top-level execFileSync import (Node builtin, ESM-safe).
624
+ * Kept for non-engine-id fallback detection (e.g. arbitrary string engines).
625
+ */
626
+ function isBinaryInstalled(name) {
627
+ try {
628
+ execFileSync('which', [name], { stdio: 'ignore' });
629
+ return true;
630
+ }
631
+ catch {
632
+ return false;
633
+ }
634
+ }
635
+ /**
636
+ * Emit a RunStreamEvent via the sink. Never throws.
637
+ */
638
+ function emit(sink, event) {
639
+ try {
640
+ sink({ ...event, ts: new Date().toISOString() });
641
+ }
642
+ catch {
643
+ // Sinks must never crash the run.
644
+ }
645
+ }
646
+ /** Known engine ids (typed subset). */
647
+ const KNOWN_ENGINE_IDS = new Set(['builtin', 'ashlrcode', 'aw', 'claude']);
648
+ // ---------------------------------------------------------------------------
649
+ // Main: runGoal
650
+ // ---------------------------------------------------------------------------
651
+ /**
652
+ * Top-level orchestrator driver. Builds/loads RunState, plans (unless resuming),
653
+ * executes the DAG with parallelism up to opts.parallel, enforces HARD budget,
654
+ * persists after every step, synthesizes final answer, best-effort Pulse POST.
655
+ *
656
+ * M7: genome-aware. Before planning, recalls top-k genome hits for the goal
657
+ * and injects them as context into the planning prompt — bounded, local-only,
658
+ * best-effort. Disabled via opts.noMemory or cfg.genome?.injectOnRun === false.
659
+ */
660
+ export async function runGoal(goal, cfg, opts) {
661
+ // Optional CLI progress hook. The CLI (src/cli/run.ts) attaches a non-typed
662
+ // __onStep property to opts to receive live per-step progress. We read it off
663
+ // here and invoke it after each persisted step (model/plan/synthesize). It is
664
+ // best-effort: it must never crash the run.
665
+ const rawCliOnStep = opts.__onStep;
666
+ const cliOnStep = typeof rawCliOnStep === 'function'
667
+ ? (step, tasks) => {
668
+ try {
669
+ rawCliOnStep(step, tasks);
670
+ }
671
+ catch {
672
+ // Progress reporting must never break the run.
673
+ }
674
+ }
675
+ : undefined;
676
+ // M11: read __sink (StreamSink) from opts. The CLI attaches it for live progress.
677
+ // Falls back to nullSink() when absent (non-TTY, tests, --no-stream).
678
+ const rawSink = opts.__sink;
679
+ const sink = typeof rawSink === 'function' ? rawSink : nullSink();
680
+ // M11: opt-in model verification. Default OFF → the per-task verify step is
681
+ // heuristic-only, charging NO extra model calls (preserves M4 deterministic
682
+ // usage accounting). When enabled, verifyTask may make one cheap model call
683
+ // per task (and one verify-driven retry) under the global budget.
684
+ const verifyModel = opts.verifyModel === true;
685
+ // M7: read noMemory from opts. Not yet typed in RunOptions (avoid editing
686
+ // types.ts) — read as an extended property, same pattern as __onStep above.
687
+ const noMemory = opts.noMemory === true;
688
+ // -- Resume short-circuit (M10 fix: must run BEFORE engine delegation) -------
689
+ // When --resume is requested we must NEVER delegate to an external engine —
690
+ // the run was already started by whichever engine created it, and resuming
691
+ // means continuing with the builtin executor against the persisted state.
692
+ // Previously the engine-delegation block ran first, so
693
+ // `run --engine ashlrcode --resume <id>` would re-run via ashlrcode instead
694
+ // of resuming. Now we handle all resume guards here, before engine selection:
695
+ // 1. Not found → throw immediately.
696
+ // 2. Already complete → return early (no-op).
697
+ // 3. Incomplete → fall through with opts.resumeId set; engine selection
698
+ // below skips delegation because we override engine to 'builtin'.
699
+ if (opts.resumeId) {
700
+ const existingForResume = loadRun(opts.resumeId);
701
+ if (!existingForResume) {
702
+ throw new Error(`Run "${opts.resumeId}" not found in ${runsDir()}`);
703
+ }
704
+ if (existingForResume.status === 'done' && existingForResume.result) {
705
+ process.stderr.write(`[ashlr run] run ${existingForResume.id} is already complete — nothing to resume\n`);
706
+ return existingForResume;
707
+ }
708
+ // Incomplete resume: force builtin so engine delegation is skipped.
709
+ // The full state reload / task-reset happens in the "Load or create
710
+ // RunState" block further below.
711
+ opts = { ...opts, engine: 'builtin' };
712
+ }
713
+ // -- Engine selection --------------------------------------------------------
714
+ const requestedEngine = opts.engine ?? 'builtin';
715
+ let engine = requestedEngine;
716
+ if (engine !== 'builtin') {
717
+ // Determine if this is a known typed engine id or an arbitrary binary name.
718
+ const isKnownEngineId = KNOWN_ENGINE_IDS.has(engine);
719
+ const engineId = isKnownEngineId ? engine : 'ashlrcode'; // arbitrary → treat as external
720
+ // Check installation: for known ids use engineInstalled(); for arbitrary names use isBinaryInstalled().
721
+ const installed = isKnownEngineId
722
+ ? engineInstalled(engineId)
723
+ : isBinaryInstalled(engine);
724
+ if (!installed) {
725
+ process.stderr.write(`[ashlr run] engine "${engine}" not found on PATH — falling back to builtin\n`);
726
+ emit(sink, { kind: 'log', text: `engine "${engine}" not found — falling back to builtin` });
727
+ engine = 'builtin';
728
+ }
729
+ else {
730
+ // Delegate to the external engine via the hardened per-engine adapter.
731
+ // buildEngineCommand produces the EXACT argv for the real CLI.
732
+ // spawnEngine applies withToolEnv(cfg) + phantom-exec wrap when enabled.
733
+ // This is a SINGLE BOUNDED SPAWN — never recursive.
734
+ const modelEnv = process.env['ASHLR_MODEL'] ?? process.env['AC_MODEL'];
735
+ // Honor opts.cwd (e.g. a swarm task's target project dir) so the engine
736
+ // spawns WITHIN the intended project, not wherever the parent launched.
737
+ // Validate it is an existing directory before use; fall back to cwd.
738
+ let cwd = process.cwd();
739
+ if (opts.cwd) {
740
+ try {
741
+ if (path.isAbsolute(opts.cwd) &&
742
+ fs.existsSync(opts.cwd) &&
743
+ fs.statSync(opts.cwd).isDirectory()) {
744
+ cwd = opts.cwd;
745
+ }
746
+ else {
747
+ process.stderr.write(`[ashlr run] opts.cwd "${opts.cwd}" is not an existing absolute directory — using ${cwd}\n`);
748
+ }
749
+ }
750
+ catch {
751
+ // stat failed — keep the default cwd
752
+ }
753
+ }
754
+ // Build the correct command for known engine ids; for unknown use the
755
+ // old-style fallback (engine binary not in KNOWN_ENGINE_IDS was already
756
+ // handled above via isBinaryInstalled, so this branch is only reached
757
+ // for known ids).
758
+ const cmd = isKnownEngineId
759
+ ? buildEngineCommand(engineId, goal, cfg, { cwd, model: modelEnv })
760
+ : null;
761
+ if (!cmd) {
762
+ // buildEngineCommand returned null (builtin) — fall through to builtin path.
763
+ engine = 'builtin';
764
+ }
765
+ else {
766
+ process.stderr.write(`[ashlr run] delegating to engine "${engine}" (${goal.slice(0, 60)}…)\n`);
767
+ emit(sink, { kind: 'log', text: `delegating to engine "${engine}"` });
768
+ const id = generateRunId();
769
+ const now = new Date().toISOString();
770
+ const delegatedState = {
771
+ id,
772
+ goal,
773
+ engine,
774
+ provider: 'external',
775
+ createdAt: now,
776
+ updatedAt: now,
777
+ budget: {
778
+ maxTokens: opts.budget?.maxTokens ?? DEFAULT_MAX_TOKENS,
779
+ maxSteps: opts.budget?.maxSteps ?? DEFAULT_MAX_STEPS,
780
+ allowCloud: opts.allowCloud ?? false,
781
+ },
782
+ usage: newUsage(),
783
+ tasks: [],
784
+ steps: [],
785
+ status: 'running',
786
+ };
787
+ // spawnEngine: applies withToolEnv(cfg) + phantom-exec when enabled.
788
+ const engineResult = spawnEngine(cmd, cfg);
789
+ if (!engineResult.ok) {
790
+ const errMsg = engineResult.error ?? 'unknown error';
791
+ process.stderr.write(`[ashlr run] engine "${engine}" failed: ${errMsg}\n`);
792
+ emit(sink, { kind: 'log', text: `engine "${engine}" failed: ${errMsg}` });
793
+ delegatedState.status = 'failed';
794
+ delegatedState.result = `Engine "${engine}" failed: ${errMsg}`;
795
+ delegatedState.updatedAt = new Date().toISOString();
796
+ saveRun(delegatedState);
797
+ return delegatedState;
798
+ }
799
+ // Account for reported usage (e.g. claude --output-format json carries tokens).
800
+ if (engineResult.usage) {
801
+ delegatedState.usage.tokensIn = engineResult.usage.tokensIn;
802
+ delegatedState.usage.tokensOut = engineResult.usage.tokensOut;
803
+ delegatedState.usage.steps = 1;
804
+ delegatedState.usage.estCostUsd = estCostUsd(engine, engineResult.usage.tokensIn, engineResult.usage.tokensOut);
805
+ }
806
+ delegatedState.status = 'done';
807
+ delegatedState.result = engineResult.output;
808
+ delegatedState.updatedAt = new Date().toISOString();
809
+ emit(sink, { kind: 'task-done', text: `engine "${engine}" completed` });
810
+ saveRun(delegatedState);
811
+ return delegatedState;
812
+ }
813
+ }
814
+ }
815
+ // Suppress unused-import warning for withToolEnv (still used by engines.ts indirectly;
816
+ // kept here for the M10 env-bridge contract — callers outside this file use it too).
817
+ void withToolEnv;
818
+ // -- M19: Spend governance check (advisory; block only when govAction==='block') --
819
+ // Read the --over-budget flag the same way noMemory/noCapture are read: as an
820
+ // extended property on opts (not yet in the typed RunOptions interface).
821
+ const overBudgetFlag = opts.overBudget === true;
822
+ const govBlock = await checkGovernance(cfg, overBudgetFlag);
823
+ if (govBlock !== null) {
824
+ const now = new Date().toISOString();
825
+ const blockState = {
826
+ id: generateRunId(),
827
+ goal,
828
+ engine: 'builtin',
829
+ provider: 'none',
830
+ createdAt: now,
831
+ updatedAt: now,
832
+ budget: {
833
+ maxTokens: opts.budget?.maxTokens ?? DEFAULT_MAX_TOKENS,
834
+ maxSteps: opts.budget?.maxSteps ?? DEFAULT_MAX_STEPS,
835
+ allowCloud: opts.allowCloud ?? false,
836
+ },
837
+ usage: newUsage(),
838
+ tasks: [],
839
+ steps: [],
840
+ status: 'failed',
841
+ result: govBlock,
842
+ };
843
+ process.stderr.write(`[ashlr run] ${govBlock}\n`);
844
+ return blockState;
845
+ }
846
+ // -- Budget / parallel defaults ----------------------------------------------
847
+ const allowCloud = opts.allowCloud ?? false;
848
+ const budget = {
849
+ maxTokens: opts.budget?.maxTokens ?? DEFAULT_MAX_TOKENS,
850
+ maxSteps: opts.budget?.maxSteps ?? DEFAULT_MAX_STEPS,
851
+ allowCloud,
852
+ };
853
+ const parallel = Math.max(1, opts.parallel ?? DEFAULT_PARALLEL);
854
+ // -- Resolve provider client -------------------------------------------------
855
+ const client = await getActiveClient(cfg, { allowCloud });
856
+ // -- Load or create RunState -------------------------------------------------
857
+ let state;
858
+ if (opts.resumeId) {
859
+ const existing = loadRun(opts.resumeId);
860
+ if (!existing) {
861
+ throw new Error(`Run "${opts.resumeId}" not found in ${runsDir()}`);
862
+ }
863
+ // Already-complete run: do NOT redo work. Re-running synthesis would
864
+ // double-count usage, append duplicate steps, and re-POST Pulse. Return the
865
+ // loaded state unchanged so `--resume <id>` on a finished run is a no-op.
866
+ if (existing.status === 'done' && existing.result) {
867
+ process.stderr.write(`[ashlr run] run ${existing.id} is already complete — nothing to resume\n`);
868
+ return existing;
869
+ }
870
+ state = {
871
+ ...existing,
872
+ status: 'running',
873
+ updatedAt: new Date().toISOString(),
874
+ };
875
+ // Reset tasks that should re-run with the (presumably larger) new budget:
876
+ // - 'running': were mid-flight when the previous invocation stopped.
877
+ // - abort-failures: tasks the budget abort marked 'failed' with the
878
+ // sentinel error. Genuine model failures are left as-is so we don't
879
+ // loop on a deterministically-failing task.
880
+ for (const task of state.tasks) {
881
+ if (task.status === 'running' ||
882
+ (task.status === 'failed' && task.error === ABORT_TASK_ERROR)) {
883
+ task.status = 'pending';
884
+ task.error = undefined;
885
+ }
886
+ }
887
+ saveRun(state);
888
+ process.stderr.write(`[ashlr run] resumed run ${state.id} (${state.tasks.length} tasks)\n`);
889
+ }
890
+ else {
891
+ const id = generateRunId();
892
+ const now = new Date().toISOString();
893
+ state = {
894
+ id,
895
+ goal,
896
+ engine,
897
+ provider: client.id,
898
+ createdAt: now,
899
+ updatedAt: now,
900
+ budget,
901
+ usage: newUsage(),
902
+ tasks: [],
903
+ steps: [],
904
+ status: 'running',
905
+ };
906
+ saveRun(state);
907
+ }
908
+ // -- Tool wiring (optional) --------------------------------------------------
909
+ // When opts.tools !== false, attempt to connect to the MCP gateway as a client.
910
+ // On any failure, continue tool-free with a warning.
911
+ let tools;
912
+ if (opts.tools !== false && client.supportsTools) {
913
+ try {
914
+ tools = await loadGatewayTools(cfg);
915
+ }
916
+ catch (err) {
917
+ const msg = err instanceof Error ? err.message : String(err);
918
+ process.stderr.write(`[ashlr run] tool gateway unavailable (${msg}) — continuing tool-free\n`);
919
+ tools = undefined;
920
+ }
921
+ }
922
+ // -- M16/M7: Genome memory injection (best-effort, bounded, local-only) ------
923
+ // M16: prefer a synthesized playbook over raw recall when playbookOnRun is on.
924
+ // Falls back to the existing raw-recall block on any playbook failure.
925
+ // Skipped when: noMemory is set, cfg disables injection, or this is a resume
926
+ // with existing tasks (context was already embedded in those task goals).
927
+ let memoryContext = '';
928
+ const injectOnRun = cfg.genome?.injectOnRun ?? true;
929
+ if (!noMemory && injectOnRun && state.tasks.length === 0) {
930
+ // Only attempt playbook injection when genome is explicitly configured and
931
+ // playbookOnRun is not disabled. When cfg.genome is absent there is nothing
932
+ // to recall, and the playbook module makes local Ollama fetch calls even on
933
+ // an empty recall — which would interfere with scripted fetch mocks in tests
934
+ // and add unnecessary latency in unconfigured environments.
935
+ const playbookOnRun = cfg.genome != null && cfg.genome.playbookOnRun !== false;
936
+ let playbookInjected = false;
937
+ if (playbookOnRun) {
938
+ try {
939
+ // Dynamic import: tolerates the module being absent (pre-M16 build).
940
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
941
+ const pbMod = await import('../genome/playbook.js');
942
+ if (typeof pbMod.buildPlaybook === 'function' &&
943
+ typeof pbMod.playbookText === 'function') {
944
+ const playbook = await pbMod.buildPlaybook(goal, cfg);
945
+ const pbText = pbMod.playbookText(playbook, GENOME_INJECT_CHAR_CAP);
946
+ if (pbText && pbText.length > 0) {
947
+ memoryContext = pbText;
948
+ playbookInjected = true;
949
+ process.stderr.write(`[ashlr run] genome: injecting ${memoryContext.length} chars of playbook context\n`);
950
+ }
951
+ }
952
+ }
953
+ catch {
954
+ // Playbook module absent or failed — fall through to raw recall below.
955
+ }
956
+ }
957
+ if (!playbookInjected) {
958
+ // M7 fallback: raw recall injection.
959
+ memoryContext = await buildMemoryBlock(goal, cfg);
960
+ if (memoryContext.length > 0) {
961
+ process.stderr.write(`[ashlr run] genome: injecting ${memoryContext.length} chars of memory context\n`);
962
+ }
963
+ }
964
+ }
965
+ // -- Plan (unless resuming with existing tasks) ------------------------------
966
+ if (state.tasks.length === 0) {
967
+ const planStep = {
968
+ ts: new Date().toISOString(),
969
+ taskId: '__plan__',
970
+ kind: 'plan',
971
+ summary: `Planning: decomposing goal into tasks`,
972
+ };
973
+ state.steps.push(planStep);
974
+ state.updatedAt = planStep.ts;
975
+ saveRun(state);
976
+ let planTokensIn = 0;
977
+ let planTokensOut = 0;
978
+ const tasks = await planGoal(goal, client, (u) => {
979
+ planTokensIn = u.tokensIn;
980
+ planTokensOut = u.tokensOut;
981
+ }, memoryContext || undefined);
982
+ state.tasks = tasks;
983
+ // Charge the planning call to the run budget so usage/cost stay accurate.
984
+ // (Previously the planning tokens were silently discarded.)
985
+ // Accumulate incrementally (price ONLY the planning tokens at the planner's
986
+ // provider) so this is consistent with the per-step accumulation below and
987
+ // never re-prices later task tokens at the planner's provider.
988
+ state.usage.tokensIn += planTokensIn;
989
+ state.usage.tokensOut += planTokensOut;
990
+ state.usage.steps += 1;
991
+ state.usage.estCostUsd += estCostUsd(client.id, planTokensIn, planTokensOut);
992
+ state.updatedAt = new Date().toISOString();
993
+ const planDoneStep = {
994
+ ts: state.updatedAt,
995
+ taskId: '__plan__',
996
+ kind: 'plan',
997
+ summary: `Planned ${tasks.length} task(s): ${tasks.map((t) => t.id).join(', ')}`,
998
+ usage: { tokensIn: planTokensIn, tokensOut: planTokensOut, steps: 1, estCostUsd: 0 },
999
+ };
1000
+ state.steps.push(planDoneStep);
1001
+ cliOnStep?.(planDoneStep, state.tasks);
1002
+ saveRun(state);
1003
+ }
1004
+ // -- DAG execution loop ------------------------------------------------------
1005
+ let aborted = false;
1006
+ while (!allTerminal(state.tasks) && !aborted) {
1007
+ // Check global budget before picking next batch
1008
+ if (overBudget(state.usage, budget)) {
1009
+ aborted = true;
1010
+ break;
1011
+ }
1012
+ const ready = readyTasks(state.tasks);
1013
+ if (ready.length === 0) {
1014
+ // No ready tasks but not all terminal — means some tasks have deps on
1015
+ // failed/skipped tasks. Mark them skipped.
1016
+ const pendingBlocked = state.tasks.filter((t) => t.status === 'pending');
1017
+ if (pendingBlocked.length > 0) {
1018
+ for (const t of pendingBlocked) {
1019
+ t.status = 'skipped';
1020
+ t.error = 'Dependency failed or was skipped';
1021
+ }
1022
+ state.updatedAt = new Date().toISOString();
1023
+ saveRun(state);
1024
+ }
1025
+ break;
1026
+ }
1027
+ // Run up to `parallel` tasks concurrently
1028
+ const batch = ready.slice(0, parallel);
1029
+ // Mark them running before spawning
1030
+ for (const task of batch) {
1031
+ task.status = 'running';
1032
+ }
1033
+ state.updatedAt = new Date().toISOString();
1034
+ saveRun(state);
1035
+ // Run the batch in parallel; each task must not crash the whole run
1036
+ await Promise.all(batch.map(async (task) => {
1037
+ try {
1038
+ // M11: emit task-start event.
1039
+ emit(sink, { kind: 'task-start', taskId: task.id, text: task.goal });
1040
+ // M15: Choose route for this task (local-first; cloud only when
1041
+ // allowCloud + escalation reason + key present). Best-effort — falls
1042
+ // back to the run-level client when router is unavailable.
1043
+ const { client: taskClient, decision: taskDecision } = await routeTask(task.goal, cfg, { allowCloud, attempt: 1, lastReason: 'none' }, client);
1044
+ emit(sink, {
1045
+ kind: 'log',
1046
+ taskId: task.id,
1047
+ text: `route: ${taskDecision.provider}/${taskDecision.model} [${taskDecision.tier}] — ${taskDecision.reason}`,
1048
+ });
1049
+ // Build per-task onStep callback (single-writer invariant preserved).
1050
+ // M15: cost attribution uses the provider that actually served EACH step.
1051
+ // We ACCUMULATE cost incrementally (+= this step's tokens priced at this
1052
+ // step's provider) rather than recomputing estCostUsd over the cumulative
1053
+ // run-wide totals at the current provider. Recomputing-from-cumulative is
1054
+ // wrong for mixed local+cloud runs: it would re-price an earlier local
1055
+ // task's tokens at a later cloud escalation's rates (over-charging), or
1056
+ // re-price an earlier cloud task's tokens at $0 when a later step is local
1057
+ // (erasing real spend). Incremental accumulation keeps local steps at $0
1058
+ // regardless of any later cloud escalation, and prices cloud escalations
1059
+ // on only the tokens they served.
1060
+ const makeTaskOnStep = (providerForCost) => (step) => {
1061
+ state.steps.push(step);
1062
+ // SINGLE-WRITER INVARIANT: orchestrator is the only mutator of state.usage.
1063
+ if (step.usage) {
1064
+ state.usage.tokensIn += step.usage.tokensIn;
1065
+ state.usage.tokensOut += step.usage.tokensOut;
1066
+ state.usage.steps += step.usage.steps;
1067
+ state.usage.estCostUsd += estCostUsd(providerForCost, step.usage.tokensIn, step.usage.tokensOut);
1068
+ }
1069
+ state.updatedAt = new Date().toISOString();
1070
+ cliOnStep?.(step, state.tasks);
1071
+ saveRun(state);
1072
+ };
1073
+ let taskOnStep = makeTaskOnStep(taskDecision.provider);
1074
+ // M11: Retry policy — bounded, budget-aware.
1075
+ // We retry on transient/tool failures only; hard budget stops are not retryable.
1076
+ const RETRY_POLICY = { maxAttempts: 2, baseDelayMs: 500 };
1077
+ const isRetryable = (err) => {
1078
+ // Don't retry if budget is already exhausted.
1079
+ if (overBudget(state.usage, budget))
1080
+ return false;
1081
+ // Retry on network/transient errors (not on deterministic task failures).
1082
+ if (err instanceof Error) {
1083
+ const msg = err.message.toLowerCase();
1084
+ return (msg.includes('network') ||
1085
+ msg.includes('timeout') ||
1086
+ msg.includes('econnrefused') ||
1087
+ msg.includes('fetch') ||
1088
+ msg.includes('socket'));
1089
+ }
1090
+ return false;
1091
+ };
1092
+ await withRetry(async (attempt) => {
1093
+ if (attempt > 1) {
1094
+ emit(sink, {
1095
+ kind: 'retry',
1096
+ taskId: task.id,
1097
+ text: `attempt ${attempt} of ${RETRY_POLICY.maxAttempts}`,
1098
+ });
1099
+ // Reset task state for re-run on retry.
1100
+ task.status = 'running';
1101
+ task.result = undefined;
1102
+ task.error = undefined;
1103
+ }
1104
+ // M20: bounded self-heal for OOM/rate-limit on model calls.
1105
+ // Opt-out: ASHLR_NO_HEAL skips the wrapper entirely.
1106
+ const noHeal = process.env['ASHLR_NO_HEAL'] === '1';
1107
+ const runWithHeal = async (healAttempt) => {
1108
+ // On heal attempt > 1 with a 'model-downgrade' event the client
1109
+ // was already logged via onHeal; chooseRoute will pick a smaller
1110
+ // model on the next routeTask call if the outer attempt increments,
1111
+ // so we just re-run with the current client here (the heal retry
1112
+ // is bounded by policy.maxRestarts and stays fully local).
1113
+ if (healAttempt > 1) {
1114
+ // Re-route to a smaller local model for the downgrade attempt.
1115
+ // Best-effort: fall back to existing taskClient on any error.
1116
+ try {
1117
+ const { client: smallerClient } = await routeTask(task.goal, cfg, { allowCloud: false, attempt: healAttempt, lastReason: 'none' }, taskClient);
1118
+ task.status = 'running';
1119
+ task.result = undefined;
1120
+ task.error = undefined;
1121
+ await runTask(task, smallerClient, {
1122
+ tools,
1123
+ budget,
1124
+ usage: state.usage,
1125
+ sink,
1126
+ onStep: makeTaskOnStep(smallerClient.id),
1127
+ });
1128
+ return;
1129
+ }
1130
+ catch {
1131
+ // Fall through to original client below.
1132
+ }
1133
+ }
1134
+ await runTask(task, taskClient, {
1135
+ tools,
1136
+ budget,
1137
+ usage: state.usage,
1138
+ sink,
1139
+ onStep: taskOnStep,
1140
+ });
1141
+ };
1142
+ if (noHeal) {
1143
+ await runTask(task, taskClient, {
1144
+ tools,
1145
+ budget,
1146
+ usage: state.usage,
1147
+ sink,
1148
+ onStep: taskOnStep,
1149
+ });
1150
+ }
1151
+ else {
1152
+ const healPolicy = defaultHealPolicy();
1153
+ await withHeal(runWithHeal, healPolicy, (event) => {
1154
+ emit(sink, {
1155
+ kind: 'log',
1156
+ taskId: task.id,
1157
+ text: `[self-heal] ${event.kind} attempt ${event.attempt}: ${event.detail}`,
1158
+ });
1159
+ process.stderr.write(`[ashlr run] self-heal(${event.kind}) task ${task.id} attempt ${event.attempt}: ${event.detail}\n`);
1160
+ }, allowCloud).catch((healErr) => {
1161
+ // withHeal exhausted — re-throw so the outer withRetry sees it.
1162
+ throw healErr;
1163
+ });
1164
+ }
1165
+ // If runTask set status to failed, surface as a throw so withRetry
1166
+ // can decide whether to retry (only on retryable errors).
1167
+ if (task.status === 'failed') {
1168
+ const errMsg = task.error ?? 'task failed';
1169
+ // Only transient errors get retried; model/parsing errors do not.
1170
+ // We check if the error looks retryable before throwing.
1171
+ if (isRetryable(new Error(errMsg))) {
1172
+ throw new Error(errMsg);
1173
+ }
1174
+ // Non-retryable failure: don't throw (withRetry would still catch
1175
+ // and re-throw since isRetryable returns false). Fall through.
1176
+ }
1177
+ }, RETRY_POLICY, isRetryable).catch((err) => {
1178
+ // withRetry exhausted all attempts or got a non-retryable error.
1179
+ // task.status is already 'failed' (set by runTask); just ensure error is set.
1180
+ if (task.status !== 'failed') {
1181
+ task.status = 'failed';
1182
+ task.error = err instanceof Error ? err.message : String(err);
1183
+ }
1184
+ });
1185
+ // M15: On task failure, attempt ONE escalated routed retry.
1186
+ // Escalation is gated by: allowCloud AND escalate.onFailure AND !overBudget.
1187
+ // chooseRoute enforces the additional cloud-key check; if it returns a
1188
+ // local route again (key absent, allowCloud false, etc.) we just stay local.
1189
+ if (task.status === 'failed' &&
1190
+ allowCloud &&
1191
+ (cfg.models.escalate?.onFailure ?? false) &&
1192
+ !overBudget(state.usage, budget)) {
1193
+ const { client: escalatedClient, decision: escalatedDecision } = await routeTask(task.goal, cfg, { allowCloud, attempt: 2, lastReason: 'task-failed' }, client);
1194
+ // Only actually escalate if chooseRoute returned a DIFFERENT (cloud)
1195
+ // route AND buildRoutedClient was able to construct a client for that
1196
+ // cloud provider. If the cloud client could not be built (key absent,
1197
+ // cloud completions unimplemented), buildRoutedClient falls back to a
1198
+ // LOCAL client whose .id is the local provider — in that case we must
1199
+ // NOT print "escalating to cloud" or charge cloud rates. Cost is
1200
+ // attributed by the ACTUAL client.id, never the intended provider.
1201
+ const cloudEscalated = escalatedDecision.tier === 'cloud' &&
1202
+ escalatedClient.id === escalatedDecision.provider;
1203
+ if (cloudEscalated) {
1204
+ emit(sink, {
1205
+ kind: 'retry',
1206
+ taskId: task.id,
1207
+ text: `escalating to cloud: ${escalatedDecision.provider}/${escalatedDecision.model} — ${escalatedDecision.reason}`,
1208
+ });
1209
+ task.status = 'running';
1210
+ task.result = undefined;
1211
+ task.error = undefined;
1212
+ // Attribute cost to the ACTUAL serving client (cloud here).
1213
+ taskOnStep = makeTaskOnStep(escalatedClient.id);
1214
+ await runTask(task, escalatedClient, {
1215
+ tools,
1216
+ budget,
1217
+ usage: state.usage,
1218
+ sink,
1219
+ onStep: taskOnStep,
1220
+ }).catch((err) => {
1221
+ if (task.status !== 'failed') {
1222
+ task.status = 'failed';
1223
+ task.error = err instanceof Error ? err.message : String(err);
1224
+ }
1225
+ });
1226
+ }
1227
+ // If escalation could not reach cloud (still local / cloud client
1228
+ // unbuildable), leave task.status as 'failed' — no further action,
1229
+ // no misleading cloud event, no cloud cost.
1230
+ }
1231
+ // M11: Verify completed tasks; one retry on !ok if budget allows.
1232
+ // Skip verify entirely once the run is over budget: a budget abort can
1233
+ // leave a task 'done' with a result annotated by an abort/needs-attention
1234
+ // marker, which the heuristic's error-sentinel check would flag as a
1235
+ // benign false-positive "verify fail". Skipping keeps the abort path
1236
+ // clean (no confusing verify line) and avoids any model call past the
1237
+ // ceiling. (Real verification still runs on every in-budget completion.)
1238
+ if (task.status === 'done' && !overBudget(state.usage, budget)) {
1239
+ const verdict = await verifyTask(task, taskClient, budget, state.usage, {
1240
+ model: verifyModel,
1241
+ });
1242
+ emit(sink, {
1243
+ kind: 'verify',
1244
+ taskId: task.id,
1245
+ text: verdict.reason,
1246
+ data: verdict,
1247
+ });
1248
+ if (!verdict.ok) {
1249
+ if (!overBudget(state.usage, budget)) {
1250
+ // M15: verify-failed escalation path — attempt ONE routed retry.
1251
+ // If allowCloud + escalate.onFailure + key present, chooseRoute
1252
+ // may return a cloud route; otherwise stays local.
1253
+ const { client: verifyRetryClient, decision: verifyRetryDecision } = await routeTask(task.goal, cfg, { allowCloud, attempt: 2, lastReason: 'verify-failed' }, taskClient);
1254
+ // Only treat this as a cloud escalation if the cloud client was
1255
+ // actually built (decision is cloud AND the returned client's id
1256
+ // matches the routed cloud provider). Otherwise buildRoutedClient
1257
+ // fell back to local — keep the event + cost attribution local.
1258
+ const escalatingToCloud = verifyRetryDecision.tier === 'cloud' &&
1259
+ verifyRetryClient.id === verifyRetryDecision.provider;
1260
+ // One verification-driven retry: re-run the task.
1261
+ emit(sink, {
1262
+ kind: 'retry',
1263
+ taskId: task.id,
1264
+ text: escalatingToCloud
1265
+ ? `verify failed (${verdict.reason}) — escalating to cloud retry: ${verifyRetryDecision.provider}`
1266
+ : `verify failed (${verdict.reason}) — retrying once`,
1267
+ });
1268
+ task.status = 'running';
1269
+ task.result = undefined;
1270
+ task.error = undefined;
1271
+ // Attribute cost to the ACTUAL serving client (never the intended
1272
+ // provider) so a local fallback stays $0.
1273
+ const verifyRetryOnStep = makeTaskOnStep(verifyRetryClient.id);
1274
+ await runTask(task, verifyRetryClient, {
1275
+ tools,
1276
+ budget,
1277
+ usage: state.usage,
1278
+ sink,
1279
+ onStep: verifyRetryOnStep,
1280
+ });
1281
+ // Re-verify after the retry (best-effort; don't loop).
1282
+ // Cast through string: TS narrowed to 'running' after the assignment above,
1283
+ // but runTask mutates task.status in place so it may be 'done' now.
1284
+ if (task.status === 'done') {
1285
+ const verdict2 = await verifyTask(task, verifyRetryClient, budget, state.usage, {
1286
+ model: verifyModel,
1287
+ });
1288
+ emit(sink, {
1289
+ kind: 'verify',
1290
+ taskId: task.id,
1291
+ text: verdict2.reason,
1292
+ data: verdict2,
1293
+ });
1294
+ if (!verdict2.ok) {
1295
+ // Still failing: annotate result but keep status 'done'.
1296
+ task.result = `[needs-attention: ${verdict2.reason}]\n${task.result ?? ''}`;
1297
+ }
1298
+ }
1299
+ }
1300
+ else {
1301
+ // Budget exhausted: annotate but keep status 'done'.
1302
+ task.result = `[needs-attention: ${verdict.reason}]\n${task.result ?? ''}`;
1303
+ }
1304
+ }
1305
+ }
1306
+ // M15: latency-threshold escalation (cfg.models.escalate?.latencyMs).
1307
+ // Latency is tracked by checking whether the task took longer than
1308
+ // the configured threshold. We use task.usage.steps as a proxy:
1309
+ // if the task completed but the run-level elapsed since task-start
1310
+ // is not directly available here, we record the threshold check as
1311
+ // informational only — the latency escalation path is a stub that
1312
+ // emits a log event when cfg.models.escalate.latencyMs is set and
1313
+ // the task usage steps are unusually high (>= TASK_STEP_CAP / 2).
1314
+ // Full wall-clock latency tracking can be wired in a follow-up.
1315
+ if (task.status === 'done' &&
1316
+ allowCloud &&
1317
+ cfg.models.escalate?.latencyMs !== undefined &&
1318
+ (task.usage?.steps ?? 0) >= 10 // heuristic: many steps → slow task
1319
+ ) {
1320
+ emit(sink, {
1321
+ kind: 'log',
1322
+ taskId: task.id,
1323
+ text: `[M15] task completed with ${task.usage?.steps ?? 0} steps; latency threshold ${cfg.models.escalate.latencyMs}ms configured (cloud escalation on latency available when re-running with --allow-cloud)`,
1324
+ });
1325
+ }
1326
+ // M11: emit task-done (or failed) event.
1327
+ if (task.status === 'done') {
1328
+ emit(sink, { kind: 'task-done', taskId: task.id, text: task.goal });
1329
+ }
1330
+ else {
1331
+ emit(sink, {
1332
+ kind: 'log',
1333
+ taskId: task.id,
1334
+ text: `task ${task.id} ${task.status}: ${task.error ?? ''}`,
1335
+ });
1336
+ }
1337
+ }
1338
+ catch (err) {
1339
+ // Defensive: runTask should handle its own errors, but catch any leak
1340
+ const msg = err instanceof Error ? err.message : String(err);
1341
+ task.status = 'failed';
1342
+ task.error = `Unexpected orchestrator error: ${msg}`;
1343
+ process.stderr.write(`[ashlr run] task ${task.id} crashed unexpectedly: ${msg}\n`);
1344
+ }
1345
+ }));
1346
+ state.updatedAt = new Date().toISOString();
1347
+ saveRun(state);
1348
+ // Check budget after batch completes
1349
+ if (overBudget(state.usage, budget)) {
1350
+ aborted = true;
1351
+ break;
1352
+ }
1353
+ }
1354
+ // -- Abort: mark remaining pending/running tasks as aborted ------------------
1355
+ if (aborted) {
1356
+ for (const task of state.tasks) {
1357
+ if (task.status === 'pending' || task.status === 'running') {
1358
+ task.status = 'failed';
1359
+ task.error = ABORT_TASK_ERROR;
1360
+ }
1361
+ }
1362
+ state.status = 'aborted';
1363
+ state.updatedAt = new Date().toISOString();
1364
+ saveRun(state);
1365
+ // M19: Emit telemetry (best-effort, opt-in). Awaited so the local sink is
1366
+ // flushed before the process exits; bounded + fully caught, never throws.
1367
+ await fireEmitRun(state, cfg);
1368
+ // M16: Auto-capture on abort path (fire-and-forget).
1369
+ const noCaptureAbort = opts.noCapture === true;
1370
+ if (!noCaptureAbort) {
1371
+ void (async () => {
1372
+ try {
1373
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1374
+ const capMod = await import('../genome/capture.js');
1375
+ if (typeof capMod.captureFromRun === 'function') {
1376
+ capMod.captureFromRun(state, cfg);
1377
+ }
1378
+ }
1379
+ catch {
1380
+ // Never surface capture errors to the caller.
1381
+ }
1382
+ })();
1383
+ }
1384
+ return state;
1385
+ }
1386
+ // -- Synthesize final answer -------------------------------------------------
1387
+ const synthStep = {
1388
+ ts: new Date().toISOString(),
1389
+ taskId: '__synthesize__',
1390
+ kind: 'synthesize',
1391
+ summary: 'Synthesizing final answer from task results',
1392
+ };
1393
+ state.steps.push(synthStep);
1394
+ state.updatedAt = synthStep.ts;
1395
+ saveRun(state);
1396
+ // Budget guard for synthesis: if the run already hit the ceiling, do NOT
1397
+ // spend another model call. Fall back to concatenating the completed task
1398
+ // results so maxTokens stays a hard ceiling at the synthesis boundary too.
1399
+ let synthResult;
1400
+ let synthUsage;
1401
+ if (overBudget(state.usage, budget)) {
1402
+ const doneTasks = state.tasks.filter((t) => t.status === 'done' && t.result);
1403
+ synthResult =
1404
+ doneTasks.length > 0
1405
+ ? doneTasks.map((t) => `[${t.id}] ${t.result ?? ''}`).join('\n')
1406
+ : 'No tasks completed successfully — no result to synthesize.';
1407
+ synthUsage = { tokensIn: 0, tokensOut: 0 };
1408
+ process.stderr.write(`[ashlr run] budget reached — skipping model synthesis, using concatenated task results\n`);
1409
+ }
1410
+ else {
1411
+ const synth = await synthesize(goal, state.tasks, client);
1412
+ synthResult = synth.content;
1413
+ synthUsage = synth.usage;
1414
+ }
1415
+ state.usage.tokensIn += synthUsage.tokensIn;
1416
+ state.usage.tokensOut += synthUsage.tokensOut;
1417
+ state.usage.steps += 1;
1418
+ // Accumulate incrementally (price ONLY the synthesis tokens at the synthesis
1419
+ // provider). Recomputing from cumulative totals at client.id here would CLOBBER
1420
+ // the per-step mixed-provider cost already accumulated by the task loop —
1421
+ // re-pricing earlier cloud-escalation tokens at the local run-level provider
1422
+ // (erasing real spend) or vice-versa.
1423
+ state.usage.estCostUsd += estCostUsd(client.id, synthUsage.tokensIn, synthUsage.tokensOut);
1424
+ const synthDoneStep = {
1425
+ ts: new Date().toISOString(),
1426
+ taskId: '__synthesize__',
1427
+ kind: 'synthesize',
1428
+ summary: 'Synthesis complete',
1429
+ usage: { tokensIn: synthUsage.tokensIn, tokensOut: synthUsage.tokensOut, steps: 1, estCostUsd: 0 },
1430
+ };
1431
+ state.steps.push(synthDoneStep);
1432
+ cliOnStep?.(synthDoneStep, state.tasks);
1433
+ state.result = synthResult;
1434
+ // Determine final status
1435
+ const failedCount = state.tasks.filter((t) => t.status === 'failed').length;
1436
+ state.status = failedCount === state.tasks.length ? 'failed' : 'done';
1437
+ state.updatedAt = new Date().toISOString();
1438
+ saveRun(state);
1439
+ // -- M19: Emit telemetry (best-effort, opt-in) ------------------------------
1440
+ // Awaited so the local sink is flushed before the process exits; bounded +
1441
+ // fully caught, never throws.
1442
+ await fireEmitRun(state, cfg);
1443
+ // -- M16: Auto-capture (fire-and-forget, never throws, never blocks) ---------
1444
+ // Read noCapture via extended property (same pattern as noMemory above).
1445
+ const noCapture = opts.noCapture === true;
1446
+ if (!noCapture) {
1447
+ // Wrap in void + try to guarantee fire-and-forget with zero blocking.
1448
+ void (async () => {
1449
+ try {
1450
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1451
+ const capMod = await import('../genome/capture.js');
1452
+ if (typeof capMod.captureFromRun === 'function') {
1453
+ capMod.captureFromRun(state, cfg);
1454
+ }
1455
+ }
1456
+ catch {
1457
+ // Never surface capture errors to the caller.
1458
+ }
1459
+ })();
1460
+ }
1461
+ return state;
1462
+ }
1463
+ // ---------------------------------------------------------------------------
1464
+ // Gateway tool loading (optional)
1465
+ // ---------------------------------------------------------------------------
1466
+ /**
1467
+ * Attempt to load aggregated tools from the MCP gateway as a client.
1468
+ * Returns the tool list (OpenAI-style tool specs) or throws on failure.
1469
+ * Used only when opts.tools !== false AND client.supportsTools.
1470
+ */
1471
+ async function loadGatewayTools(cfg) {
1472
+ // Lazy-import MCP SDK to keep startup fast when tools are disabled
1473
+ const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
1474
+ const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js');
1475
+ // Resolve the ashlr binary path from the config tools map, or fall back to PATH
1476
+ const ashlrBin = cfg.tools?.['ashlr'] ?? 'ashlr';
1477
+ const transport = new StdioClientTransport({
1478
+ command: ashlrBin,
1479
+ args: ['mcp'],
1480
+ stderr: 'ignore',
1481
+ });
1482
+ const mcpClient = new Client({ name: 'ashlr-orchestrator', version: '0.1.0' }, { capabilities: {} });
1483
+ // Connect with a 10s timeout
1484
+ const ctrl = new AbortController();
1485
+ const timer = setTimeout(() => ctrl.abort(), 10_000);
1486
+ try {
1487
+ await mcpClient.connect(transport);
1488
+ clearTimeout(timer);
1489
+ const listed = await mcpClient.listTools({}, { timeout: 10_000 });
1490
+ // Convert MCP tool specs to OpenAI-style function specs for the provider
1491
+ const tools = (listed.tools ?? []).map((t) => ({
1492
+ type: 'function',
1493
+ function: {
1494
+ name: t.name,
1495
+ description: t.description ?? t.name,
1496
+ parameters: t.inputSchema ?? { type: 'object', properties: {} },
1497
+ },
1498
+ }));
1499
+ // Close client after fetching — tools are passed as static specs to the model
1500
+ try {
1501
+ await mcpClient.close();
1502
+ }
1503
+ catch { /* ignore */ }
1504
+ return tools;
1505
+ }
1506
+ catch (err) {
1507
+ clearTimeout(timer);
1508
+ try {
1509
+ await mcpClient.close();
1510
+ }
1511
+ catch { /* ignore */ }
1512
+ throw err;
1513
+ }
1514
+ }
1515
+ //# sourceMappingURL=orchestrator.js.map