carthagent 0.0.0-stage → 1.0.7

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 (379) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +219 -2
  3. package/bin/ctg.mjs +736 -0
  4. package/docs/budgets.md +72 -0
  5. package/docs/compaction.md +34 -0
  6. package/docs/context-efficiency-plan.md +24 -0
  7. package/docs/context-efficiency-review.md +27 -0
  8. package/docs/context-efficiency.md +49 -0
  9. package/docs/deepseek-jev-benchmark-plan.md +70 -0
  10. package/docs/deepseek-jev-benchmark-repair-plan.md +20 -0
  11. package/docs/deepseek-jev-benchmark-restart.md +9 -0
  12. package/docs/deepseek-jev-benchmark-results.md +77 -0
  13. package/docs/deepseek-jev-benchmark-running.md +60 -0
  14. package/docs/deepseek-jev-benchmark-v3-results.md +41 -0
  15. package/docs/deepseek-jev-benchmark-v4-protocol.md +15 -0
  16. package/docs/deepseek-jev-benchmark-v4-results.md +119 -0
  17. package/docs/deepseek-jev-http400-diagnosis.md +45 -0
  18. package/docs/delivery-review-evidence.md +53 -0
  19. package/docs/delivery-timeout-repair-plan.md +51 -0
  20. package/docs/evaluation-readiness.md +31 -0
  21. package/docs/evaluation.md +70 -0
  22. package/docs/extending.md +354 -0
  23. package/docs/glm-pilot-execution.md +9 -0
  24. package/docs/glm-pilot-plan.md +15 -0
  25. package/docs/glm-pilot-results.md +71 -0
  26. package/docs/graph-context-optimization.md +15 -0
  27. package/docs/jev-repository-pilot-plan.md +25 -0
  28. package/docs/jev-repository-pilot-protocol.md +39 -0
  29. package/docs/jev-repository-pilot-restart.md +7 -0
  30. package/docs/jev-repository-pilot-results.md +79 -0
  31. package/docs/jev.md +32 -0
  32. package/docs/model-registry.md +25 -0
  33. package/docs/native-compatibility.md +117 -0
  34. package/docs/opencode-archive.md +9 -0
  35. package/docs/opencode-migration.md +115 -0
  36. package/docs/opencode-runtime.md +94 -0
  37. package/docs/opencode-workspace.md +31 -0
  38. package/docs/owned-runtime-workflow-registration.md +17 -0
  39. package/docs/plan-advisor-evaluation.md +38 -0
  40. package/docs/planning-startup-fix-plan.md +23 -0
  41. package/docs/planning-startup-fix-review.md +24 -0
  42. package/docs/planning.md +118 -0
  43. package/docs/production-merge-review.md +23 -0
  44. package/docs/quality.md +35 -0
  45. package/docs/repository-benchmark-design.md +111 -0
  46. package/docs/review-assurance.md +9 -0
  47. package/docs/review-experiment-results.md +59 -0
  48. package/docs/tested-planning-roadmap.md +58 -0
  49. package/docs/tested-planning.md +58 -0
  50. package/docs/tui-pickers.md +9 -0
  51. package/docs/webdriver.md +83 -0
  52. package/docs/workflow-modes.md +26 -0
  53. package/docs/workflow.d2 +17 -0
  54. package/docs/workflow.svg +149 -0
  55. package/docs/workspace-coordination.md +9 -0
  56. package/evaluate.mjs +249 -0
  57. package/extensions/codegraph.ts +36 -0
  58. package/extensions/delivery.ts +819 -0
  59. package/extensions/experiential.ts +21 -0
  60. package/extensions/subscriptions.ts +21 -0
  61. package/guidance/profiles/authenticated-web.json +24 -0
  62. package/guidance/profiles/data-visualization.json +25 -0
  63. package/guidance/profiles/database-change.json +24 -0
  64. package/guidance/profiles/external-api.json +24 -0
  65. package/guidance/profiles/transactional-data.json +25 -0
  66. package/guidance/profiles/web-application.json +26 -0
  67. package/lib/acceptance-review.mjs +4 -0
  68. package/lib/browser-bin.mjs +160 -0
  69. package/lib/browser-errors.mjs +54 -0
  70. package/lib/browser.mjs +219 -0
  71. package/lib/budget.mjs +37 -0
  72. package/lib/cdp.mjs +221 -0
  73. package/lib/cloud/client.mjs +275 -0
  74. package/lib/cloud/control-plane.mjs +350 -0
  75. package/lib/cloud/gateway.mjs +380 -0
  76. package/lib/cloud/http.mjs +135 -0
  77. package/lib/cloud/ledger.mjs +215 -0
  78. package/lib/codegraph-query.mjs +60 -0
  79. package/lib/codegraph.mjs +378 -0
  80. package/lib/context-archive.mjs +18 -0
  81. package/lib/delivery-context.mjs +132 -0
  82. package/lib/delivery-guidance.mjs +9 -0
  83. package/lib/delivery-review.mjs +94 -0
  84. package/lib/delivery.mjs +610 -0
  85. package/lib/engine.mjs +137 -0
  86. package/lib/guidance.mjs +71 -0
  87. package/lib/jev-advisor.mjs +47 -0
  88. package/lib/opencode/archive.mjs +122 -0
  89. package/lib/opencode/compatibility.mjs +19 -0
  90. package/lib/opencode/credentials.mjs +205 -0
  91. package/lib/opencode/evaluation-budget-plugin/index.ts +147 -0
  92. package/lib/opencode/evaluation.mjs +242 -0
  93. package/lib/opencode/file-lock.mjs +86 -0
  94. package/lib/opencode/headless.mjs +200 -0
  95. package/lib/opencode/review.mjs +213 -0
  96. package/lib/opencode/runtime.mjs +246 -0
  97. package/lib/opencode/service.mjs +249 -0
  98. package/lib/opencode/session-options.mjs +60 -0
  99. package/lib/opencode/workspace.mjs +133 -0
  100. package/lib/openrouter-jev.mjs +37 -0
  101. package/lib/outcome-contract.mjs +21 -0
  102. package/lib/plan-advisor.mjs +38 -0
  103. package/lib/planning-access.mjs +7 -0
  104. package/lib/planning-advice.mjs +28 -0
  105. package/lib/planning-discovery.mjs +14 -0
  106. package/lib/planning-guide.mjs +34 -0
  107. package/lib/planning-view.mjs +57 -0
  108. package/lib/planning.mjs +155 -0
  109. package/lib/probe-diagnostics.mjs +13 -0
  110. package/lib/providers/experiential.mjs +292 -0
  111. package/lib/providers/model-catalog.json +700 -0
  112. package/lib/providers/model-catalog.mjs +7 -0
  113. package/lib/providers/names.mjs +13 -0
  114. package/lib/providers/subscription-models.mjs +483 -0
  115. package/lib/reports.mjs +80 -0
  116. package/lib/review-state.mjs +49 -0
  117. package/lib/review.mjs +114 -0
  118. package/lib/scenarios.mjs +99 -0
  119. package/lib/ship-auth.mjs +44 -0
  120. package/lib/ship-jev.mjs +59 -0
  121. package/lib/ship-telemetry.mjs +183 -0
  122. package/lib/tui/ansi.mjs +255 -0
  123. package/lib/tui/app.mjs +2445 -0
  124. package/lib/tui/auth.mjs +249 -0
  125. package/lib/tui/carthage.mjs +109 -0
  126. package/lib/tui/clipboard.mjs +122 -0
  127. package/lib/tui/d2.mjs +431 -0
  128. package/lib/tui/feed.mjs +467 -0
  129. package/lib/tui/framing.mjs +80 -0
  130. package/lib/tui/glyphs.mjs +172 -0
  131. package/lib/tui/hash-worker.mjs +11 -0
  132. package/lib/tui/mock-engine.mjs +222 -0
  133. package/lib/tui/models.mjs +73 -0
  134. package/lib/tui/picker.mjs +18 -0
  135. package/lib/tui/theme.mjs +663 -0
  136. package/lib/tui/title.mjs +7 -0
  137. package/lib/update-channel.mjs +62 -0
  138. package/lib/update.mjs +230 -0
  139. package/lib/webdriver.mjs +82 -0
  140. package/lib/workflow.mjs +88 -0
  141. package/lib/workspace.mjs +39 -0
  142. package/metadata.json +8 -0
  143. package/package.json +94 -4
  144. package/plugins/carthagent/index.ts +3 -0
  145. package/plugins/carthagent/package.json +28 -0
  146. package/plugins/carthagent/src/archive-rpc.ts +46 -0
  147. package/plugins/carthagent/src/bootstrap.ts +38 -0
  148. package/plugins/carthagent/src/index.ts +62 -0
  149. package/plugins/carthagent/src/pull-request-rpc.ts +54 -0
  150. package/plugins/carthagent/src/rpc.ts +76 -0
  151. package/plugins/carthagent/src/server/archive-rpc.ts +327 -0
  152. package/plugins/carthagent/src/server/budgets.ts +21 -0
  153. package/plugins/carthagent/src/server/codegraph.ts +46 -0
  154. package/plugins/carthagent/src/server/delivery.ts +488 -0
  155. package/plugins/carthagent/src/server/device-auth.ts +91 -0
  156. package/plugins/carthagent/src/server/native-delivery.ts +26 -0
  157. package/plugins/carthagent/src/server/orchestration.ts +56 -0
  158. package/plugins/carthagent/src/server/policy.ts +24 -0
  159. package/plugins/carthagent/src/server/providers.ts +424 -0
  160. package/plugins/carthagent/src/server/pull-request-rpc.ts +387 -0
  161. package/plugins/carthagent/src/server/session-controls.ts +82 -0
  162. package/plugins/carthagent/src/server/telemetry.ts +92 -0
  163. package/plugins/carthagent/src/server/workspace-rpc.ts +349 -0
  164. package/plugins/carthagent/src/session-rpc.ts +31 -0
  165. package/plugins/carthagent/src/testing.ts +85 -0
  166. package/plugins/carthagent/src/workspace-rpc.ts +133 -0
  167. package/plugins/carthagent/test-plugin/index.ts +4 -0
  168. package/plugins/carthagent/test-plugin/package.json +1 -0
  169. package/plugins/carthagent/tsconfig.json +19 -0
  170. package/plugins/carthagent/tui.ts +1 -0
  171. package/prompts/delivery.md +7 -0
  172. package/public/index.html +983 -0
  173. package/scenarios/cli/grade_cli.py +69 -0
  174. package/scenarios/cli/scenario.json +21 -0
  175. package/scenarios/game/baseline/minecraft.py +193 -0
  176. package/scenarios/game/context.md +1 -0
  177. package/scenarios/game/grade_minecraft_clone.py +603 -0
  178. package/scenarios/game/requirements.txt +2 -0
  179. package/scenarios/game/scenario.json +33 -0
  180. package/server.mjs +341 -0
  181. package/skills/game-development/SKILL.md +36 -0
  182. package/skills/game-development/assets/ursina_starter.py +98 -0
  183. package/skills/game-development/scripts/verify_ursina.py +80 -0
  184. package/skills/software-delivery/SKILL.md +50 -0
  185. package/skills/typescript-delivery/SKILL.md +69 -0
  186. package/tests/acceptance-review.test.mjs +13 -0
  187. package/tests/auth.test.mjs +234 -0
  188. package/tests/bounded-summary.test.mjs +68 -0
  189. package/tests/browser-bin.test.mjs +91 -0
  190. package/tests/browser.test.mjs +96 -0
  191. package/tests/budget-engine.test.mjs +110 -0
  192. package/tests/budget.test.mjs +23 -0
  193. package/tests/cli.test.mjs +245 -0
  194. package/tests/clipboard.test.mjs +79 -0
  195. package/tests/cloud-client.test.mjs +129 -0
  196. package/tests/cloud-control-plane.test.mjs +144 -0
  197. package/tests/cloud-gateway.test.mjs +147 -0
  198. package/tests/cloud-http.test.mjs +102 -0
  199. package/tests/cloud-ledger.test.mjs +75 -0
  200. package/tests/codegraph-query.test.mjs +66 -0
  201. package/tests/codegraph.test.mjs +142 -0
  202. package/tests/delivery-context.test.mjs +66 -0
  203. package/tests/delivery-review.test.mjs +139 -0
  204. package/tests/delivery.test.mjs +343 -0
  205. package/tests/engine-api.mjs +24 -0
  206. package/tests/experiential-extension.test.mjs +37 -0
  207. package/tests/experiential.test.mjs +300 -0
  208. package/tests/extension.test.mjs +921 -0
  209. package/tests/fixtures/archive-plugin/index.ts +10 -0
  210. package/tests/guidance.test.mjs +78 -0
  211. package/tests/model-registry.test.mjs +68 -0
  212. package/tests/models.test.mjs +70 -0
  213. package/tests/openai-idempotency.test.mjs +99 -0
  214. package/tests/opencode-archive-rpc.test.mjs +82 -0
  215. package/tests/opencode-delivery.test.mjs +231 -0
  216. package/tests/opencode-providers.test.mjs +414 -0
  217. package/tests/opencode-runtime.test.mjs +455 -0
  218. package/tests/opencode-session-controls.test.mjs +115 -0
  219. package/tests/opencode-session-options.test.mjs +26 -0
  220. package/tests/opencode-workspace.test.mjs +339 -0
  221. package/tests/outcome-contract.test.mjs +17 -0
  222. package/tests/picker.test.mjs +34 -0
  223. package/tests/plan-advisor.test.mjs +88 -0
  224. package/tests/plan-review-loop.test.mjs +53 -0
  225. package/tests/planning-advice.test.mjs +27 -0
  226. package/tests/planning-contract.test.mjs +65 -0
  227. package/tests/planning-startup.test.mjs +38 -0
  228. package/tests/planning-view.test.mjs +33 -0
  229. package/tests/planning.test.mjs +223 -0
  230. package/tests/probe-diagnostics.test.mjs +21 -0
  231. package/tests/pull-request-rpc.test.mjs +366 -0
  232. package/tests/review-state.test.mjs +32 -0
  233. package/tests/review.test.mjs +127 -0
  234. package/tests/server.test.mjs +252 -0
  235. package/tests/ship-jev.test.mjs +77 -0
  236. package/tests/ship-telemetry.test.mjs +333 -0
  237. package/tests/standard-workflow.test.mjs +28 -0
  238. package/tests/subscription-models.test.mjs +416 -0
  239. package/tests/subscriptions-extension.test.mjs +27 -0
  240. package/tests/terminal-title.test.mjs +13 -0
  241. package/tests/test_grade_minecraft_clone.py +199 -0
  242. package/tests/test_starter.py +78 -0
  243. package/tests/theme.test.mjs +175 -0
  244. package/tests/tui-copy-paste.test.mjs +54 -0
  245. package/tests/tui.test.mjs +808 -0
  246. package/tests/update-channels.test.mjs +129 -0
  247. package/tests/update.test.mjs +175 -0
  248. package/tests/webdriver.test.mjs +31 -0
  249. package/tests/workflow.test.mjs +47 -0
  250. package/tests/workspace-rpc.test.mjs +236 -0
  251. package/tests/workspace.test.mjs +24 -0
  252. package/tools/benchmark/advisor.test.mjs +7 -0
  253. package/tools/benchmark/advisor.ts +27 -0
  254. package/tools/benchmark/campaign.mjs +45 -0
  255. package/tools/benchmark/diagnose-request.mjs +40 -0
  256. package/tools/benchmark/eval-common.mjs +32 -0
  257. package/tools/benchmark/eval-ledger.mjs +22 -0
  258. package/tools/benchmark/eval-shop.mjs +26 -0
  259. package/tools/benchmark/eval-voxel.mjs +20 -0
  260. package/tools/benchmark/evaluate.mjs +24 -0
  261. package/tools/benchmark/evaluators.test.mjs +13 -0
  262. package/tools/benchmark/fixtures/shop/server.mjs +18 -0
  263. package/tools/benchmark/fixtures/shop/ui.html +8 -0
  264. package/tools/benchmark/freeze.mjs +30 -0
  265. package/tools/benchmark/gateway.mjs +91 -0
  266. package/tools/benchmark/gateway.test.mjs +38 -0
  267. package/tools/benchmark/generator.mjs +11 -0
  268. package/tools/benchmark/generator.test.mjs +26 -0
  269. package/tools/benchmark/guard.test.mjs +133 -0
  270. package/tools/benchmark/guard.ts +136 -0
  271. package/tools/benchmark/native-plugin/index.ts +24 -0
  272. package/tools/benchmark/planning-startup.test.mjs +47 -0
  273. package/tools/benchmark/preflight-live.mjs +31 -0
  274. package/tools/benchmark/preflight.mjs +22 -0
  275. package/tools/benchmark/replay-diagnostic.mjs +23 -0
  276. package/tools/benchmark/runner.mjs +141 -0
  277. package/tools/benchmark/runner.test.mjs +93 -0
  278. package/tools/benchmark/runtime.mjs +23 -0
  279. package/tools/benchmark/selftest.mjs +47 -0
  280. package/tools/benchmark/smoke-jev.mjs +5 -0
  281. package/tools/benchmark/tasks.mjs +20 -0
  282. package/tools/benchmark/transport.mjs +65 -0
  283. package/tools/benchmark/transport.test.mjs +44 -0
  284. package/tools/benchmark/upstream-error.mjs +18 -0
  285. package/tools/benchmark/upstream-error.test.mjs +10 -0
  286. package/tools/browser-check.mjs +109 -0
  287. package/tools/check-syntax.mjs +27 -0
  288. package/tools/evaluate-plan-advisor.mjs +20 -0
  289. package/tools/native-fixture.mjs +194 -0
  290. package/tools/native-pty-smoke.py +108 -0
  291. package/tools/native-report-plugin/index.ts +50 -0
  292. package/tools/native-report-plugin/package.json +7 -0
  293. package/tools/patch-compaction.mjs +24 -0
  294. package/tools/patch-model-catalog.mjs +12 -0
  295. package/tools/patch-print-shutdown.mjs +39 -0
  296. package/tools/repo-pilot/evaluator.cjs +32 -0
  297. package/tools/repo-pilot/reference.mjs +43 -0
  298. package/tools/repo-pilot/runner.mjs +85 -0
  299. package/tools/repo-pilot/sandbox-entry.mjs +12 -0
  300. package/tools/repo-pilot/tasks.mjs +20 -0
  301. package/tools/repo-pilot/type-evaluator.cjs +6 -0
  302. package/tools/test-native-controls.mjs +84 -0
  303. package/tools/test-native-delivery-host.mjs +307 -0
  304. package/tools/test-native-evaluation.mjs +46 -0
  305. package/tools/test-native-host.mjs +385 -0
  306. package/tools/test-native-outcomes.mjs +73 -0
  307. package/tools/test-native-package.mjs +70 -0
  308. package/tools/test-native-pty.mjs +20 -0
  309. package/tools/test-native-report.mjs +88 -0
  310. package/tools/test-native-swe-host.mjs +132 -0
  311. package/tools/test-native-swe.mjs +122 -0
  312. package/tools/test-picker-pty.py +45 -0
  313. package/tools/test-python.mjs +29 -0
  314. package/tools/test-webdriver.mjs +54 -0
  315. package/tools/verify-release-version.mjs +37 -0
  316. package/tools/webdriver-check.mjs +52 -0
  317. package/vendor/agent/bounded-summary.mjs +88 -0
  318. package/vendor/agent/chunks/anthropic-messages-VWZZOSJQ.js +6 -0
  319. package/vendor/agent/chunks/anthropic.js +75 -0
  320. package/vendor/agent/chunks/azure-openai-responses-BJLAM6CD.js +2 -0
  321. package/vendor/agent/chunks/bedrock-converse-stream.js +74 -0
  322. package/vendor/agent/chunks/chord-context.js +96 -0
  323. package/vendor/agent/chunks/chunk-4L3WN2XY.js +2 -0
  324. package/vendor/agent/chunks/chunk-6CYCI3EJ.js +12 -0
  325. package/vendor/agent/chunks/chunk-6R7UI4IY.js +2 -0
  326. package/vendor/agent/chunks/chunk-7ZC4UXSL.js +2 -0
  327. package/vendor/agent/chunks/chunk-AXIIZGTV.js +2 -0
  328. package/vendor/agent/chunks/chunk-CLNPYIDP.js +42 -0
  329. package/vendor/agent/chunks/chunk-DRQDKNXA.js +2 -0
  330. package/vendor/agent/chunks/chunk-GMWCTUPB.js +2 -0
  331. package/vendor/agent/chunks/chunk-IDDQWTHI.js +2 -0
  332. package/vendor/agent/chunks/chunk-JVUZSMYM.js +1548 -0
  333. package/vendor/agent/chunks/chunk-L2ZPNLYD.js +2 -0
  334. package/vendor/agent/chunks/chunk-NUHFSC37.js +25 -0
  335. package/vendor/agent/chunks/chunk-OUXLLA64.js +11 -0
  336. package/vendor/agent/chunks/chunk-PGXDEOVV.js +2 -0
  337. package/vendor/agent/chunks/chunk-PJACZCIR.js +2 -0
  338. package/vendor/agent/chunks/chunk-PRRNXB7S.js +2 -0
  339. package/vendor/agent/chunks/chunk-TEPMHNKQ.js +2 -0
  340. package/vendor/agent/chunks/chunk-TRDNDS6A.js +4 -0
  341. package/vendor/agent/chunks/chunk-U6ZMQKGD.js +50 -0
  342. package/vendor/agent/chunks/chunk-UAQELI3K.js +2 -0
  343. package/vendor/agent/chunks/chunk-USJZMAIR.js +2 -0
  344. package/vendor/agent/chunks/chunk-XDUWOHPL.js +6 -0
  345. package/vendor/agent/chunks/chunk-XNGRGP62.js +2 -0
  346. package/vendor/agent/chunks/github-copilot.js +2 -0
  347. package/vendor/agent/chunks/google-generative-ai-Q2K7FBDI.js +2 -0
  348. package/vendor/agent/chunks/google-vertex-7UJBF4S7.js +2 -0
  349. package/vendor/agent/chunks/https-proxy-agent-2VXB7436.js +13 -0
  350. package/vendor/agent/chunks/image-resize-worker.js +2 -0
  351. package/vendor/agent/chunks/kimi-coding.js +2 -0
  352. package/vendor/agent/chunks/lib-HGQMNYVT.js +9 -0
  353. package/vendor/agent/chunks/mistral-conversations-4M6BZBX6.js +5 -0
  354. package/vendor/agent/chunks/multipart-parser-SQ6GDJL7.js +3 -0
  355. package/vendor/agent/chunks/node-KG362ECQ.js +16 -0
  356. package/vendor/agent/chunks/node-TWVVAGPO.js +2 -0
  357. package/vendor/agent/chunks/openai-codex-responses-R6VYZTVY.js +10 -0
  358. package/vendor/agent/chunks/openai-codex.js +75 -0
  359. package/vendor/agent/chunks/openai-completions-EKZT2IH2.js +7 -0
  360. package/vendor/agent/chunks/openai-responses-TFDINO6W.js +2 -0
  361. package/vendor/agent/chunks/openrouter-images-N4XVVFSD.js +2 -0
  362. package/vendor/agent/chunks/openrouter.js +75 -0
  363. package/vendor/agent/chunks/pi-messages-PML4SPVJ.js +8 -0
  364. package/vendor/agent/chunks/radius.js +75 -0
  365. package/vendor/agent/chunks/src-UK77SYZE.js +4 -0
  366. package/vendor/agent/chunks/xai.js +2 -0
  367. package/vendor/agent/cli.js +3 -0
  368. package/vendor/agent/dist/modes/interactive/theme/dark.json +91 -0
  369. package/vendor/agent/dist/modes/interactive/theme/light.json +90 -0
  370. package/vendor/agent/dist/modes/interactive/theme/theme-schema.json +356 -0
  371. package/vendor/agent/index.js +2 -0
  372. package/vendor/agent/modes/interactive/theme/dark.json +91 -0
  373. package/vendor/agent/modes/interactive/theme/light.json +90 -0
  374. package/vendor/agent/modes/interactive/theme/theme-schema.json +356 -0
  375. package/vendor/agent/package.json +14 -0
  376. package/vendor/agent/rpc-entry.js +3 -0
  377. package/vendor/agent/theme/dark.json +91 -0
  378. package/vendor/agent/theme/light.json +90 -0
  379. package/vendor/agent/theme/theme-schema.json +356 -0
package/lib/tui/d2.mjs ADDED
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Parse + render D2 flowchart sources (the `planD2` shape emitted by the
3
+ * delivery extension) as a Unicode box-drawing graph for terminal display.
4
+ *
5
+ * Layout: longest-path ranks on the forward-edge DAG; edges that point back
6
+ * to an earlier rank ("back edges", e.g. repair -> verify) are drawn as a
7
+ * side channel. Straight edges render as │ + ▼ connectors, side branches as
8
+ * elbows, and the active edge carries an animated packet.
9
+ */
10
+ import { fg, bg, mix, width, wrap } from './ansi.mjs';
11
+ import { UNICODE_GLYPHS } from './glyphs.mjs';
12
+
13
+ /** Parse planD2-style source into { nodes:[{id,label}], edges:[{from,to,label}] }. */
14
+ export function parseD2(src) {
15
+ const nodes = new Map();
16
+ const edges = [];
17
+ const ensure = id => {
18
+ if (!nodes.has(id)) nodes.set(id, { id, label: id });
19
+ return nodes.get(id);
20
+ };
21
+ for (const rawLine of String(src).split('\n')) {
22
+ const line = rawLine.trim();
23
+ if (!line || line.startsWith('#') || line === 'direction: down' || line.startsWith('direction:')) continue;
24
+ const edge = line.match(/^([\w.-]+)\s*-+>\s*([\w.-]+)\s*(?::\s*(.*))?$/);
25
+ if (edge) {
26
+ ensure(edge[1]); ensure(edge[2]);
27
+ edges.push({ from: edge[1], to: edge[2], label: (edge[3] || '').replace(/^"|"$/g, '') });
28
+ continue;
29
+ }
30
+ const decl = line.match(/^([\w.-]+)\s*:\s*(.+)$/);
31
+ if (decl && !decl[2].startsWith('{')) {
32
+ const n = ensure(decl[1]);
33
+ let label = decl[2].trim();
34
+ try { if (label.startsWith('"')) label = JSON.parse(label); } catch { label = label.replace(/^"|"$/g, ''); }
35
+ n.label = label;
36
+ }
37
+ }
38
+ return { nodes: [...nodes.values()], edges };
39
+ }
40
+
41
+ /** Compute ranks (rows) and columns; returns Map<id,{rank,col}> + backEdges. */
42
+ export function layoutD2(graph) {
43
+ const ids = graph.nodes.map(n => n.id);
44
+ const back = new Set();
45
+ // Back edge = target already on the DFS path (creates a cycle).
46
+ const adj = new Map(ids.map(id => [id, []]));
47
+ for (const e of graph.edges) adj.get(e.from)?.push(e);
48
+ const stack = new Set(), seen = new Set();
49
+ const dfs = id => {
50
+ if (stack.has(id) || seen.has(id)) return;
51
+ stack.add(id);
52
+ for (const e of adj.get(id) || []) {
53
+ if (stack.has(e.to)) { back.add(e); continue; }
54
+ dfs(e.to);
55
+ }
56
+ stack.delete(id); seen.add(id);
57
+ };
58
+ const roots = ids.filter(id => !graph.edges.some(e => e.to === id));
59
+ (roots.length ? roots : ids.slice(0, 1)).forEach(dfs);
60
+ ids.forEach(dfs); // disconnected leftovers
61
+
62
+ // Longest-path ranks over forward edges.
63
+ const rank = new Map(ids.map(id => [id, 0]));
64
+ for (let i = 0; i < ids.length; i++) {
65
+ let moved = false;
66
+ for (const e of graph.edges) {
67
+ if (back.has(e)) continue;
68
+ const want = rank.get(e.from) + 1;
69
+ if (rank.get(e.to) < want) { rank.set(e.to, want); moved = true; }
70
+ }
71
+ if (!moved) break;
72
+ }
73
+ // Back-edge sources go to the side column (1 = right of spine).
74
+ const col = new Map(ids.map(id => [id, 0]));
75
+ for (const e of back) col.set(e.from, 1);
76
+ return { rank, col, back, ranks: Math.max(0, ...rank.values()) + 1 };
77
+ }
78
+
79
+ /**
80
+ * Render the graph to styled lines.
81
+ * opts: { width, theme, states:Map<id,state>, packet:{from,to,phase}, now,
82
+ * phaseIndex?: number (current PHASES index for rank tinting) }
83
+ * states: pending|active|done|fail|stale|blocked. Returns { lines, hotRow }.
84
+ */
85
+ export function renderD2(graph, opts) {
86
+ const { width: W, theme: T, states = new Map(), packet = null, flash = new Set(), frame = 0, phaseIndex = -1, glyphs = UNICODE_GLYPHS } = opts;
87
+ const G = glyphs, B = glyphs.box;
88
+ const { rank, col, back } = layoutD2(graph);
89
+ // Narrow rails (<40 cols) skip the side channel: repair renders as an
90
+ // inline "↩ failure" badge under verify instead of right-column elbows.
91
+ const narrow = W < 40;
92
+ const sideNodes = !narrow && [...col.values()].includes(1);
93
+ const sideW = sideNodes ? Math.min(14, Math.floor(W * 0.34)) : 0;
94
+ const channelW = sideNodes ? 3 : 0;
95
+ const spineW = W - sideW - channelW;
96
+
97
+ // Full node labels, word-wrapped up to 3 text rows — no more 28-char
98
+ // truncation. Boxes go full rail width so text has room to breathe.
99
+ const MAX_TEXT_ROWS = 3;
100
+ const wrapLabel = (label, inner) => {
101
+ const rows = wrap(String(label), Math.max(4, inner)).slice(0, MAX_TEXT_ROWS);
102
+ if (rows.length === MAX_TEXT_ROWS) {
103
+ let last = rows[rows.length - 1];
104
+ if (width(last) >= Math.max(4, inner)) {
105
+ while (width(last + '…') > Math.max(4, inner) && last.length) last = last.slice(0, -1);
106
+ rows[rows.length - 1] = last + '…';
107
+ }
108
+ }
109
+ return rows.length ? rows : [''];
110
+ };
111
+
112
+ // Box sizing per node: full available width (spine or side column).
113
+ const boxW = new Map();
114
+ for (const n of graph.nodes) {
115
+ if (narrow && col.get(n.id) === 1) { boxW.set(n.id, 0); continue; } // hidden in narrow mode
116
+ const avail = (col.get(n.id) === 1 ? sideW : spineW) - 2;
117
+ const maxBw = Math.max(4, W - 2);
118
+ boxW.set(n.id, Math.min(maxBw, Math.max(Math.min(9, maxBw), avail)));
119
+ }
120
+
121
+ const centerX = new Map();
122
+ const info = new Map();
123
+ // Variable-height layout: each node measures its wrapped text rows first,
124
+ // then rank tops stack cumulatively (box + 2-row edge gap).
125
+ const EDGE_GAP = 2;
126
+ const nodeRows = new Map(); // id -> wrapped text rows
127
+ for (const n of graph.nodes) {
128
+ const bw = boxW.get(n.id) || 0;
129
+ const inner = Math.max(4, bw - 2);
130
+ nodeRows.set(n.id, wrapLabel(n.label, inner - 2));
131
+ }
132
+ const boxH = id => 2 + (nodeRows.get(id)?.length || 1);
133
+ const rankTop = new Map(); // rank -> top row
134
+ {
135
+ let y = 0;
136
+ const maxRank = Math.max(0, ...rank.values());
137
+ for (let r = 0; r <= maxRank; r++) {
138
+ // Ranks holding only hidden side-column nodes collapse to nothing —
139
+ // reserving rows for them leaves a blank band mid-graph.
140
+ if (!graph.nodes.some(n => rank.get(n.id) === r && !(narrow && col.get(n.id) === 1))) continue;
141
+ rankTop.set(r, y);
142
+ let tallest = 3;
143
+ for (const n of graph.nodes) {
144
+ if (narrow && col.get(n.id) === 1) continue;
145
+ if (rank.get(n.id) === r) tallest = Math.max(tallest, boxH(n.id));
146
+ }
147
+ y += tallest + EDGE_GAP;
148
+ }
149
+ }
150
+ for (const n of graph.nodes) {
151
+ const r = rank.get(n.id), c = col.get(n.id);
152
+ const bw = boxW.get(n.id);
153
+ const left = Math.max(0, c === 1 ? W - bw - 1 : Math.round((spineW - bw) / 2));
154
+ const top = rankTop.get(r) ?? r * (3 + EDGE_GAP);
155
+ const h = boxH(n.id);
156
+ centerX.set(n.id, left + Math.floor(bw / 2));
157
+ info.set(n.id, { left, top, right: left + bw - 1, mid: top + 1, bottom: top + h - 1, bw, rows: nodeRows.get(n.id) || [n.label], h });
158
+ }
159
+ // Canvas height covers visible nodes only — hidden side-column nodes
160
+ // shouldn't inflate the scrollable area with blank rows.
161
+ const _H = Math.max(1, ...[...info.entries()].filter(([id]) => !(narrow && col.get(id) === 1)).map(([, v]) => v.bottom + 1));
162
+ const canvas = Array.from({ length: _H }, () => Array.from({ length: W }, () => null));
163
+ const put = (x, y, ch, color, bgc = null, mods = '') => {
164
+ if (x >= 0 && x < W && y >= 0 && y < _H && ch) canvas[y][x] = { ch, color, bg: bgc, mods };
165
+ };
166
+ const hline = (x0, x1, y, ch, color, bgc = null, mods = '') => { for (let x = x0; x <= x1; x++) put(x, y, ch, color, bgc, mods); };
167
+
168
+ // Opencode token set with legacy carthagent fallbacks (accent/ok/err/warn/muted/faint/hot).
169
+ const ok = T.diffAdded || T.success || T.ok;
170
+ const err = T.diffRemoved || T.error || T.err;
171
+ const warn = T.warning || T.warn;
172
+ const accent = T.primary || T.accent;
173
+ const hot = T.accent || T.hot || accent;
174
+ const muted = T.muted || T.textMuted || T.faint;
175
+ const faint = T.textMuted || T.faint || muted;
176
+ const panelBg = T.backgroundPanel || T.background;
177
+ const warnTok = T.diffHunkHeader || T.diffContext || warn;
178
+ // Node interior fills: tint the panel with the status color so boxes read
179
+ // as colored surfaces. On dark themes the diff bg variants (diffAddedBg etc.)
180
+ // are pre-tinted and blend nicely at 0.45–0.82. On light themes any tint
181
+ // dark enough to keep text readable is too saturated to look like a tint,
182
+ // so light themes use the plain panel and let the colored border carry the
183
+ // state — the standard light-UI convention.
184
+ const panelLum = (() => {
185
+ const h = (panelBg || '#000').replace('#', '');
186
+ const [r, g, b] = [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16) / 255);
187
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
188
+ })();
189
+ const isLight = panelLum > 0.5;
190
+ const fillFor = st => {
191
+ if (isLight) return panelBg;
192
+ if (st === 'done') return mix(T.diffAddedBg || ok, panelBg, 0.55);
193
+ if (st === 'fail') return mix(T.diffRemovedBg || err, panelBg, 0.45);
194
+ if (st === 'stale') return mix(warn, panelBg, 0.82);
195
+ if (st === 'active') return mix(accent, panelBg, 0.82);
196
+ if (st === 'blocked') return mix(warn, panelBg, 0.75);
197
+ return panelBg;
198
+ };
199
+ const edgeColor = e => {
200
+ const st = states.get(e.to) || 'pending';
201
+ if (st === 'fail') return err;
202
+ if (st === 'active') return accent;
203
+ if (st === 'done') return ok;
204
+ return T.border;
205
+ };
206
+ const isHot = e => packet && packet.from === e.from && packet.to === e.to;
207
+ const milestone = id => id === 'goal' || id === 'verify' || id === 'deliver';
208
+ const topJoin = id => milestone(id) ? B.topJoinM : B.topJoin;
209
+ const botJoin = id => milestone(id) ? B.botJoinM : B.botJoin;
210
+
211
+ // Narrow-mode badges: each hidden back-edge source leaves an inline
212
+ // "↩ <label>" marker on the gap row below its target, since the side
213
+ // channel is collapsed away. Connectors skip only cells a badge occupies.
214
+ const badges = [];
215
+ if (narrow) {
216
+ for (const e of back) {
217
+ const v = info.get(e.to);
218
+ if (!v) continue;
219
+ // The human-readable trigger usually lives on the paired forward edge
220
+ // (verify -> repair: failure), not on the back edge itself.
221
+ const fwd = graph.edges.find(o => o !== e && !back.has(o) && o.from === e.to && o.to === e.from);
222
+ const label = String(fwd?.label || e.label || 'recovery').split(/[;,]/)[0].trim() || 'recovery';
223
+ let text = `${G.back} ${label}`;
224
+ while (width(text) > W - 1 && text.length > 3) text = text.slice(0, -2) + '…';
225
+ badges.push({ edge: e, row: v.bottom + 1, x: Math.max(0, Math.min(W - width(text), v.left - 1)), w: width(text), text });
226
+ }
227
+ }
228
+
229
+ // --- nodes first; edges stamped on top so junctions replace border chars ---
230
+ // Phase tint: current-rank borders go primary, prior ranks success, future border.
231
+ for (const n of graph.nodes) {
232
+ if (narrow && col.get(n.id) === 1) continue; // hidden in narrow mode
233
+ const v = info.get(n.id);
234
+ const { left, top, right, bw } = v;
235
+ const rows = v.rows;
236
+ const bottom = v.bottom;
237
+ const st = states.get(n.id) || 'pending';
238
+ const fill = fillFor(st);
239
+ let border = T.border, mods = '', label = muted;
240
+ if (st === 'active') { const p = (Math.sin(frame / 12) + 1) / 2; border = mix(accent, hot, 0.2 + p * 0.55); mods = BOLDM; label = T.text; }
241
+ else if (st === 'done') { border = ok; label = T.text; }
242
+ else if (st === 'fail') { border = err; label = err; }
243
+ else if (st === 'stale') { border = warn; label = T.text; }
244
+ else if (st === 'blocked') { border = warn; label = warn; }
245
+ else if (phaseIndex >= 0) {
246
+ const r = rank.get(n.id);
247
+ if (r < phaseIndex) border = mix(ok, T.border, 0.5);
248
+ else if (r === phaseIndex) border = mix(accent, T.border, 0.25);
249
+ }
250
+ if (flash.has(n.id)) { border = mix(border, '#ffffff', 0.65); mods = BOLDM; }
251
+ const isMilestone = n.id === 'goal' || n.id === 'verify' || n.id === 'deliver';
252
+ const [tl, hz, tr, vt, bl, br] = isMilestone ? [B.mtl, B.mh, B.mtr, B.mv, B.mbl, B.mbr] : [B.tl, B.h, B.tr, B.v, B.bl, B.br];
253
+ const inner = bw - 2;
254
+ put(left, top, tl, border, fill, mods); hline(left + 1, right - 1, top, hz, border, fill, mods); put(right, top, tr, border, fill, mods);
255
+ const glyph = st === 'active' ? G.active : st === 'done' ? G.done : st === 'fail' ? G.fail : st === 'stale' ? G.stale : st === 'blocked' ? G.blocked : G.pending;
256
+ const glyphCol = st === 'pending' ? faint : border;
257
+ const gpad = inner >= 12 ? 2 : 0; // glyph + space, dropped when boxes get tight
258
+ rows.forEach((rowText, ri) => {
259
+ const y = top + 1 + ri;
260
+ put(left, y, vt, border, fill, mods); put(right, y, vt, border, fill, mods);
261
+ for (let x = left + 1; x <= right - 1; x++) put(x, y, ' ', label, fill, mods);
262
+ const tw = width(rowText) + (ri === 0 ? gpad : 0);
263
+ const tx = left + 1 + Math.max(0, Math.floor((inner - tw) / 2));
264
+ let cx = tx;
265
+ if (ri === 0 && gpad) { put(cx, y, glyph, glyphCol, fill, mods); cx += gpad; }
266
+ [...rowText].forEach(ch => { put(cx, y, ch, label, fill, st === 'active' ? BOLDM : ''); cx += width(ch) || 1; });
267
+ });
268
+ put(left, bottom, bl, border, fill, mods); hline(left + 1, right - 1, bottom, hz, border, fill, mods); put(right, bottom, br, border, fill, mods);
269
+ // Swirl: a bright comet of 3 cells orbits the active box's perimeter,
270
+ // overdrawing border chars with a hot→accent gradient so the box stays
271
+ // intact while a light visibly travels around it (the "current step").
272
+ if (st === 'active') {
273
+ const perim = [];
274
+ for (let x = left; x <= right; x++) perim.push([x, top]);
275
+ for (let y = top + 1; y <= bottom; y++) perim.push([right, y]);
276
+ for (let x = right - 1; x >= left; x--) perim.push([x, bottom]);
277
+ for (let y = bottom - 1; y >= top + 1; y--) perim.push([left, y]);
278
+ const len = perim.length;
279
+ if (len > 0) {
280
+ const head = Math.floor(frame * 1.5) % len;
281
+ const comet = [mix(accent, '#ffffff', 0.55), mix(hot, accent, 0.5), mix(accent, hot, 0.3)];
282
+ for (let k = 0; k < comet.length; k++) {
283
+ const [sx, sy] = perim[(head - k + len) % len];
284
+ const cell = canvas[sy]?.[sx];
285
+ if (cell?.ch) put(sx, sy, cell.ch, comet[k], fill, BOLDM);
286
+ }
287
+ }
288
+ }
289
+ }
290
+ // Stamp badges before edges so connectors can route around their cells.
291
+ for (const bd of badges) {
292
+ const failed = states.get(bd.edge.from) === 'fail' || states.get(bd.edge.to) === 'fail';
293
+ [...bd.text].forEach((ch, i) => put(bd.x + i, bd.row, ch, failed ? err : warnTok));
294
+ }
295
+
296
+ // --- edges ---
297
+ // Repair back-edges render dashed in the failure color; the hot edge
298
+ // carries a two-cell packet. Edges into stale targets go warning.
299
+ // Labels are collected here and stamped after all edges are drawn.
300
+ const labels = [];
301
+ for (const e of graph.edges) {
302
+ const a = info.get(e.from), b = info.get(e.to);
303
+ if (!a || !b) continue;
304
+ if (narrow && (col.get(e.from) === 1 || col.get(e.to) === 1)) continue;
305
+ let c = edgeColor(e);
306
+ if (back.has(e)) c = err;
307
+ else if ((states.get(e.to) || 'pending') === 'stale') c = warn;
308
+ const dashed = back.has(e);
309
+ const vch = dashed ? B.dashV : B.v, hch = dashed ? B.dashH : B.h;
310
+ const backEdge = back.has(e);
311
+ const sameCol = col.get(e.from) === col.get(e.to) && !backEdge;
312
+ if (sameCol) {
313
+ const x = centerX.get(e.from), tx = centerX.get(e.to);
314
+ put(x, a.bottom, botJoin(e.from), c);
315
+ const drop = b.top - a.bottom - 1;
316
+ if (x === tx) {
317
+ const hotEdge = isHot(e);
318
+ const toActive = (states.get(e.to) || 'pending') === 'active';
319
+ const pulse = toActive ? (Math.sin(frame / 12) + 1) / 2 : 0;
320
+ for (let y = a.bottom + 1; y < b.top - 1; y++) {
321
+ // Skip cells a narrow-mode badge owns so text stays intact.
322
+ if (badges.some(bd => bd.row === y && x >= bd.x && x < bd.x + bd.w)) continue;
323
+ const step = Math.max(1, drop);
324
+ const pk = hotEdge && ((y - a.bottom + frame) % step) < 2 && drop > 1;
325
+ // Slow brightness pulse on the connector into the active node so the
326
+ // arrow's "stem" breathes in sync with its head (not just the packet).
327
+ const lineCol = toActive && !pk ? mix(c, hot, 0.15 + pulse * 0.5) : c;
328
+ put(x, y, pk ? (frame % 2 ? G.packetOn : G.packetOff) : vch, pk ? hot : lineCol);
329
+ }
330
+ // Arrowhead into the active node breathes slowly: hollow ~1/8 cycle,
331
+ // color pulsed toward hot on the active-border phase.
332
+ put(tx, b.top - 1, toActive && frame % 16 < 2 ? G.arrowDownHollow : G.arrowDown,
333
+ hotEdge ? hot : toActive ? mix(c, hot, 0.2 + pulse * 0.6) : c);
334
+ put(tx, b.top, topJoin(e.to), states.get(e.to) === 'pending' ? T.border : c);
335
+ } else {
336
+ for (let y = a.bottom + 1; y < b.top - 1; y++) put(x, y, vch, c);
337
+ put(x, b.top - 1, x < tx ? B.elbowUR : B.elbowUL, c);
338
+ hline(Math.min(x, tx) + 1, Math.max(x, tx) - 1, b.top - 1, hch, c);
339
+ put(tx, b.top - 1, x < tx ? B.elbowDL : B.elbowDR, c);
340
+ put(tx, b.top, topJoin(e.to), c);
341
+ }
342
+ if (e.label) labels.push({ text: e.label, anchor: x, y0: a.bottom + 1, y1: b.top - 1 });
343
+ } else if (backEdge) {
344
+ const chX = W - 1;
345
+ // Attach at the vertical middle text row of the (possibly tall) boxes.
346
+ const aMid = a.top + 1 + Math.floor(((a.rows?.length || 1) - 1) / 2);
347
+ const bMid = b.top + 1 + Math.floor(((b.rows?.length || 1) - 1) / 2);
348
+ hline(a.right + 1, chX - 1, aMid, B.h, c);
349
+ put(chX, aMid, B.elbowUL, c);
350
+ for (let y = bMid + 1; y < aMid; y++) put(chX, y, B.v, c);
351
+ put(chX, bMid, B.elbowDL, c);
352
+ hline(b.right + 1, chX - 1, bMid, B.h, c);
353
+ // Same slow blink on the side-channel arrowhead when it lands on the
354
+ // active node — color stays (back-edges are the failure channel).
355
+ put(b.right, bMid, (states.get(e.to) || 'pending') === 'active' && frame % 16 < 2 ? G.arrowLeftHollow : G.arrowLeft, c);
356
+ } else {
357
+ // forward edge into the side column: drop near parent's right, elbow over, land on child top
358
+ const x = Math.min(a.right - 1, a.left + Math.floor(a.bw * 0.7));
359
+ put(x, a.bottom, botJoin(e.from), c);
360
+ for (let y = a.bottom + 1; y < b.top - 1; y++) put(x, y, B.v, c);
361
+ const tx = centerX.get(e.to);
362
+ // Side-to-spine edges run right-to-left: mirror the elbows or the
363
+ // horizontal run collapses to floating corner fragments.
364
+ put(x, b.top - 1, x < tx ? B.elbowUR : B.elbowUL, c);
365
+ hline(Math.min(x, tx) + 1, Math.max(x, tx) - 1, b.top - 1, B.h, c);
366
+ put(tx, b.top - 1, x < tx ? B.elbowDL : B.elbowDR, c);
367
+ put(tx, b.top, topJoin(e.to), c);
368
+ if (e.label) labels.push({ text: e.label, anchor: x, y0: a.bottom + 1, y1: b.top - 1 });
369
+ }
370
+ }
371
+
372
+ // --- edge labels ---
373
+ // Stamped after all structure so lines never overwrite text: each label
374
+ // takes the first row in its edge's span where it fits in a free run —
375
+ // right of the connector first, then left of it. A label that can't be
376
+ // placed intact is dropped, never clipped mid-word.
377
+ const labelFree = (x, y, n) => {
378
+ if (x < 0 || x + n > W - 1) return false;
379
+ for (let i = 0; i < n; i++) if (canvas[y]?.[x + i]) return false;
380
+ return true;
381
+ };
382
+ for (const l of labels) {
383
+ const chars = [...String(l.text).split(/[;,]/)[0].trim()];
384
+ if (!chars.length) continue;
385
+ for (let y = l.y0; y <= l.y1; y++) {
386
+ if (labelFree(l.anchor + 2, y, chars.length)) {
387
+ chars.forEach((ch, i) => put(l.anchor + 2 + i, y, ch, warnTok));
388
+ break;
389
+ }
390
+ if (labelFree(l.anchor - chars.length - 1, y, chars.length)) {
391
+ chars.forEach((ch, i) => put(l.anchor - chars.length - 1 + i, y, ch, warnTok));
392
+ break;
393
+ }
394
+ }
395
+ }
396
+
397
+ // --- emit (per-cell fg+bg; bg falls back to the panel so fills survive) ---
398
+ const lines = canvas.map(row => {
399
+ let out = '', cur = null;
400
+ for (const cell of row) {
401
+ if (!cell) { if (cur !== null) { out += RST; cur = null; } out += ' '; continue; }
402
+ const key = cell.color + '|' + (cell.bg || '') + '|' + cell.mods;
403
+ if (key !== cur) { out += (cell.mods || '') + fg(cell.color) + bg(cell.bg || panelBg); cur = key; }
404
+ out += cell.ch;
405
+ }
406
+ if (cur !== null) out += RST;
407
+ return out;
408
+ });
409
+ // hotRow = top row of the frontier node (for viewport follow).
410
+ // Also return the frontier id + full label so the rail can print an
411
+ // untruncated detail line for labels that exceed even 3 wrapped rows.
412
+ const ordered = [...graph.nodes].sort((a, b) => rank.get(a.id) - rank.get(b.id));
413
+ let hotRow = 0;
414
+ const firstPending = ordered.find(n => (states.get(n.id) || 'pending') === 'pending');
415
+ const frontier = ordered.find(n => ['active', 'fail', 'stale', 'blocked'].includes(states.get(n.id))) || firstPending;
416
+ if (frontier && info.get(frontier.id)) hotRow = info.get(frontier.id).top;
417
+ const boxes = [...info.entries()]
418
+ .filter(([id]) => !(narrow && col.get(id) === 1))
419
+ .map(([id, v]) => ({ id, top: v.top, bottom: v.bottom, h: v.h }));
420
+ return { lines, hotRow, frontierId: frontier?.id || null, frontierLabel: frontier ? String(frontier.label) : '', boxes };
421
+ }
422
+
423
+ const BOLDM = '\x1b[1m';
424
+ const RST = '\x1b[0m';
425
+
426
+ /** Summary: count node states for the header strip. */
427
+ export function graphStats(states) {
428
+ const c = { active: 0, done: 0, fail: 0, pending: 0, blocked: 0 };
429
+ for (const s of states.values()) c[s] = (c[s] || 0) + 1;
430
+ return c;
431
+ }