@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,1906 @@
1
+ /**
2
+ * app.js — ashlr-hub local web dashboard SPA
3
+ *
4
+ * Vanilla JS, no framework, no CDN, no external deps.
5
+ * Runs entirely in the browser against the local API served by startServer().
6
+ *
7
+ * Views (hash-routed):
8
+ * #overview — DashboardSnapshot cards
9
+ * #runs — RunState table + dispatch panel (M32)
10
+ * #swarms — SwarmRun list + DAG graph + live burndown
11
+ * #pulse — ActivityRollup SVG bar charts
12
+ * #genome — GenomeEntry search + list
13
+ * #portfolio — PortfolioSummary org-level view (read-only; M29)
14
+ * #inbox — Pending proposals list + approve/reject (M32)
15
+ * #daemon — Daemon state card (M32)
16
+ *
17
+ * Live updates via EventSource('/api/events') — patches runs + swarms +
18
+ * inbox + daemon views.
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Constants
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const VIEWS = ['overview', 'runs', 'swarms', 'pulse', 'genome', 'portfolio', 'inbox', 'daemon'];
28
+ const DEFAULT_VIEW = 'overview';
29
+ const API_BASE = ''; // same origin
30
+
31
+ // sessionStorage key for the session token (never localStorage, never URL).
32
+ const TOKEN_STORAGE_KEY = 'ashlr-token';
33
+
34
+ // Status -> color map (matches brand palette from styles.css)
35
+ const STATUS_COLOR = {
36
+ // Run / Swarm lifecycle
37
+ running: '#4ade80', // green
38
+ done: '#60a5fa', // blue
39
+ failed: '#f87171', // red
40
+ aborted: '#fbbf24', // amber
41
+ planning: '#a78bfa', // purple
42
+ // Task-level
43
+ pending: '#6b7280', // gray
44
+ skipped: '#9ca3af', // light gray
45
+ };
46
+
47
+ // Swarm phase -> column index for DAG layout
48
+ const PHASE_ORDER = { scaffold: 0, build: 1, integrate: 2, verify: 3, review: 4 };
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Tiny reactive state
52
+ // ---------------------------------------------------------------------------
53
+
54
+ const state = {
55
+ snapshot: null,
56
+ runs: [],
57
+ swarms: [],
58
+ currentSwarm: null,
59
+ pulse: null,
60
+ genome: [],
61
+ genomeQuery: '',
62
+ portfolio: null, // M29: read-only org-level PortfolioSummary | null
63
+ // M32: inbox + daemon
64
+ inbox: { pending: 0, proposals: [] },
65
+ inboxDetail: null, // currently-open Proposal (full, with diff)
66
+ daemon: null, // DaemonState | null
67
+ inboxBadge: 0, // pending count from SSE, drives nav badge
68
+ loading: {}, // viewName -> boolean
69
+ error: {}, // viewName -> string | null
70
+ activeView: DEFAULT_VIEW,
71
+ eventSource: null,
72
+ // M32: estimate debounce timer
73
+ _estimateTimer: null,
74
+ };
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Utility helpers
78
+ // ---------------------------------------------------------------------------
79
+
80
+ function el(tag, attrs = {}, ...children) {
81
+ const e = document.createElement(tag);
82
+ for (const [k, v] of Object.entries(attrs)) {
83
+ if (k === 'cls') { e.className = v; }
84
+ else if (k.startsWith('on')) { e.addEventListener(k.slice(2).toLowerCase(), v); }
85
+ else { e.setAttribute(k, v); }
86
+ }
87
+ for (const c of children) {
88
+ if (c == null) continue;
89
+ e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
90
+ }
91
+ return e;
92
+ }
93
+
94
+ function svgEl(tag, attrs = {}, ...children) {
95
+ const e = document.createElementNS('http://www.w3.org/2000/svg', tag);
96
+ for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
97
+ for (const c of children) {
98
+ if (c == null) continue;
99
+ e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
100
+ }
101
+ return e;
102
+ }
103
+
104
+ function fmt(n, decimals = 2) {
105
+ if (typeof n !== 'number' || isNaN(n)) return '—';
106
+ return n.toFixed(decimals);
107
+ }
108
+
109
+ function fmtK(n) {
110
+ if (typeof n !== 'number') return '—';
111
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
112
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
113
+ return String(n);
114
+ }
115
+
116
+ function fmtDate(iso) {
117
+ if (!iso) return '—';
118
+ try {
119
+ const d = new Date(iso);
120
+ return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
121
+ } catch { return iso; }
122
+ }
123
+
124
+ function fmtRelative(iso) {
125
+ if (!iso) return '—';
126
+ try {
127
+ const diff = Date.now() - new Date(iso).getTime();
128
+ const mins = Math.floor(diff / 60_000);
129
+ if (mins < 1) return 'just now';
130
+ if (mins < 60) return `${mins}m ago`;
131
+ const hrs = Math.floor(mins / 60);
132
+ if (hrs < 24) return `${hrs}h ago`;
133
+ return `${Math.floor(hrs / 24)}d ago`;
134
+ } catch { return iso; }
135
+ }
136
+
137
+ function statusBadge(status) {
138
+ const color = STATUS_COLOR[status] ?? '#9ca3af';
139
+ return el('span', { cls: 'badge', style: `background:${color}20;color:${color};border:1px solid ${color}40` }, status ?? '—');
140
+ }
141
+
142
+ function progressBar(done, total) {
143
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0;
144
+ const wrap = el('div', { cls: 'progress-track' });
145
+ const fill = el('div', { cls: 'progress-fill', style: `width:${pct}%` });
146
+ wrap.appendChild(fill);
147
+ return el('div', { cls: 'progress-wrap' },
148
+ wrap,
149
+ el('span', { cls: 'progress-label' }, `${done}/${total}`)
150
+ );
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // API fetching
155
+ // ---------------------------------------------------------------------------
156
+
157
+ async function apiFetch(path) {
158
+ const res = await fetch(API_BASE + path);
159
+ if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
160
+ return res.json();
161
+ }
162
+
163
+ // Token-authenticated POST. Returns parsed JSON or throws with message.
164
+ async function apiPost(path, token) {
165
+ const res = await fetch(API_BASE + path, {
166
+ method: 'POST',
167
+ headers: {
168
+ 'Content-Type': 'application/json',
169
+ 'x-ashlr-token': token,
170
+ },
171
+ body: '{}',
172
+ });
173
+ const json = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
174
+ if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`);
175
+ return json;
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Token management (sessionStorage only — never localStorage, never URL)
180
+ // ---------------------------------------------------------------------------
181
+
182
+ function getToken() {
183
+ try { return sessionStorage.getItem(TOKEN_STORAGE_KEY) ?? ''; } catch { return ''; }
184
+ }
185
+
186
+ function setToken(t) {
187
+ try { sessionStorage.setItem(TOKEN_STORAGE_KEY, t); } catch {}
188
+ }
189
+
190
+ function clearToken() {
191
+ try { sessionStorage.removeItem(TOKEN_STORAGE_KEY); } catch {}
192
+ }
193
+
194
+ // Prompt user for token and store it; returns the entered value (may be empty).
195
+ function promptToken() {
196
+ const current = getToken();
197
+ const entered = window.prompt(
198
+ 'Enter the ashlr session token printed by the server at startup.\n' +
199
+ 'It is stored in sessionStorage only (cleared when the tab closes).\n\n' +
200
+ (current ? 'Current token is set. Leave blank to clear it.' : 'No token is set yet.'),
201
+ current
202
+ );
203
+ if (entered === null) return current; // cancelled
204
+ const trimmed = entered.trim();
205
+ if (trimmed) { setToken(trimmed); } else { clearToken(); }
206
+ updateTokenIndicator();
207
+ return trimmed;
208
+ }
209
+
210
+ // Update the subtle token indicator in the nav bar.
211
+ function updateTokenIndicator() {
212
+ const el_ = document.getElementById('token-indicator');
213
+ if (!el_) return;
214
+ const hasToken = Boolean(getToken());
215
+ el_.title = hasToken ? 'Session token is set (click to change)' : 'No session token — click to enter';
216
+ el_.classList.toggle('token-indicator--set', hasToken);
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // HTML escaping (for untrusted diff text rendered in <pre>)
221
+ // ---------------------------------------------------------------------------
222
+
223
+ function escapeHtml(s) {
224
+ if (typeof s !== 'string') return '';
225
+ return s
226
+ .replace(/&/g, '&amp;')
227
+ .replace(/</g, '&lt;')
228
+ .replace(/>/g, '&gt;')
229
+ .replace(/"/g, '&quot;')
230
+ .replace(/'/g, '&#39;');
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // EventSource — live updates
235
+ // ---------------------------------------------------------------------------
236
+
237
+ function connectSSE() {
238
+ if (state.eventSource) return;
239
+ try {
240
+ const es = new EventSource('/api/events');
241
+ state.eventSource = es;
242
+
243
+ es.addEventListener('snapshot', (e) => {
244
+ try { state.snapshot = JSON.parse(e.data); renderActiveView(); } catch {}
245
+ });
246
+ es.addEventListener('runs', (e) => {
247
+ try { state.runs = JSON.parse(e.data); if (state.activeView === 'runs') renderRuns(); } catch {}
248
+ });
249
+ es.addEventListener('swarms', (e) => {
250
+ try {
251
+ state.swarms = JSON.parse(e.data);
252
+ if (state.activeView === 'swarms') renderSwarms();
253
+ // Also refresh overview swarm cards
254
+ if (state.activeView === 'overview' && state.snapshot) renderOverview();
255
+ } catch {}
256
+ });
257
+ // M32: live inbox state — update badge and, if viewing inbox, re-render
258
+ es.addEventListener('inbox', (e) => {
259
+ try {
260
+ const data = JSON.parse(e.data);
261
+ state.inbox = data;
262
+ state.inboxBadge = data.pending ?? 0;
263
+ updateInboxBadge();
264
+ if (state.activeView === 'inbox') renderInbox();
265
+ } catch {}
266
+ });
267
+ // M32: live daemon state
268
+ es.addEventListener('daemon', (e) => {
269
+ try {
270
+ state.daemon = JSON.parse(e.data);
271
+ if (state.activeView === 'daemon') renderDaemon();
272
+ } catch {}
273
+ });
274
+ es.addEventListener('error', () => {
275
+ // Silently tolerate — browser will auto-reconnect or server is stopping
276
+ });
277
+ } catch {
278
+ // EventSource not available or server not yet up — silent
279
+ }
280
+ }
281
+
282
+ // Update the pending-count badge on the Inbox nav link.
283
+ function updateInboxBadge() {
284
+ const badge = document.getElementById('inbox-nav-badge');
285
+ if (!badge) return;
286
+ const n = state.inboxBadge;
287
+ badge.textContent = n > 0 ? String(n > 99 ? '99+' : n) : '';
288
+ badge.hidden = n === 0;
289
+ }
290
+
291
+ function disconnectSSE() {
292
+ if (state.eventSource) {
293
+ state.eventSource.close();
294
+ state.eventSource = null;
295
+ }
296
+ }
297
+
298
+ // Close the live stream cleanly when the page is unloaded so the server's
299
+ // SSE connection (and its poll timer) is released promptly.
300
+ window.addEventListener('beforeunload', disconnectSSE);
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // Router
304
+ // ---------------------------------------------------------------------------
305
+
306
+ function getView() {
307
+ const h = location.hash.replace('#', '').trim();
308
+ return VIEWS.includes(h) ? h : DEFAULT_VIEW;
309
+ }
310
+
311
+ function navigate(view) {
312
+ location.hash = view;
313
+ }
314
+
315
+ async function onHashChange() {
316
+ const view = getView();
317
+ state.activeView = view;
318
+ setActiveNav(view);
319
+ renderActiveView();
320
+ await loadView(view);
321
+ }
322
+
323
+ function renderActiveView() {
324
+ const view = state.activeView;
325
+ if (view === 'overview') renderOverview();
326
+ else if (view === 'runs') renderRuns();
327
+ else if (view === 'swarms') renderSwarms();
328
+ else if (view === 'pulse') renderPulse();
329
+ else if (view === 'genome') renderGenome();
330
+ else if (view === 'portfolio') renderPortfolio();
331
+ else if (view === 'inbox') renderInbox();
332
+ else if (view === 'daemon') renderDaemon();
333
+ }
334
+
335
+ async function loadView(view) {
336
+ if (view === 'overview') await loadOverview();
337
+ else if (view === 'runs') await loadRuns();
338
+ else if (view === 'swarms') await loadSwarms();
339
+ else if (view === 'pulse') await loadPulse();
340
+ else if (view === 'genome') await loadGenome();
341
+ else if (view === 'portfolio') await loadPortfolio();
342
+ else if (view === 'inbox') await loadInbox();
343
+ else if (view === 'daemon') await loadDaemon();
344
+ }
345
+
346
+ // ---------------------------------------------------------------------------
347
+ // Nav
348
+ // ---------------------------------------------------------------------------
349
+
350
+ function setActiveNav(view) {
351
+ document.querySelectorAll('.nav-link').forEach((a) => {
352
+ a.classList.toggle('active', a.dataset.view === view);
353
+ });
354
+ }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Shell: render the skeleton (called once on DOMContentLoaded)
358
+ // ---------------------------------------------------------------------------
359
+
360
+ function renderShell() {
361
+ document.body.innerHTML = '';
362
+
363
+ // Nav icons per view (inline SVG path data)
364
+ const VIEW_ICONS = {
365
+ overview: '<rect x="1" y="1" width="6" height="6" rx="1" fill="currentColor" opacity=".85"/><rect x="9" y="1" width="6" height="6" rx="1" fill="currentColor" opacity=".55"/><rect x="1" y="9" width="6" height="6" rx="1" fill="currentColor" opacity=".55"/><rect x="9" y="9" width="6" height="6" rx="1" fill="currentColor" opacity=".3"/>',
366
+ runs: '<circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="1.5"/><polyline points="5.5,8 7.5,10 10.5,6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
367
+ swarms: '<circle cx="8" cy="3" r="2" fill="currentColor" opacity=".9"/><circle cx="3" cy="13" r="2" fill="currentColor" opacity=".7"/><circle cx="13" cy="13" r="2" fill="currentColor" opacity=".7"/><line x1="8" y1="5" x2="3" y2="11" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><line x1="8" y1="5" x2="13" y2="11" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><line x1="3" y1="13" x2="13" y2="13" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>',
368
+ pulse: '<polyline points="1,8 4,8 5.5,3 7,13 8.5,6 10,10 11.5,8 15,8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
369
+ genome: '<path d="M5,1 Q8,4 5,7 Q8,10 5,13" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" fill="none"/><path d="M11,1 Q8,4 11,7 Q8,10 11,13" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" fill="none"/><line x1="6.5" y1="3.5" x2="9.5" y2="3.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><line x1="6" y1="7" x2="10" y2="7" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><line x1="6.5" y1="10.5" x2="9.5" y2="10.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>',
370
+ portfolio: '<rect x="1.5" y="9" width="3" height="5.5" rx="0.6" stroke="currentColor" stroke-width="1.2" fill="none"/><rect x="6.5" y="5" width="3" height="9.5" rx="0.6" stroke="currentColor" stroke-width="1.2" fill="none"/><rect x="11.5" y="2" width="3" height="12.5" rx="0.6" stroke="currentColor" stroke-width="1.2" fill="none"/>',
371
+ inbox: '<rect x="1.5" y="3" width="13" height="10" rx="1.5" stroke="currentColor" stroke-width="1.4" fill="none"/><polyline points="1.5,5 8,9.5 14.5,5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
372
+ daemon: '<circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="1.5" fill="none"/><line x1="8" y1="4.5" x2="8" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><line x1="8" y1="8" x2="11" y2="10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>',
373
+ };
374
+
375
+ // Build nav links, including inbox badge
376
+ const navLinks = VIEWS.map((v) => {
377
+ const iconSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
378
+ iconSvg.setAttribute('width', '14');
379
+ iconSvg.setAttribute('height', '14');
380
+ iconSvg.setAttribute('viewBox', '0 0 16 16');
381
+ iconSvg.setAttribute('fill', 'none');
382
+ iconSvg.setAttribute('aria-hidden', 'true');
383
+ iconSvg.innerHTML = VIEW_ICONS[v] ?? '';
384
+
385
+ const label = document.createTextNode(v.charAt(0).toUpperCase() + v.slice(1));
386
+ const a = el('a', { cls: 'nav-link', href: `#${v}`, 'data-view': v });
387
+ a.appendChild(iconSvg);
388
+ a.appendChild(label);
389
+
390
+ // Inbox pending badge
391
+ if (v === 'inbox') {
392
+ const badge = el('span', { cls: 'nav-inbox-badge', id: 'inbox-nav-badge', 'aria-label': 'pending proposals' });
393
+ badge.hidden = true;
394
+ a.appendChild(badge);
395
+ }
396
+ return a;
397
+ });
398
+
399
+ // Token button (gear icon in nav footer)
400
+ const tokenBtn = el('button', {
401
+ cls: 'token-btn',
402
+ id: 'token-indicator',
403
+ title: 'No session token — click to enter',
404
+ type: 'button',
405
+ onClick: () => { promptToken(); },
406
+ });
407
+ // gear SVG
408
+ const gearSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
409
+ gearSvg.setAttribute('width', '14'); gearSvg.setAttribute('height', '14');
410
+ gearSvg.setAttribute('viewBox', '0 0 16 16'); gearSvg.setAttribute('fill', 'none');
411
+ gearSvg.setAttribute('aria-hidden', 'true');
412
+ gearSvg.innerHTML = '<circle cx="8" cy="8" r="2.5" stroke="currentColor" stroke-width="1.3" fill="none"/><path d="M8 1.5V3M8 13v1.5M1.5 8H3M13 8h1.5M3.22 3.22l1.06 1.06M11.72 11.72l1.06 1.06M12.78 3.22l-1.06 1.06M4.28 11.72l-1.06 1.06" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>';
413
+ tokenBtn.appendChild(gearSvg);
414
+ tokenBtn.appendChild(document.createTextNode(' Token'));
415
+
416
+ // Top nav
417
+ const nav = el('nav', { cls: 'topnav' },
418
+ el('div', { cls: 'nav-brand' },
419
+ el('span', { cls: 'brand-icon' }, '⬡'),
420
+ el('span', { cls: 'brand-name' }, 'ashlr hub')
421
+ ),
422
+ el('div', { cls: 'nav-links' }, ...navLinks),
423
+ el('div', { cls: 'nav-status' },
424
+ el('span', { cls: 'sse-dot', id: 'sse-dot', title: 'Live stream' }),
425
+ tokenBtn
426
+ )
427
+ );
428
+
429
+ const main = el('main', { cls: 'main', id: 'main' });
430
+ document.body.appendChild(nav);
431
+ document.body.appendChild(main);
432
+ updateTokenIndicator();
433
+ }
434
+
435
+ function getMain() {
436
+ return document.getElementById('main');
437
+ }
438
+
439
+ // ---------------------------------------------------------------------------
440
+ // Loading / error helpers
441
+ // ---------------------------------------------------------------------------
442
+
443
+ function showLoading(view) {
444
+ if (state.activeView !== view) return;
445
+ const main = getMain();
446
+ if (main) main.innerHTML = '<div class="loading-wrap"><div class="spinner"></div><p>Loading…</p></div>';
447
+ }
448
+
449
+ function showError(view, msg) {
450
+ if (state.activeView !== view) return;
451
+ const main = getMain();
452
+ if (main) {
453
+ main.innerHTML = '';
454
+ main.appendChild(el('div', { cls: 'error-wrap' },
455
+ el('p', { cls: 'error-msg' }, `⚠ ${msg}`),
456
+ el('button', { cls: 'btn', onClick: () => loadView(view) }, 'Retry')
457
+ ));
458
+ }
459
+ }
460
+
461
+ // ---------------------------------------------------------------------------
462
+ // Overview view
463
+ // ---------------------------------------------------------------------------
464
+
465
+ async function loadOverview() {
466
+ showLoading('overview');
467
+ try {
468
+ state.snapshot = await apiFetch('/api/snapshot');
469
+ renderOverview();
470
+ } catch (err) {
471
+ showError('overview', err.message);
472
+ }
473
+ }
474
+
475
+ function renderOverview() {
476
+ if (state.activeView !== 'overview') return;
477
+ const s = state.snapshot;
478
+ const main = getMain();
479
+ if (!main) return;
480
+ main.innerHTML = '';
481
+
482
+ if (!s) {
483
+ main.appendChild(el('div', { cls: 'loading-wrap' }, el('p', {}, 'No data yet.')));
484
+ return;
485
+ }
486
+
487
+ const section = el('section', { cls: 'view-section' });
488
+
489
+ // Header
490
+ section.appendChild(el('div', { cls: 'view-header' },
491
+ el('h1', { cls: 'view-title' }, 'Overview'),
492
+ el('span', { cls: 'view-subtitle' }, `Updated ${fmtRelative(s.generatedAt)}`)
493
+ ));
494
+
495
+ // Metric cards row
496
+ const cards = el('div', { cls: 'cards-row' });
497
+ cards.appendChild(metricCard('Repos', s.repos.total, `${s.repos.dirty} dirty · ${s.repos.stale} stale`, '#60a5fa'));
498
+ cards.appendChild(metricCard('Sessions (7d)', s.activity.sessions, `${fmtK(s.activity.tokens)} tokens`, '#4ade80'));
499
+ cards.appendChild(metricCard('Cost (7d)', `$${fmt(s.activity.estCostUsd)}`, `${s.activity.commits} commits`, '#fbbf24'));
500
+ cards.appendChild(metricCard('Tools', s.tools.installed, `of ${s.tools.total} detected`, '#a78bfa'));
501
+ cards.appendChild(metricCard('Genome', s.genome.entries, `${s.genome.projects} projects`, '#34d399'));
502
+ section.appendChild(cards);
503
+
504
+ // Recent Runs
505
+ if (s.runs && s.runs.length > 0) {
506
+ section.appendChild(el('h2', { cls: 'section-heading' }, 'Recent Runs'));
507
+ const table = buildRunsTable(s.runs);
508
+ section.appendChild(table);
509
+ }
510
+
511
+ // Active Swarms
512
+ if (s.swarms && s.swarms.length > 0) {
513
+ section.appendChild(el('h2', { cls: 'section-heading' }, 'Active Swarms'));
514
+ const grid = el('div', { cls: 'swarms-grid' });
515
+ for (const sw of s.swarms) {
516
+ grid.appendChild(swarmCard(sw));
517
+ }
518
+ section.appendChild(grid);
519
+ }
520
+
521
+ // MCP servers
522
+ if (s.mcp && s.mcp.length > 0) {
523
+ section.appendChild(el('h2', { cls: 'section-heading' }, 'MCP Servers'));
524
+ const pills = el('div', { cls: 'mcp-pills' });
525
+ for (const m of s.mcp) {
526
+ pills.appendChild(el('span', {
527
+ cls: `mcp-pill ${m.ok ? 'ok' : 'fail'}`,
528
+ title: `${m.tools} tools`
529
+ }, m.name));
530
+ }
531
+ section.appendChild(pills);
532
+ }
533
+
534
+ main.appendChild(section);
535
+ }
536
+
537
+ function metricCard(label, value, sub, accent = '#60a5fa') {
538
+ return el('div', { cls: 'metric-card', style: `--accent:${accent}` },
539
+ el('div', { cls: 'metric-value' }, String(value)),
540
+ el('div', { cls: 'metric-label' }, label),
541
+ el('div', { cls: 'metric-sub' }, sub)
542
+ );
543
+ }
544
+
545
+ function swarmCard(sw) {
546
+ const card = el('div', { cls: 'swarm-card' });
547
+ card.appendChild(el('div', { cls: 'swarm-card-header' },
548
+ el('span', { cls: 'swarm-goal', title: sw.goal }, sw.goal),
549
+ statusBadge(sw.status)
550
+ ));
551
+ if (sw.phase) card.appendChild(el('div', { cls: 'swarm-phase' }, `Phase: ${sw.phase}`));
552
+ card.appendChild(progressBar(sw.tasksDone, sw.tasksTotal));
553
+ // Click to navigate to swarm detail
554
+ if (sw.id) {
555
+ card.style.cursor = 'pointer';
556
+ card.addEventListener('click', () => {
557
+ navigate('swarms');
558
+ // store pending swarm to open
559
+ state._pendingSwarmId = sw.id;
560
+ });
561
+ }
562
+ return card;
563
+ }
564
+
565
+ // ---------------------------------------------------------------------------
566
+ // Runs view
567
+ // ---------------------------------------------------------------------------
568
+
569
+ async function loadRuns() {
570
+ showLoading('runs');
571
+ try {
572
+ state.runs = await apiFetch('/api/runs');
573
+ renderRuns();
574
+ } catch (err) {
575
+ showError('runs', err.message);
576
+ }
577
+ }
578
+
579
+ function renderRuns() {
580
+ if (state.activeView !== 'runs') return;
581
+ const main = getMain();
582
+ if (!main) return;
583
+ main.innerHTML = '';
584
+
585
+ const section = el('section', { cls: 'view-section' });
586
+ section.appendChild(el('div', { cls: 'view-header' },
587
+ el('h1', { cls: 'view-title' }, 'Runs'),
588
+ el('span', { cls: 'view-subtitle' }, `${state.runs.length} run${state.runs.length !== 1 ? 's' : ''}`)
589
+ ));
590
+
591
+ // M32: dispatch panel
592
+ section.appendChild(buildDispatchPanel());
593
+
594
+ if (state.runs.length === 0) {
595
+ section.appendChild(el('div', { cls: 'empty-state' },
596
+ el('p', {}, 'No runs recorded yet.'),
597
+ el('p', { cls: 'hint' }, 'Use the dispatch panel above or run `ashlr run "your goal"` in your terminal.')
598
+ ));
599
+ } else {
600
+ section.appendChild(buildRunsTable(state.runs, true));
601
+ }
602
+
603
+ main.appendChild(section);
604
+ }
605
+
606
+ // ---------------------------------------------------------------------------
607
+ // M32: Dispatch panel (inside Runs view)
608
+ // ---------------------------------------------------------------------------
609
+
610
+ function buildDispatchPanel() {
611
+ const snap = state.snapshot;
612
+ const dispatchEnabled = snap ? snap.dispatchEnabled === true : false;
613
+ const token = getToken();
614
+ const canDispatch = dispatchEnabled && Boolean(token);
615
+
616
+ // Disable reason shown as a note under the button
617
+ let disabledReason = '';
618
+ if (!dispatchEnabled) disabledReason = 'Start the server with --allow-dispatch to enable dispatch.';
619
+ else if (!token) disabledReason = 'Enter your session token (gear icon) to enable dispatch.';
620
+
621
+ const panel = el('div', { cls: 'dispatch-panel' });
622
+ panel.appendChild(el('h2', { cls: 'dispatch-panel__title' }, 'Dispatch a run'));
623
+
624
+ // Goal textarea
625
+ const goalInput = el('textarea', {
626
+ cls: 'input dispatch-panel__goal',
627
+ placeholder: 'Goal — what should the agent do?',
628
+ rows: '2',
629
+ 'aria-label': 'Goal',
630
+ });
631
+
632
+ // Budget input
633
+ const budgetInput = el('input', {
634
+ type: 'number',
635
+ cls: 'input dispatch-panel__budget',
636
+ placeholder: 'Max tokens (optional)',
637
+ min: '1000',
638
+ max: '200000',
639
+ step: '1000',
640
+ 'aria-label': 'Max tokens budget',
641
+ });
642
+
643
+ // Estimate line
644
+ const estimateLine = el('div', { cls: 'dispatch-panel__estimate', id: 'dispatch-estimate', 'aria-live': 'polite' });
645
+
646
+ // Dispatch button
647
+ const dispatchBtn = el('button', {
648
+ cls: 'btn btn-primary dispatch-panel__btn',
649
+ type: 'button',
650
+ ...(canDispatch ? {} : { disabled: 'disabled' }),
651
+ }, 'Dispatch');
652
+ if (!canDispatch && disabledReason) {
653
+ dispatchBtn.title = disabledReason;
654
+ }
655
+
656
+ // Result line
657
+ const resultLine = el('div', { cls: 'dispatch-panel__result', 'aria-live': 'polite' });
658
+
659
+ // Debounced estimate fetch on input
660
+ function scheduleEstimate() {
661
+ if (state._estimateTimer) clearTimeout(state._estimateTimer);
662
+ state._estimateTimer = setTimeout(async () => {
663
+ const goal = goalInput.value.trim();
664
+ if (!goal) { estimateLine.textContent = ''; return; }
665
+ estimateLine.textContent = 'Estimating…';
666
+ try {
667
+ const params = new URLSearchParams({ kind: 'run', goal });
668
+ const maxTok = parseInt(budgetInput.value, 10);
669
+ if (!isNaN(maxTok) && maxTok > 0) params.set('maxTokens', String(maxTok));
670
+ const est = await apiFetch(`/api/estimate?${params}`);
671
+ const med = est.tokens?.median ?? '—';
672
+ const costMed = est.estCostUsd?.median != null ? `$${est.estCostUsd.median.toFixed(4)}` : '—';
673
+ const conf = est.confidence ?? '—';
674
+ estimateLine.textContent = `Est: ~${fmtK(med)} tokens · ${costMed} (${conf} confidence, n=${est.sampleSize ?? 0})`;
675
+ } catch (err) {
676
+ estimateLine.textContent = `Estimate unavailable: ${err.message}`;
677
+ }
678
+ }, 400);
679
+ }
680
+
681
+ goalInput.addEventListener('input', scheduleEstimate);
682
+ budgetInput.addEventListener('input', scheduleEstimate);
683
+
684
+ dispatchBtn.addEventListener('click', async () => {
685
+ const goal = goalInput.value.trim();
686
+ if (!goal) { resultLine.textContent = 'Goal is required.'; return; }
687
+ if (!canDispatch) { resultLine.textContent = disabledReason || 'Dispatch not available.'; return; }
688
+ dispatchBtn.disabled = true;
689
+ resultLine.textContent = 'Dispatching…';
690
+ try {
691
+ const body = { goal };
692
+ const maxTok = parseInt(budgetInput.value, 10);
693
+ if (!isNaN(maxTok) && maxTok > 0) body.maxTokens = maxTok;
694
+ const res = await fetch('/api/run', {
695
+ method: 'POST',
696
+ headers: { 'Content-Type': 'application/json', 'x-ashlr-token': token },
697
+ body: JSON.stringify(body),
698
+ });
699
+ const json = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
700
+ if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`);
701
+ resultLine.textContent = `Dispatched: run ${json.id ?? ''} (${json.status ?? 'queued'})`;
702
+ resultLine.className = 'dispatch-panel__result dispatch-panel__result--ok';
703
+ goalInput.value = '';
704
+ estimateLine.textContent = '';
705
+ // Refresh runs list
706
+ await loadRuns();
707
+ } catch (err) {
708
+ resultLine.textContent = `Error: ${err.message}`;
709
+ resultLine.className = 'dispatch-panel__result dispatch-panel__result--err';
710
+ } finally {
711
+ dispatchBtn.disabled = !canDispatch;
712
+ }
713
+ });
714
+
715
+ const row = el('div', { cls: 'dispatch-panel__row' });
716
+ row.appendChild(goalInput);
717
+ row.appendChild(budgetInput);
718
+
719
+ const actions = el('div', { cls: 'dispatch-panel__actions' });
720
+ actions.appendChild(estimateLine);
721
+ actions.appendChild(dispatchBtn);
722
+
723
+ if (!canDispatch && disabledReason) {
724
+ actions.appendChild(el('span', { cls: 'dispatch-panel__note' }, disabledReason));
725
+ }
726
+
727
+ panel.appendChild(row);
728
+ panel.appendChild(actions);
729
+ panel.appendChild(resultLine);
730
+ return panel;
731
+ }
732
+
733
+ function buildRunsTable(runs, full = false) {
734
+ const wrap = el('div', { cls: 'table-wrap' });
735
+ const table = el('table', { cls: 'data-table' });
736
+
737
+ const thead = el('thead', {});
738
+ const headerRow = el('tr', {});
739
+ for (const h of ['Goal', 'Status', 'Engine', 'Tokens', 'Cost', 'Updated']) {
740
+ headerRow.appendChild(el('th', {}, h));
741
+ }
742
+ thead.appendChild(headerRow);
743
+ table.appendChild(thead);
744
+
745
+ const tbody = el('tbody', {});
746
+ for (const run of runs) {
747
+ const tokens = (run.usage?.tokensIn ?? 0) + (run.usage?.tokensOut ?? 0);
748
+ const cost = run.usage?.estCostUsd ?? 0;
749
+ const tr = el('tr', { cls: 'table-row', style: 'cursor:pointer' });
750
+ tr.appendChild(el('td', { cls: 'goal-cell', title: run.goal }, truncate(run.goal, 60)));
751
+ tr.appendChild(el('td', {}, statusBadge(run.status)));
752
+ tr.appendChild(el('td', {}, run.engine ?? '—'));
753
+ tr.appendChild(el('td', { cls: 'num' }, fmtK(tokens)));
754
+ tr.appendChild(el('td', { cls: 'num' }, `$${fmt(cost)}`));
755
+ tr.appendChild(el('td', { cls: 'ts' }, fmtRelative(run.updatedAt)));
756
+ if (full) {
757
+ tr.addEventListener('click', () => showRunDetail(run));
758
+ }
759
+ tbody.appendChild(tr);
760
+ }
761
+ table.appendChild(tbody);
762
+ wrap.appendChild(table);
763
+ return wrap;
764
+ }
765
+
766
+ function showRunDetail(run) {
767
+ const main = getMain();
768
+ if (!main) return;
769
+ main.innerHTML = '';
770
+
771
+ const section = el('section', { cls: 'view-section' });
772
+ section.appendChild(el('div', { cls: 'view-header' },
773
+ el('button', { cls: 'btn back-btn', onClick: () => { state.activeView = 'runs'; renderRuns(); } }, '← Back'),
774
+ el('h1', { cls: 'view-title' }, 'Run Detail'),
775
+ statusBadge(run.status)
776
+ ));
777
+
778
+ // Meta
779
+ section.appendChild(infoGrid([
780
+ ['ID', run.id],
781
+ ['Goal', run.goal],
782
+ ['Engine', run.engine ?? '—'],
783
+ ['Provider', run.provider ?? '—'],
784
+ ['Created', fmtDate(run.createdAt)],
785
+ ['Updated', fmtDate(run.updatedAt)],
786
+ ['Tokens In', fmtK(run.usage?.tokensIn ?? 0)],
787
+ ['Tokens Out', fmtK(run.usage?.tokensOut ?? 0)],
788
+ ['Cost', `$${fmt(run.usage?.estCostUsd ?? 0)}`],
789
+ ['Steps', run.usage?.steps ?? 0],
790
+ ]));
791
+
792
+ // Task list
793
+ if (run.tasks && run.tasks.length > 0) {
794
+ section.appendChild(el('h2', { cls: 'section-heading' }, 'Tasks'));
795
+ const taskList = el('div', { cls: 'task-list' });
796
+ for (const t of run.tasks) {
797
+ taskList.appendChild(taskRow(t));
798
+ }
799
+ section.appendChild(taskList);
800
+ }
801
+
802
+ // Result
803
+ if (run.result) {
804
+ section.appendChild(el('h2', { cls: 'section-heading' }, 'Result'));
805
+ section.appendChild(el('pre', { cls: 'result-pre' }, run.result));
806
+ }
807
+
808
+ main.appendChild(section);
809
+ }
810
+
811
+ function taskRow(t) {
812
+ const color = STATUS_COLOR[t.status] ?? '#9ca3af';
813
+ const row = el('div', { cls: 'task-row', style: `border-left:3px solid ${color}` });
814
+ row.appendChild(el('div', { cls: 'task-header' },
815
+ el('span', { cls: 'task-id' }, t.id),
816
+ statusBadge(t.status)
817
+ ));
818
+ row.appendChild(el('div', { cls: 'task-goal' }, t.goal));
819
+ if (t.deps && t.deps.length > 0) {
820
+ row.appendChild(el('div', { cls: 'task-deps' }, `Deps: ${t.deps.join(', ')}`));
821
+ }
822
+ if (t.error) {
823
+ row.appendChild(el('div', { cls: 'task-error' }, `Error: ${t.error}`));
824
+ }
825
+ return row;
826
+ }
827
+
828
+ // ---------------------------------------------------------------------------
829
+ // Swarms view
830
+ // ---------------------------------------------------------------------------
831
+
832
+ async function loadSwarms() {
833
+ showLoading('swarms');
834
+ try {
835
+ state.swarms = await apiFetch('/api/swarms');
836
+ renderSwarms();
837
+ } catch (err) {
838
+ showError('swarms', err.message);
839
+ }
840
+ }
841
+
842
+ function renderSwarms() {
843
+ if (state.activeView !== 'swarms') return;
844
+ const main = getMain();
845
+ if (!main) return;
846
+ main.innerHTML = '';
847
+
848
+ const section = el('section', { cls: 'view-section' });
849
+ section.appendChild(el('div', { cls: 'view-header' },
850
+ el('h1', { cls: 'view-title' }, 'Swarms'),
851
+ el('span', { cls: 'view-subtitle' }, `${state.swarms.length} swarm${state.swarms.length !== 1 ? 's' : ''}`)
852
+ ));
853
+
854
+ if (state.swarms.length === 0) {
855
+ section.appendChild(el('div', { cls: 'empty-state' },
856
+ el('p', {}, 'No swarms recorded yet.'),
857
+ el('p', { cls: 'hint' }, 'Run `ashlr swarm "your goal"` to start one.')
858
+ ));
859
+ main.appendChild(section);
860
+ return;
861
+ }
862
+
863
+ // Check if we should auto-open a pending swarm
864
+ const pendingId = state._pendingSwarmId;
865
+ delete state._pendingSwarmId;
866
+
867
+ if (pendingId) {
868
+ const sw = state.swarms.find((s) => s.id === pendingId);
869
+ if (sw) { renderSwarmDetail(sw, section, main); return; }
870
+ }
871
+
872
+ // List
873
+ const list = el('div', { cls: 'swarm-list' });
874
+ for (const sw of state.swarms) {
875
+ const row = el('div', { cls: 'swarm-list-row', style: 'cursor:pointer' });
876
+
877
+ const tasksDone = sw.tasks.filter((t) => t.status === 'done' || t.status === 'skipped').length;
878
+ const tasksTotal = sw.tasks.length;
879
+
880
+ row.appendChild(el('div', { cls: 'swarm-list-main' },
881
+ el('div', { cls: 'swarm-goal', title: sw.goal }, truncate(sw.goal, 70)),
882
+ el('div', { cls: 'swarm-meta-row' },
883
+ statusBadge(sw.status),
884
+ el('span', { cls: 'ts' }, fmtRelative(sw.updatedAt)),
885
+ el('span', {}, `${tasksTotal} tasks`)
886
+ )
887
+ ));
888
+ row.appendChild(progressBar(tasksDone, tasksTotal));
889
+ row.addEventListener('click', () => {
890
+ main.innerHTML = '';
891
+ const sec = el('section', { cls: 'view-section' });
892
+ renderSwarmDetail(sw, sec, main);
893
+ });
894
+ list.appendChild(row);
895
+ }
896
+ section.appendChild(list);
897
+ main.appendChild(section);
898
+ }
899
+
900
+ function renderSwarmDetail(sw, section, main) {
901
+ section.appendChild(el('div', { cls: 'view-header' },
902
+ el('button', { cls: 'btn back-btn', onClick: () => loadSwarms() }, '← Back'),
903
+ el('h1', { cls: 'view-title', title: sw.goal }, truncate(sw.goal, 50)),
904
+ statusBadge(sw.status)
905
+ ));
906
+
907
+ // Meta
908
+ const tasksDone = sw.tasks.filter((t) => t.status === 'done' || t.status === 'skipped').length;
909
+ section.appendChild(infoGrid([
910
+ ['ID', sw.id],
911
+ ['Status', sw.status],
912
+ ['Tasks', `${tasksDone} / ${sw.tasks.length} done`],
913
+ ['Cost', `$${fmt(sw.usage?.estCostUsd ?? 0)}`],
914
+ ['Tokens', fmtK((sw.usage?.tokensIn ?? 0) + (sw.usage?.tokensOut ?? 0))],
915
+ ['Created', fmtDate(sw.createdAt)],
916
+ ['Updated', fmtDate(sw.updatedAt)],
917
+ ]));
918
+
919
+ // Burndown bar
920
+ section.appendChild(el('h2', { cls: 'section-heading' }, 'Progress'));
921
+ section.appendChild(progressBar(tasksDone, sw.tasks.length));
922
+
923
+ // DAG Graph
924
+ section.appendChild(el('h2', { cls: 'section-heading' }, 'Task DAG'));
925
+ const dagSvg = buildSwarmDag(sw);
926
+ section.appendChild(el('div', { cls: 'dag-wrap' }, dagSvg));
927
+
928
+ // Task detail list
929
+ section.appendChild(el('h2', { cls: 'section-heading' }, 'Tasks'));
930
+ const taskList = el('div', { cls: 'task-list' });
931
+ for (const t of sw.tasks) {
932
+ const spec = sw.plan?.tasks?.find((p) => p.id === t.id);
933
+ const taskRun = Object.assign({}, spec ?? {}, t);
934
+ taskList.appendChild(taskRow(taskRun));
935
+ }
936
+ section.appendChild(taskList);
937
+
938
+ // Result
939
+ if (sw.result) {
940
+ section.appendChild(el('h2', { cls: 'section-heading' }, 'Result'));
941
+ section.appendChild(el('pre', { cls: 'result-pre' }, sw.result));
942
+ }
943
+
944
+ main.appendChild(section);
945
+ }
946
+
947
+ // ---------------------------------------------------------------------------
948
+ // Swarm DAG — SVG node-link diagram
949
+ // ---------------------------------------------------------------------------
950
+
951
+ function buildSwarmDag(sw) {
952
+ // Merge plan tasks (spec) with runtime tasks for status + goal
953
+ const specTasks = sw.plan?.tasks ?? [];
954
+ const runTasks = sw.tasks ?? [];
955
+
956
+ // Build combined task list: spec carries goal+deps+phase, run carries status
957
+ const taskMap = new Map();
958
+ for (const s of specTasks) taskMap.set(s.id, { ...s });
959
+ for (const r of runTasks) {
960
+ const existing = taskMap.get(r.id) ?? {};
961
+ taskMap.set(r.id, { ...existing, ...r });
962
+ }
963
+ const tasks = [...taskMap.values()];
964
+
965
+ if (tasks.length === 0) {
966
+ return el('div', { cls: 'dag-empty' }, 'No tasks in this swarm.');
967
+ }
968
+
969
+ // Layout: columns by phase, rows within phase
970
+ const phaseGroups = {};
971
+ for (const t of tasks) {
972
+ const ph = t.phase ?? 'build';
973
+ if (!phaseGroups[ph]) phaseGroups[ph] = [];
974
+ phaseGroups[ph].push(t);
975
+ }
976
+
977
+ const phases = Object.keys(phaseGroups).sort(
978
+ (a, b) => (PHASE_ORDER[a] ?? 99) - (PHASE_ORDER[b] ?? 99)
979
+ );
980
+
981
+ // Dimensions
982
+ const NODE_W = 140;
983
+ const NODE_H = 48;
984
+ const H_GAP = 60; // gap between columns
985
+ const V_GAP = 20; // gap between rows within a column
986
+ const PAD = 20;
987
+
988
+ const colX = {};
989
+ let x = PAD;
990
+ for (const ph of phases) {
991
+ colX[ph] = x;
992
+ x += NODE_W + H_GAP;
993
+ }
994
+ const totalW = x + PAD;
995
+
996
+ // Compute Y per task
997
+ const nodePos = {};
998
+ for (const ph of phases) {
999
+ const group = phaseGroups[ph];
1000
+ let y = PAD;
1001
+ for (const t of group) {
1002
+ nodePos[t.id] = { x: colX[ph], y, cx: colX[ph] + NODE_W / 2, cy: y + NODE_H / 2 };
1003
+ y += NODE_H + V_GAP;
1004
+ }
1005
+ }
1006
+
1007
+ // Total SVG height
1008
+ const totalH = Math.max(...Object.values(nodePos).map((p) => p.y + NODE_H)) + PAD * 2;
1009
+
1010
+ const svg = svgEl('svg', {
1011
+ width: totalW,
1012
+ height: totalH,
1013
+ viewBox: `0 0 ${totalW} ${totalH}`,
1014
+ class: 'dag-svg',
1015
+ 'aria-label': 'Swarm task dependency graph'
1016
+ });
1017
+
1018
+ // Phase column labels
1019
+ for (const ph of phases) {
1020
+ svg.appendChild(svgEl('text', {
1021
+ x: colX[ph] + NODE_W / 2,
1022
+ y: PAD - 6,
1023
+ 'text-anchor': 'middle',
1024
+ class: 'dag-phase-label'
1025
+ }, ph));
1026
+ }
1027
+
1028
+ // Draw edges (dep arrows) BELOW nodes
1029
+ for (const t of tasks) {
1030
+ if (!t.deps || !t.id || !nodePos[t.id]) continue;
1031
+ const to = nodePos[t.id];
1032
+ for (const depId of t.deps) {
1033
+ const from = nodePos[depId];
1034
+ if (!from) continue;
1035
+ // Draw a bezier from right-center of dep node to left-center of this node
1036
+ const x1 = from.x + NODE_W;
1037
+ const y1 = from.cy;
1038
+ const x2 = to.x;
1039
+ const y2 = to.cy;
1040
+ const mx = (x1 + x2) / 2;
1041
+ svg.appendChild(svgEl('path', {
1042
+ d: `M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`,
1043
+ class: 'dag-edge',
1044
+ fill: 'none',
1045
+ 'marker-end': 'url(#arrow)'
1046
+ }));
1047
+ }
1048
+ }
1049
+
1050
+ // Arrow marker
1051
+ const defs = svgEl('defs');
1052
+ const marker = svgEl('marker', {
1053
+ id: 'arrow',
1054
+ markerWidth: '8',
1055
+ markerHeight: '8',
1056
+ refX: '6',
1057
+ refY: '3',
1058
+ orient: 'auto'
1059
+ });
1060
+ marker.appendChild(svgEl('path', {
1061
+ d: 'M0,0 L0,6 L8,3 z',
1062
+ class: 'dag-arrow-head'
1063
+ }));
1064
+ defs.appendChild(marker);
1065
+ svg.insertBefore(defs, svg.firstChild);
1066
+
1067
+ // Draw nodes
1068
+ for (const t of tasks) {
1069
+ if (!nodePos[t.id]) continue;
1070
+ const pos = nodePos[t.id];
1071
+ const color = STATUS_COLOR[t.status] ?? '#6b7280';
1072
+ const g = svgEl('g', { class: 'dag-node', transform: `translate(${pos.x},${pos.y})` });
1073
+
1074
+ // Node rect
1075
+ g.appendChild(svgEl('rect', {
1076
+ width: NODE_W,
1077
+ height: NODE_H,
1078
+ rx: 6,
1079
+ ry: 6,
1080
+ class: 'dag-node-rect',
1081
+ style: `stroke:${color};fill:${color}18`
1082
+ }));
1083
+
1084
+ // Status dot
1085
+ g.appendChild(svgEl('circle', {
1086
+ cx: 10,
1087
+ cy: NODE_H / 2,
1088
+ r: 4,
1089
+ style: `fill:${color}`
1090
+ }));
1091
+
1092
+ // Task id
1093
+ g.appendChild(svgEl('text', {
1094
+ x: 20,
1095
+ y: 17,
1096
+ class: 'dag-node-id'
1097
+ }, truncate(t.id, 18)));
1098
+
1099
+ // Goal text
1100
+ g.appendChild(svgEl('text', {
1101
+ x: 20,
1102
+ y: 33,
1103
+ class: 'dag-node-goal'
1104
+ }, truncate(t.goal ?? '', 20)));
1105
+
1106
+ svg.appendChild(g);
1107
+ }
1108
+
1109
+ return svg;
1110
+ }
1111
+
1112
+ // ---------------------------------------------------------------------------
1113
+ // Pulse view
1114
+ // ---------------------------------------------------------------------------
1115
+
1116
+ async function loadPulse(window = '7d') {
1117
+ showLoading('pulse');
1118
+ try {
1119
+ state.pulse = await apiFetch(`/api/pulse?window=${window}`);
1120
+ renderPulse();
1121
+ } catch (err) {
1122
+ showError('pulse', err.message);
1123
+ }
1124
+ }
1125
+
1126
+ function renderPulse() {
1127
+ if (state.activeView !== 'pulse') return;
1128
+ const p = state.pulse;
1129
+ const main = getMain();
1130
+ if (!main) return;
1131
+ main.innerHTML = '';
1132
+
1133
+ if (!p) {
1134
+ main.appendChild(el('div', { cls: 'loading-wrap' }, el('p', {}, 'No data yet.')));
1135
+ return;
1136
+ }
1137
+
1138
+ const section = el('section', { cls: 'view-section' });
1139
+
1140
+ // Header + window picker
1141
+ const windowPicker = el('div', { cls: 'window-picker' });
1142
+ for (const w of ['1d', '7d', '30d']) {
1143
+ const btn = el('button', {
1144
+ cls: `btn window-btn ${p.window === w ? 'active' : ''}`,
1145
+ onClick: () => loadPulse(w)
1146
+ }, w);
1147
+ windowPicker.appendChild(btn);
1148
+ }
1149
+
1150
+ section.appendChild(el('div', { cls: 'view-header' },
1151
+ el('h1', { cls: 'view-title' }, 'Pulse'),
1152
+ windowPicker
1153
+ ));
1154
+
1155
+ // Totals row
1156
+ const budget = p.budget ?? {};
1157
+ const cards = el('div', { cls: 'cards-row' });
1158
+ cards.appendChild(metricCard('Cost', `$${fmt(p.totals?.estCostUsd ?? 0)}`, p.window, '#fbbf24'));
1159
+ cards.appendChild(metricCard('Tokens In', fmtK(p.totals?.tokensIn ?? 0), '', '#60a5fa'));
1160
+ cards.appendChild(metricCard('Tokens Out', fmtK(p.totals?.tokensOut ?? 0), '', '#a78bfa'));
1161
+ cards.appendChild(metricCard('Sessions', p.totals?.sessions ?? 0, `${p.totals?.commits ?? 0} commits`, '#4ade80'));
1162
+ section.appendChild(cards);
1163
+
1164
+ // Budget alert
1165
+ if (budget.level && budget.level !== 'ok') {
1166
+ const levelColor = budget.level === 'over' ? '#f87171' : '#fbbf24';
1167
+ section.appendChild(el('div', {
1168
+ cls: 'budget-alert',
1169
+ style: `border-left:4px solid ${levelColor};background:${levelColor}18`
1170
+ }, el('strong', {}, budget.level.toUpperCase() + ': '), budget.message ?? ''));
1171
+ }
1172
+
1173
+ // Daily cost chart
1174
+ if (p.byDay && p.byDay.length > 0) {
1175
+ section.appendChild(el('h2', { cls: 'section-heading' }, 'Daily Cost'));
1176
+ section.appendChild(buildBarChart(
1177
+ p.byDay.map((d) => ({ label: d.day.slice(5), value: d.estCostUsd, sub: `$${fmt(d.estCostUsd)}` })),
1178
+ '#fbbf24',
1179
+ { height: 160, prefix: '$' }
1180
+ ));
1181
+ }
1182
+
1183
+ // Daily tokens chart
1184
+ if (p.byDay && p.byDay.length > 0) {
1185
+ section.appendChild(el('h2', { cls: 'section-heading' }, 'Daily Tokens'));
1186
+ section.appendChild(buildBarChart(
1187
+ p.byDay.map((d) => ({ label: d.day.slice(5), value: d.tokensIn + d.tokensOut, sub: fmtK(d.tokensIn + d.tokensOut) })),
1188
+ '#60a5fa',
1189
+ { height: 160 }
1190
+ ));
1191
+ }
1192
+
1193
+ // By project table
1194
+ if (p.byProject && p.byProject.length > 0) {
1195
+ section.appendChild(el('h2', { cls: 'section-heading' }, 'By Project'));
1196
+ section.appendChild(buildProjectTable(p.byProject));
1197
+ }
1198
+
1199
+ // By model chart
1200
+ if (p.byModel && p.byModel.length > 0) {
1201
+ section.appendChild(el('h2', { cls: 'section-heading' }, 'By Model'));
1202
+ section.appendChild(buildBarChart(
1203
+ p.byModel.map((m) => ({ label: shortModel(m.model), value: m.estCostUsd, sub: `$${fmt(m.estCostUsd)} · ${m.calls} calls` })),
1204
+ '#a78bfa',
1205
+ { height: 160, prefix: '$' }
1206
+ ));
1207
+ }
1208
+
1209
+ main.appendChild(section);
1210
+ }
1211
+
1212
+ function shortModel(model) {
1213
+ if (!model) return '—';
1214
+ const parts = model.split(/[/-]/);
1215
+ return parts[parts.length - 1] ?? model;
1216
+ }
1217
+
1218
+ // SVG bar chart
1219
+ function buildBarChart(items, color, opts = {}) {
1220
+ const { height = 160, prefix = '' } = opts;
1221
+ if (!items.length) return el('div', { cls: 'empty-state' }, 'No data.');
1222
+
1223
+ const BAR_W = 36;
1224
+ const GAP = 8;
1225
+ const LABEL_H = 32;
1226
+ const PAD = 16;
1227
+ const chartH = height;
1228
+ const totalW = items.length * (BAR_W + GAP) + PAD * 2;
1229
+ const svgH = chartH + LABEL_H + PAD;
1230
+
1231
+ const maxVal = Math.max(...items.map((i) => i.value), 0.001);
1232
+
1233
+ const svg = svgEl('svg', {
1234
+ width: totalW,
1235
+ height: svgH,
1236
+ viewBox: `0 0 ${totalW} ${svgH}`,
1237
+ class: 'bar-chart-svg'
1238
+ });
1239
+
1240
+ // Max-value axis label (top-right, clear of the bars) — gives cost charts a
1241
+ // currency affordance via `prefix` (e.g. "$12.50" / "1.2K").
1242
+ svg.appendChild(svgEl('text', {
1243
+ x: totalW - PAD,
1244
+ y: PAD - 4,
1245
+ 'text-anchor': 'end',
1246
+ class: 'bar-axis-max',
1247
+ style: 'fill:#94a3b8;font-size:10px'
1248
+ }, `max ${prefix}${maxVal >= 1000 ? fmtK(maxVal) : fmt(maxVal)}`));
1249
+
1250
+ // Baseline
1251
+ svg.appendChild(svgEl('line', {
1252
+ x1: PAD,
1253
+ y1: chartH + PAD / 2,
1254
+ x2: totalW - PAD,
1255
+ y2: chartH + PAD / 2,
1256
+ class: 'bar-baseline'
1257
+ }));
1258
+
1259
+ items.forEach((item, i) => {
1260
+ const x = PAD + i * (BAR_W + GAP);
1261
+ const barH = Math.max(2, (item.value / maxVal) * chartH);
1262
+ const y = chartH + PAD / 2 - barH;
1263
+
1264
+ // Bar rect
1265
+ const rect = svgEl('rect', {
1266
+ x,
1267
+ y,
1268
+ width: BAR_W,
1269
+ height: barH,
1270
+ rx: 3,
1271
+ class: 'bar-rect',
1272
+ style: `fill:${color}cc`,
1273
+ 'data-val': item.sub ?? item.value
1274
+ });
1275
+
1276
+ // Tooltip via title
1277
+ const title = svgEl('title');
1278
+ title.textContent = `${item.label}: ${item.sub ?? item.value}`;
1279
+ rect.appendChild(title);
1280
+ svg.appendChild(rect);
1281
+
1282
+ // X-axis label
1283
+ svg.appendChild(svgEl('text', {
1284
+ x: x + BAR_W / 2,
1285
+ y: chartH + PAD / 2 + 16,
1286
+ 'text-anchor': 'middle',
1287
+ class: 'bar-label'
1288
+ }, item.label));
1289
+ });
1290
+
1291
+ // Wrap in scrollable div in case many bars
1292
+ const wrap = el('div', { cls: 'bar-chart-wrap' });
1293
+ wrap.appendChild(svg);
1294
+ return wrap;
1295
+ }
1296
+
1297
+ function buildProjectTable(byProject) {
1298
+ const wrap = el('div', { cls: 'table-wrap' });
1299
+ const table = el('table', { cls: 'data-table' });
1300
+ const thead = el('thead', {});
1301
+ const hr = el('tr', {});
1302
+ for (const h of ['Project', 'Sessions', 'Commits', 'Tokens', 'Cost']) {
1303
+ hr.appendChild(el('th', {}, h));
1304
+ }
1305
+ thead.appendChild(hr);
1306
+ table.appendChild(thead);
1307
+ const tbody = el('tbody', {});
1308
+ for (const p of byProject.slice(0, 20)) {
1309
+ const tr = el('tr', {});
1310
+ const name = p.project?.split('/').slice(-2).join('/') ?? '—';
1311
+ tr.appendChild(el('td', { title: p.project }, name));
1312
+ tr.appendChild(el('td', { cls: 'num' }, p.sessions));
1313
+ tr.appendChild(el('td', { cls: 'num' }, p.commits));
1314
+ tr.appendChild(el('td', { cls: 'num' }, fmtK(p.tokensIn + p.tokensOut)));
1315
+ tr.appendChild(el('td', { cls: 'num' }, `$${fmt(p.estCostUsd)}`));
1316
+ tbody.appendChild(tr);
1317
+ }
1318
+ table.appendChild(tbody);
1319
+ wrap.appendChild(table);
1320
+ return wrap;
1321
+ }
1322
+
1323
+ // ---------------------------------------------------------------------------
1324
+ // Genome view
1325
+ // ---------------------------------------------------------------------------
1326
+
1327
+ async function loadGenome(query = '') {
1328
+ showLoading('genome');
1329
+ try {
1330
+ const url = query ? `/api/genome?q=${encodeURIComponent(query)}` : '/api/genome';
1331
+ state.genome = await apiFetch(url);
1332
+ state.genomeQuery = query;
1333
+ renderGenome();
1334
+ } catch (err) {
1335
+ showError('genome', err.message);
1336
+ }
1337
+ }
1338
+
1339
+ function renderGenome() {
1340
+ if (state.activeView !== 'genome') return;
1341
+ const main = getMain();
1342
+ if (!main) return;
1343
+ main.innerHTML = '';
1344
+
1345
+ const section = el('section', { cls: 'view-section' });
1346
+ section.appendChild(el('div', { cls: 'view-header' },
1347
+ el('h1', { cls: 'view-title' }, 'Genome'),
1348
+ el('span', { cls: 'view-subtitle' }, 'Shared memory / cross-project notes')
1349
+ ));
1350
+
1351
+ // Search box
1352
+ const searchRow = el('div', { cls: 'search-row' });
1353
+ const input = el('input', {
1354
+ type: 'text',
1355
+ cls: 'search-input',
1356
+ placeholder: 'Search genome…',
1357
+ value: state.genomeQuery
1358
+ });
1359
+ const searchBtn = el('button', { cls: 'btn', onClick: () => loadGenome(input.value.trim()) }, 'Search');
1360
+ input.addEventListener('keydown', (e) => {
1361
+ if (e.key === 'Enter') loadGenome(input.value.trim());
1362
+ });
1363
+ searchRow.appendChild(input);
1364
+ searchRow.appendChild(searchBtn);
1365
+ if (state.genomeQuery) {
1366
+ const clearBtn = el('button', { cls: 'btn btn-ghost', onClick: () => loadGenome('') }, 'Clear');
1367
+ searchRow.appendChild(clearBtn);
1368
+ }
1369
+ section.appendChild(searchRow);
1370
+
1371
+ // Results
1372
+ const entries = state.genome;
1373
+ const isRecall = Array.isArray(entries) && entries.length > 0 && 'entry' in entries[0];
1374
+
1375
+ // Normalize: recall hits wrap { entry, score } vs direct GenomeEntry[]
1376
+ const items = isRecall
1377
+ ? entries.map((h) => ({ ...h.entry, _score: h.score, _method: h.method }))
1378
+ : entries;
1379
+
1380
+ if (items.length === 0) {
1381
+ section.appendChild(el('div', { cls: 'empty-state' },
1382
+ el('p', {}, state.genomeQuery ? `No results for "${state.genomeQuery}".` : 'No genome entries yet.'),
1383
+ el('p', { cls: 'hint' }, 'Use `ashlr learn "text"` to add entries.')
1384
+ ));
1385
+ } else {
1386
+ section.appendChild(el('div', { cls: 'view-subtitle' }, `${items.length} entr${items.length !== 1 ? 'ies' : 'y'}`));
1387
+ const list = el('div', { cls: 'genome-list' });
1388
+ for (const item of items) {
1389
+ list.appendChild(genomeCard(item));
1390
+ }
1391
+ section.appendChild(list);
1392
+ }
1393
+
1394
+ main.appendChild(section);
1395
+ }
1396
+
1397
+ function genomeCard(entry) {
1398
+ const card = el('div', { cls: 'genome-card' });
1399
+
1400
+ const header = el('div', { cls: 'genome-card-header' });
1401
+ header.appendChild(el('span', { cls: 'genome-title' }, entry.title || '(untitled)'));
1402
+ const meta = el('div', { cls: 'genome-meta' });
1403
+ if (entry.source) meta.appendChild(el('span', { cls: 'badge badge-source' }, entry.source));
1404
+ if (entry.project) meta.appendChild(el('span', { cls: 'genome-project', title: entry.project },
1405
+ entry.project.split('/').pop()));
1406
+ if (entry._score != null) meta.appendChild(el('span', { cls: 'badge' }, `score: ${entry._score.toFixed(2)}`));
1407
+ meta.appendChild(el('span', { cls: 'ts' }, fmtRelative(entry.ts)));
1408
+ header.appendChild(meta);
1409
+ card.appendChild(header);
1410
+
1411
+ card.appendChild(el('p', { cls: 'genome-text' }, entry.text));
1412
+
1413
+ if (entry.tags && entry.tags.length > 0) {
1414
+ const tagRow = el('div', { cls: 'genome-tags' });
1415
+ for (const tag of entry.tags) {
1416
+ tagRow.appendChild(el('span', { cls: 'badge badge-tag' }, tag));
1417
+ }
1418
+ card.appendChild(tagRow);
1419
+ }
1420
+
1421
+ return card;
1422
+ }
1423
+
1424
+ // ---------------------------------------------------------------------------
1425
+ // Portfolio view (M29) — READ-ONLY org-level summary.
1426
+ //
1427
+ // Reads GET /api/portfolio (a read-only projection of buildSnapshot().portfolio)
1428
+ // and renders health / in-flight goals / top backlog / cost+forecast /
1429
+ // effectiveness / today deltas. No mutation controls — there is nothing to
1430
+ // approve, apply, or dispatch from this view. Degrades to an empty-state card
1431
+ // when the portfolio section is null (older producer / empty enrollment).
1432
+ // ---------------------------------------------------------------------------
1433
+
1434
+ async function loadPortfolio() {
1435
+ showLoading('portfolio');
1436
+ try {
1437
+ state.portfolio = await apiFetch('/api/portfolio');
1438
+ renderPortfolio();
1439
+ } catch (err) {
1440
+ showError('portfolio', err.message);
1441
+ }
1442
+ }
1443
+
1444
+ function renderPortfolio() {
1445
+ if (state.activeView !== 'portfolio') return;
1446
+ const main = getMain();
1447
+ if (!main) return;
1448
+ main.innerHTML = '';
1449
+
1450
+ const section = el('section', { cls: 'view-section' });
1451
+ section.appendChild(el('div', { cls: 'view-header' },
1452
+ el('h1', { cls: 'view-title' }, 'Portfolio'),
1453
+ el('span', { cls: 'view-subtitle' }, 'Org-level health, goals, cost — read-only')
1454
+ ));
1455
+
1456
+ const p = state.portfolio;
1457
+ if (!p) {
1458
+ section.appendChild(el('div', { cls: 'empty-state' },
1459
+ el('p', {}, 'No portfolio data yet.'),
1460
+ el('p', { cls: 'hint' }, 'Enroll repos and run `ashlr health` / `ashlr goals` to populate this view.')
1461
+ ));
1462
+ main.appendChild(section);
1463
+ return;
1464
+ }
1465
+
1466
+ // ── Health ────────────────────────────────────────────────────────────
1467
+ const h = p.health || { reposScored: 0, averageScore: 0, averageGrade: 'F', worstRepos: [] };
1468
+ const healthCard = el('div', { cls: 'card' });
1469
+ healthCard.appendChild(el('h2', { cls: 'card-title' }, 'Health'));
1470
+ if (h.reposScored > 0) {
1471
+ healthCard.appendChild(infoGrid([
1472
+ ['Repos scored', h.reposScored],
1473
+ ['Avg score', Math.round(h.averageScore)],
1474
+ ['Avg grade', h.averageGrade],
1475
+ ]));
1476
+ if (Array.isArray(h.worstRepos) && h.worstRepos.length > 0) {
1477
+ const ul = el('ul', { cls: 'portfolio-worst' });
1478
+ for (const w of h.worstRepos) {
1479
+ ul.appendChild(el('li', {},
1480
+ el('span', { cls: 'portfolio-repo', title: w.repo }, (w.repo || '').split('/').filter(Boolean).pop() || w.repo),
1481
+ el('span', { cls: `badge grade-${w.grade}` }, `${w.grade} (${Math.round(w.score)})`)
1482
+ ));
1483
+ }
1484
+ healthCard.appendChild(el('p', { cls: 'hint' }, 'Needs attention:'));
1485
+ healthCard.appendChild(ul);
1486
+ }
1487
+ } else {
1488
+ healthCard.appendChild(el('p', { cls: 'hint' }, 'No enrolled repos scored.'));
1489
+ }
1490
+ section.appendChild(healthCard);
1491
+
1492
+ // ── Goals in flight ─────────────────────────────────────────────────────
1493
+ const goals = Array.isArray(p.goalsInFlight) ? p.goalsInFlight : [];
1494
+ const goalsCard = el('div', { cls: 'card' });
1495
+ goalsCard.appendChild(el('h2', { cls: 'card-title' }, `Goals in flight (${goals.length})`));
1496
+ if (goals.length === 0) {
1497
+ goalsCard.appendChild(el('p', { cls: 'hint' }, 'None active.'));
1498
+ } else {
1499
+ const list = el('div', { cls: 'portfolio-goals' });
1500
+ for (const g of goals) {
1501
+ const pct = Math.round(Math.max(0, Math.min(1, g.fractionDone || 0)) * 100);
1502
+ const row = el('div', { cls: 'portfolio-goal' });
1503
+ row.appendChild(el('div', { cls: 'portfolio-goal__obj', title: g.objective },
1504
+ truncate(g.objective, 70)));
1505
+ row.appendChild(el('div', { cls: 'portfolio-goal__meta' }, `${pct}% · ${g.proposed}/${g.totalMilestones} proposed`));
1506
+ if (g.nextActionable) {
1507
+ row.appendChild(el('div', { cls: 'hint' }, `next: ${truncate(g.nextActionable, 60)}`));
1508
+ }
1509
+ list.appendChild(row);
1510
+ }
1511
+ goalsCard.appendChild(list);
1512
+ }
1513
+ section.appendChild(goalsCard);
1514
+
1515
+ // ── Top backlog ───────────────────────────────────────────────────────
1516
+ const backlog = Array.isArray(p.backlogTop) ? p.backlogTop : [];
1517
+ const backlogCard = el('div', { cls: 'card' });
1518
+ backlogCard.appendChild(el('h2', { cls: 'card-title' }, 'Top backlog'));
1519
+ if (backlog.length === 0) {
1520
+ backlogCard.appendChild(el('p', { cls: 'hint' }, 'Empty.'));
1521
+ } else {
1522
+ const ul = el('ul', { cls: 'portfolio-backlog' });
1523
+ for (const item of backlog) {
1524
+ ul.appendChild(el('li', {},
1525
+ el('span', { cls: 'badge' }, String(Math.round(item.score))),
1526
+ el('span', { cls: 'portfolio-backlog__title', title: item.title }, truncate(item.title, 80))
1527
+ ));
1528
+ }
1529
+ backlogCard.appendChild(ul);
1530
+ }
1531
+ section.appendChild(backlogCard);
1532
+
1533
+ // ── Cost + forecast ─────────────────────────────────────────────────────
1534
+ const c = p.cost || { window: '7d', spentUsd: 0, localSavingsUsd: 0, projectedMonthlyUsd: 0 };
1535
+ const costCard = el('div', { cls: 'card' });
1536
+ costCard.appendChild(el('h2', { cls: 'card-title' }, `Cost (${c.window})`));
1537
+ costCard.appendChild(infoGrid([
1538
+ ['Spent', `$${(c.spentUsd || 0).toFixed(2)}`],
1539
+ ['Saved (local)', `$${(c.localSavingsUsd || 0).toFixed(2)}`],
1540
+ ['Projected / mo', `$${(c.projectedMonthlyUsd || 0).toFixed(2)}`],
1541
+ ]));
1542
+ section.appendChild(costCard);
1543
+
1544
+ // ── Effectiveness ───────────────────────────────────────────────────────
1545
+ if (p.effectiveness) {
1546
+ const eff = el('div', { cls: 'card' });
1547
+ eff.appendChild(el('h2', { cls: 'card-title' }, 'Effectiveness'));
1548
+ eff.appendChild(el('p', {}, p.effectiveness.headline || ''));
1549
+ section.appendChild(eff);
1550
+ }
1551
+
1552
+ // ── Today (day-over-day deltas) ──────────────────────────────────────────
1553
+ const t = p.today || { previousAt: null };
1554
+ const todayCard = el('div', { cls: 'card' });
1555
+ todayCard.appendChild(el('h2', { cls: 'card-title' }, 'Today'));
1556
+ if (t.previousAt === null || t.previousAt === undefined) {
1557
+ todayCard.appendChild(el('p', { cls: 'hint' }, 'No prior digest to compare against yet.'));
1558
+ } else {
1559
+ todayCard.appendChild(infoGrid([
1560
+ ['Pending Δ', fmtDelta(t.pendingProposalsDelta)],
1561
+ ['Dirty Δ', fmtDelta(t.dirtyReposDelta)],
1562
+ ['Spend Δ', fmtDelta(t.spendUsdDelta, '$')],
1563
+ ['Health Δ', fmtDelta(t.healthScoreDelta)],
1564
+ ['Goals Δ', fmtDelta(t.goalsInFlightDelta)],
1565
+ ]));
1566
+ }
1567
+ section.appendChild(todayCard);
1568
+
1569
+ main.appendChild(section);
1570
+ }
1571
+
1572
+ // ---------------------------------------------------------------------------
1573
+ // M32: Inbox view
1574
+ // ---------------------------------------------------------------------------
1575
+
1576
+ async function loadInbox() {
1577
+ showLoading('inbox');
1578
+ try {
1579
+ const data = await apiFetch('/api/inbox');
1580
+ state.inbox = data;
1581
+ state.inboxBadge = data.pending ?? 0;
1582
+ updateInboxBadge();
1583
+ renderInbox();
1584
+ } catch (err) {
1585
+ showError('inbox', err.message);
1586
+ }
1587
+ }
1588
+
1589
+ function renderInbox() {
1590
+ if (state.activeView !== 'inbox') return;
1591
+ const main = getMain();
1592
+ if (!main) return;
1593
+ main.innerHTML = '';
1594
+
1595
+ const section = el('section', { cls: 'view-section' });
1596
+ section.appendChild(el('div', { cls: 'view-header' },
1597
+ el('h1', { cls: 'view-title' }, 'Inbox'),
1598
+ el('span', { cls: 'view-subtitle' }, `${state.inbox.pending ?? 0} pending`)
1599
+ ));
1600
+
1601
+ const proposals = state.inbox.proposals ?? [];
1602
+
1603
+ if (state.inboxDetail) {
1604
+ // Detail pane
1605
+ section.appendChild(buildInboxDetail(state.inboxDetail));
1606
+ main.appendChild(section);
1607
+ return;
1608
+ }
1609
+
1610
+ if (proposals.length === 0) {
1611
+ section.appendChild(el('div', { cls: 'empty-state' },
1612
+ el('p', {}, 'No pending proposals.'),
1613
+ el('p', { cls: 'hint' }, 'Proposals appear here when the daemon queues work for review.')
1614
+ ));
1615
+ } else {
1616
+ const list = el('div', { cls: 'inbox-list' });
1617
+ for (const p of proposals) {
1618
+ list.appendChild(buildInboxRow(p));
1619
+ }
1620
+ section.appendChild(list);
1621
+ }
1622
+
1623
+ main.appendChild(section);
1624
+ }
1625
+
1626
+ function buildInboxRow(p) {
1627
+ const row = el('div', { cls: 'inbox-row', style: 'cursor:pointer' });
1628
+ const repoBase = (p.repo ?? '').split('/').filter(Boolean).pop() ?? p.repo ?? '—';
1629
+
1630
+ row.appendChild(el('div', { cls: 'inbox-row__main' },
1631
+ el('span', { cls: 'inbox-row__title', title: p.title }, truncate(p.title ?? '(untitled)', 80)),
1632
+ el('div', { cls: 'inbox-row__meta' },
1633
+ el('span', { cls: 'badge badge-kind' }, p.kind ?? 'proposal'),
1634
+ el('span', { cls: 'inbox-row__repo' }, repoBase),
1635
+ el('span', { cls: 'ts' }, fmtRelative(p.createdAt))
1636
+ )
1637
+ ));
1638
+
1639
+ row.addEventListener('click', async () => {
1640
+ try {
1641
+ const full = await apiFetch(`/api/inbox/${encodeURIComponent(p.id)}`);
1642
+ state.inboxDetail = full;
1643
+ renderInbox();
1644
+ } catch (err) {
1645
+ const main_ = getMain();
1646
+ if (main_) {
1647
+ const errEl = el('div', { cls: 'inline-error' }, `Failed to load proposal: ${err.message}`);
1648
+ main_.appendChild(errEl);
1649
+ }
1650
+ }
1651
+ });
1652
+
1653
+ return row;
1654
+ }
1655
+
1656
+ function buildInboxDetail(p) {
1657
+ const snap = state.snapshot;
1658
+ const dispatchEnabled = snap ? snap.dispatchEnabled === true : false;
1659
+ const token = getToken();
1660
+ const canAct = dispatchEnabled && Boolean(token);
1661
+
1662
+ let disabledReason = '';
1663
+ if (!dispatchEnabled) disabledReason = 'Start the server with --allow-dispatch to approve or reject.';
1664
+ else if (!token) disabledReason = 'Enter your session token (gear icon) to approve or reject.';
1665
+
1666
+ const detail = el('div', { cls: 'inbox-detail' });
1667
+
1668
+ // Back button
1669
+ detail.appendChild(el('button', {
1670
+ cls: 'btn back-btn',
1671
+ type: 'button',
1672
+ onClick: () => { state.inboxDetail = null; renderInbox(); },
1673
+ }, '← Back'));
1674
+
1675
+ const repoBase = (p.repo ?? '').split('/').filter(Boolean).pop() ?? p.repo ?? '—';
1676
+
1677
+ // Summary card
1678
+ const summary = el('div', { cls: 'inbox-detail__summary card' });
1679
+ summary.appendChild(el('h2', { cls: 'card-title' }, p.title ?? '(untitled)'));
1680
+ summary.appendChild(infoGrid([
1681
+ ['Kind', p.kind ?? '—'],
1682
+ ['Repo', repoBase],
1683
+ ['Origin', p.origin ?? '—'],
1684
+ ['Status', p.status ?? '—'],
1685
+ ['Created', fmtDate(p.createdAt)],
1686
+ ]));
1687
+ detail.appendChild(summary);
1688
+
1689
+ // Diff
1690
+ if (p.diff) {
1691
+ const diffSection = el('div', { cls: 'inbox-detail__diff-wrap' });
1692
+ diffSection.appendChild(el('h3', { cls: 'section-heading' }, 'Diff'));
1693
+ const pre = el('pre', { cls: 'inbox-diff' });
1694
+ pre.innerHTML = escapeHtml(p.diff);
1695
+ diffSection.appendChild(pre);
1696
+ detail.appendChild(diffSection);
1697
+ }
1698
+
1699
+ // Approve / Reject buttons + result
1700
+ const actionsDiv = el('div', { cls: 'inbox-detail__actions' });
1701
+ const resultLine = el('div', { cls: 'inbox-detail__result', 'aria-live': 'polite' });
1702
+
1703
+ const approveBtn = el('button', {
1704
+ cls: 'btn btn-primary',
1705
+ type: 'button',
1706
+ ...(canAct ? {} : { disabled: 'disabled' }),
1707
+ title: canAct ? 'Approve and apply this proposal' : disabledReason,
1708
+ }, 'Approve');
1709
+
1710
+ const rejectBtn = el('button', {
1711
+ cls: 'btn btn-danger',
1712
+ type: 'button',
1713
+ ...(canAct ? {} : { disabled: 'disabled' }),
1714
+ title: canAct ? 'Reject this proposal' : disabledReason,
1715
+ }, 'Reject');
1716
+
1717
+ approveBtn.addEventListener('click', async () => {
1718
+ if (!canAct) return;
1719
+ approveBtn.disabled = true;
1720
+ rejectBtn.disabled = true;
1721
+ resultLine.textContent = 'Approving…';
1722
+ try {
1723
+ const result = await apiPost(`/api/inbox/${encodeURIComponent(p.id)}/approve`, token);
1724
+ resultLine.textContent = result.detail ? `Applied: ${result.detail}` : (result.ok ? 'Approved and applied.' : 'Apply returned ok:false');
1725
+ resultLine.className = 'inbox-detail__result inbox-detail__result--ok';
1726
+ } catch (err) {
1727
+ resultLine.textContent = `Error: ${err.message}`;
1728
+ resultLine.className = 'inbox-detail__result inbox-detail__result--err';
1729
+ approveBtn.disabled = false;
1730
+ rejectBtn.disabled = false;
1731
+ }
1732
+ // Refresh inbox list after action
1733
+ state.inboxDetail = null;
1734
+ await loadInbox();
1735
+ });
1736
+
1737
+ rejectBtn.addEventListener('click', async () => {
1738
+ if (!canAct) return;
1739
+ approveBtn.disabled = true;
1740
+ rejectBtn.disabled = true;
1741
+ resultLine.textContent = 'Rejecting…';
1742
+ try {
1743
+ await apiPost(`/api/inbox/${encodeURIComponent(p.id)}/reject`, token);
1744
+ resultLine.textContent = 'Rejected.';
1745
+ resultLine.className = 'inbox-detail__result inbox-detail__result--ok';
1746
+ } catch (err) {
1747
+ resultLine.textContent = `Error: ${err.message}`;
1748
+ resultLine.className = 'inbox-detail__result inbox-detail__result--err';
1749
+ approveBtn.disabled = false;
1750
+ rejectBtn.disabled = false;
1751
+ }
1752
+ state.inboxDetail = null;
1753
+ await loadInbox();
1754
+ });
1755
+
1756
+ actionsDiv.appendChild(approveBtn);
1757
+ actionsDiv.appendChild(rejectBtn);
1758
+ if (!canAct && disabledReason) {
1759
+ actionsDiv.appendChild(el('span', { cls: 'dispatch-panel__note' }, disabledReason));
1760
+ }
1761
+ detail.appendChild(actionsDiv);
1762
+ detail.appendChild(resultLine);
1763
+
1764
+ return detail;
1765
+ }
1766
+
1767
+ // ---------------------------------------------------------------------------
1768
+ // M32: Daemon view
1769
+ // ---------------------------------------------------------------------------
1770
+
1771
+ async function loadDaemon() {
1772
+ showLoading('daemon');
1773
+ try {
1774
+ state.daemon = await apiFetch('/api/daemon');
1775
+ renderDaemon();
1776
+ } catch (err) {
1777
+ showError('daemon', err.message);
1778
+ }
1779
+ }
1780
+
1781
+ function renderDaemon() {
1782
+ if (state.activeView !== 'daemon') return;
1783
+ const main = getMain();
1784
+ if (!main) return;
1785
+ main.innerHTML = '';
1786
+
1787
+ const section = el('section', { cls: 'view-section' });
1788
+ section.appendChild(el('div', { cls: 'view-header' },
1789
+ el('h1', { cls: 'view-title' }, 'Daemon'),
1790
+ el('span', { cls: 'view-subtitle' }, 'Background automation state')
1791
+ ));
1792
+
1793
+ const d = state.daemon;
1794
+ if (!d) {
1795
+ section.appendChild(el('div', { cls: 'empty-state' },
1796
+ el('p', {}, 'Daemon state unavailable.'),
1797
+ el('p', { cls: 'hint' }, 'Start the daemon with `ashlr daemon start`.')
1798
+ ));
1799
+ main.appendChild(section);
1800
+ return;
1801
+ }
1802
+
1803
+ const card = el('div', { cls: 'daemon-card card' });
1804
+
1805
+ // Running indicator
1806
+ const runningDot = el('span', {
1807
+ cls: d.running ? 'daemon-dot daemon-dot--running' : 'daemon-dot',
1808
+ title: d.running ? 'Running' : 'Stopped',
1809
+ });
1810
+ const statusRow = el('div', { cls: 'daemon-card__status' },
1811
+ runningDot,
1812
+ el('span', { cls: d.running ? 'daemon-status--on' : 'daemon-status--off' },
1813
+ d.running ? 'Running' : 'Stopped')
1814
+ );
1815
+ card.appendChild(statusRow);
1816
+
1817
+ // Info grid
1818
+ const pairs = [
1819
+ ['Last tick', d.lastTick ? fmtRelative(d.lastTick) : '—'],
1820
+ ['Today spend', d.todaySpendUsd != null ? `$${d.todaySpendUsd.toFixed(4)}` : '—'],
1821
+ ['Spend cap', d.spendCapUsd != null ? `$${d.spendCapUsd.toFixed(2)}` : '—'],
1822
+ ['Pending proposals', d.pendingProposals ?? state.inboxBadge ?? '—'],
1823
+ ];
1824
+ card.appendChild(infoGrid(pairs));
1825
+
1826
+ // Spend vs cap mini bar
1827
+ if (d.spendCapUsd != null && d.todaySpendUsd != null) {
1828
+ const pct = Math.min(100, (d.todaySpendUsd / d.spendCapUsd) * 100);
1829
+ const level = pct >= 90 ? 'var(--status-failed)' : pct >= 70 ? 'var(--status-aborted)' : 'var(--status-done)';
1830
+ const track = el('div', { cls: 'daemon-spend-track' });
1831
+ track.appendChild(el('div', {
1832
+ cls: 'daemon-spend-fill',
1833
+ style: `width:${pct.toFixed(1)}%;background:${level}`,
1834
+ }));
1835
+ const spendWrap = el('div', { cls: 'daemon-spend-wrap' },
1836
+ el('span', { cls: 'daemon-spend-label' }, `Spend today: ${pct.toFixed(1)}% of cap`),
1837
+ track
1838
+ );
1839
+ card.appendChild(spendWrap);
1840
+ }
1841
+
1842
+ section.appendChild(card);
1843
+ main.appendChild(section);
1844
+ }
1845
+
1846
+ /** Format a signed delta for the portfolio "today" block. null → em-dash. */
1847
+ function fmtDelta(n, prefix = '') {
1848
+ if (n === null || n === undefined || typeof n !== 'number' || !isFinite(n)) return '—';
1849
+ const sign = n > 0 ? '+' : n < 0 ? '-' : '';
1850
+ const mag = prefix === '$' ? Math.abs(n).toFixed(2) : String(Math.abs(n));
1851
+ return `${sign}${prefix}${mag}`;
1852
+ }
1853
+
1854
+ // ---------------------------------------------------------------------------
1855
+ // Shared UI helpers
1856
+ // ---------------------------------------------------------------------------
1857
+
1858
+ function infoGrid(pairs) {
1859
+ const grid = el('dl', { cls: 'info-grid' });
1860
+ for (const [label, value] of pairs) {
1861
+ grid.appendChild(el('dt', {}, label));
1862
+ grid.appendChild(el('dd', { title: String(value) }, String(value ?? '—')));
1863
+ }
1864
+ return grid;
1865
+ }
1866
+
1867
+ function truncate(s, max) {
1868
+ if (!s) return '';
1869
+ return s.length > max ? s.slice(0, max - 1) + '…' : s;
1870
+ }
1871
+
1872
+ // ---------------------------------------------------------------------------
1873
+ // SSE dot indicator
1874
+ // ---------------------------------------------------------------------------
1875
+
1876
+ function updateSseDot(connected) {
1877
+ const dot = document.getElementById('sse-dot');
1878
+ if (!dot) return;
1879
+ dot.classList.toggle('connected', connected);
1880
+ }
1881
+
1882
+ // ---------------------------------------------------------------------------
1883
+ // Boot
1884
+ // ---------------------------------------------------------------------------
1885
+
1886
+ document.addEventListener('DOMContentLoaded', () => {
1887
+ renderShell();
1888
+
1889
+ // Connect SSE first so events can patch data while views load
1890
+ connectSSE();
1891
+
1892
+ // Wire SSE dot
1893
+ if (state.eventSource) {
1894
+ state.eventSource.addEventListener('open', () => updateSseDot(true));
1895
+ state.eventSource.addEventListener('error', () => updateSseDot(false));
1896
+ }
1897
+
1898
+ // Hash routing
1899
+ window.addEventListener('hashchange', onHashChange);
1900
+
1901
+ // Initial view
1902
+ onHashChange();
1903
+
1904
+ // M32: initialise token indicator after shell is rendered
1905
+ updateTokenIndicator();
1906
+ });