@iowarp/clio-coder 0.3.6 → 0.3.8

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 (323) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +22 -6
  3. package/dist/{acp-2BEHC4DL.js → acp-U67UHUK2.js} +14 -14
  4. package/dist/{agents-LNNFTM53.js → agents-YU6SGALZ.js} +42 -35
  5. package/dist/assets/codewiki.json +1 -1
  6. package/dist/{auth-KXXFI2VS.js → auth-5ZPJOIVG.js} +32 -24
  7. package/dist/builtins-C6JMZVV6.js +17 -0
  8. package/dist/{chunk-E25LMLRW.js → chunk-26LEYJZH.js} +2 -2
  9. package/dist/{chunk-TSHXZTOQ.js → chunk-2HEJ2F35.js} +5 -5
  10. package/dist/{chunk-PBTHKCPN.js → chunk-2HFZQUHL.js} +7 -7
  11. package/dist/{chunk-FO5ZOVUY.js → chunk-3BINW3FP.js} +5 -5
  12. package/dist/{chunk-4OC57DA6.js → chunk-4DGYLA73.js} +53 -2
  13. package/dist/{chunk-CKXWIANG.js → chunk-4SPRNWDE.js} +18 -16
  14. package/dist/{chunk-E2ER4LJF.js → chunk-5C3AQNDW.js} +25 -1
  15. package/dist/{chunk-XF5N4U5A.js → chunk-5DHKRSMQ.js} +9 -8
  16. package/dist/{chunk-43AOLP7E.js → chunk-5FR74PWO.js} +2 -1
  17. package/dist/{chunk-EKY57CSP.js → chunk-5H3GB5BO.js} +68 -772
  18. package/dist/{chunk-6XXKFVSN.js → chunk-5Q2VVUKB.js} +4 -4
  19. package/dist/{chunk-DJVECN66.js → chunk-7RFXX52T.js} +295 -3705
  20. package/dist/{chunk-ZXF4XRKW.js → chunk-7RGZWPB6.js} +158 -8
  21. package/dist/{chunk-FYYLNIL5.js → chunk-A2NJGIB3.js} +2 -2
  22. package/dist/{chunk-XXQNGV4M.js → chunk-A3WNZD3P.js} +548 -72
  23. package/dist/{chunk-VEZEGCGW.js → chunk-B5CSFE7B.js} +23 -21
  24. package/dist/{chunk-2SFS6XQE.js → chunk-DGSYXYMX.js} +3 -2
  25. package/dist/chunk-DR52UMZW.js +21 -0
  26. package/dist/{chunk-ZWLZP4ZT.js → chunk-DYIM5TJT.js} +98 -12
  27. package/dist/{chunk-QKMUKYO7.js → chunk-E77JEWSD.js} +270 -125
  28. package/dist/{chunk-LYF7OHWH.js → chunk-EMYUUSFG.js} +12 -467
  29. package/dist/{chunk-4VP4KH3K.js → chunk-EQ63NRB7.js} +8 -8
  30. package/dist/chunk-FBVTI2TJ.js +518 -0
  31. package/dist/{chunk-R46L2BIR.js → chunk-FHJEP5SW.js} +19 -25
  32. package/dist/{chunk-WR67VIZY.js → chunk-GN57SG4G.js} +66 -8
  33. package/dist/{chunk-RD5U66HV.js → chunk-GPIEI3LY.js} +9 -9
  34. package/dist/{chunk-PCZJO5TI.js → chunk-GU2UIAFZ.js} +13 -178
  35. package/dist/chunk-GWS3VEIW.js +195 -0
  36. package/dist/chunk-H7IXIC72.js +103 -0
  37. package/dist/{chunk-3BPUFZDL.js → chunk-HLE42MG7.js} +3 -3
  38. package/dist/{chunk-24I7BN55.js → chunk-IFBNV6H6.js} +3 -3
  39. package/dist/{chunk-QNQHSOLF.js → chunk-IGWKHNIQ.js} +114 -55
  40. package/dist/chunk-IIZWH4XA.js +172 -0
  41. package/dist/{chunk-AD2SYQYC.js → chunk-IJ7RPIYJ.js} +124 -6
  42. package/dist/chunk-J3YUBZWY.js +382 -0
  43. package/dist/{chunk-5JGRAMKL.js → chunk-JOZYP4GM.js} +8 -6
  44. package/dist/{chunk-OH3TOQTB.js → chunk-K4XHGFR5.js} +751 -15
  45. package/dist/{chunk-EYPA3EGJ.js → chunk-KTYTFRMB.js} +178 -14
  46. package/dist/chunk-LU7P4LHA.js +33 -0
  47. package/dist/chunk-ME6CCNFO.js +108 -0
  48. package/dist/chunk-MXKJU4JB.js +1100 -0
  49. package/dist/{chunk-RY3LY4J5.js → chunk-N22QMJKY.js} +21 -18
  50. package/dist/chunk-NMPKI6XL.js +3006 -0
  51. package/dist/chunk-NUGM5KR6.js +165 -0
  52. package/dist/{chunk-22NAGB7X.js → chunk-P43ETTHK.js} +5 -94
  53. package/dist/{chunk-NILBFAPG.js → chunk-PMDBGQSJ.js} +2 -2
  54. package/dist/chunk-PT7HYKEM.js +165 -0
  55. package/dist/chunk-RVG5JXAL.js +41 -0
  56. package/dist/{chunk-GEYXPTRF.js → chunk-RWSI4YD7.js} +2 -2
  57. package/dist/{chunk-4BPJXDWC.js → chunk-TANS5ZJS.js} +35 -19
  58. package/dist/{verifiers-NCBTHHN2.js → chunk-TB5666IT.js} +67 -324
  59. package/dist/{chunk-QM3F2GKX.js → chunk-TLQJPP24.js} +7927 -8088
  60. package/dist/{chunk-G7MUEIGA.js → chunk-TT36MB5S.js} +2 -1
  61. package/dist/chunk-TTHACPOM.js +961 -0
  62. package/dist/{chunk-6US73PDB.js → chunk-TYPGUK6W.js} +7 -7
  63. package/dist/{chunk-MFFY33HR.js → chunk-U6MBIEMB.js} +554 -209
  64. package/dist/chunk-VAWNZU7Z.js +242 -0
  65. package/dist/{chunk-IR4CFBFN.js → chunk-VCBR6CU7.js} +12 -12
  66. package/dist/{chunk-WHJYKASB.js → chunk-VHN4MY6O.js} +2 -2
  67. package/dist/{chunk-XYDYPRZI.js → chunk-VWZOAB7K.js} +10 -10
  68. package/dist/{chunk-ZRGEBJ4T.js → chunk-WLFILSD5.js} +48 -48
  69. package/dist/{chunk-K7T3E2SR.js → chunk-WNIJTQQK.js} +12 -11
  70. package/dist/{chunk-CYQKWTG3.js → chunk-WSB3FPX7.js} +68 -20
  71. package/dist/{chunk-KHSFENX2.js → chunk-WWCZ5F23.js} +116 -14
  72. package/dist/{chunk-XE2VEJHX.js → chunk-WXY7KU3G.js} +2 -2
  73. package/dist/{chunk-PPAMZ32Z.js → chunk-XK56QHLX.js} +6 -1
  74. package/dist/{chunk-KOHPCX4K.js → chunk-XWSF374K.js} +5 -5
  75. package/dist/{chunk-CJUB2JJ2.js → chunk-YS5VLNH5.js} +10 -10
  76. package/dist/{chunk-ZZMN5OM4.js → chunk-ZNLWCMVZ.js} +2 -2
  77. package/dist/{chunk-WHGPSPT5.js → chunk-ZVJ5BLO2.js} +2 -2
  78. package/dist/cli/index.js +33 -31
  79. package/dist/{clio-M2KGYUFZ.js → clio-QVTYJ57A.js} +10 -10
  80. package/dist/{code-nav-GQNL7XA6.js → code-nav-FGGFIE7L.js} +5 -5
  81. package/dist/codewiki/build-worker.js +4 -4
  82. package/dist/{components-5TTYYX6G.js → components-ZFA3SAER.js} +9 -9
  83. package/dist/{config-XUUYQIWO.js → config-LW5IJFQN.js} +65 -55
  84. package/dist/{configure-IHJ7YOMV.js → configure-7XIZCOU4.js} +29 -24
  85. package/dist/{context-75MIWW3U.js → context-L3WL3X7K.js} +54 -43
  86. package/dist/{context-ZQ7SIFJV.js → context-N52ZA626.js} +30 -14
  87. package/dist/{context-74JLXAWD.js → context-Y6Y7QPR6.js} +12 -12
  88. package/dist/{context-clear-GYKWNUML.js → context-clear-MBQRLSDQ.js} +54 -43
  89. package/dist/{context-index-SSR5ECNE.js → context-index-HVMFQHK3.js} +5 -5
  90. package/dist/{context-working-set-UX5KEP4J.js → context-working-set-GS6DSO7F.js} +18 -18
  91. package/dist/{dispatch-runner-GIJBHNFL.js → dispatch-runner-22ZCNOM3.js} +367 -79
  92. package/dist/{docs-6FZSCG5B.js → docs-7LQ23DLM.js} +9 -9
  93. package/dist/{doctor-SVJ5BZCW.js → doctor-M7YEDGAE.js} +27 -23
  94. package/dist/{eval-CG6LLBLD.js → eval-BEC2WHDA.js} +73 -20
  95. package/dist/{evidence-ZYFIEN42.js → evidence-REJUMSKM.js} +70 -59
  96. package/dist/{evolve-QGEXEMDW.js → evolve-PY5ZBA5K.js} +50 -39
  97. package/dist/{extensions-ADGNCJJD.js → extensions-HVKU65YU.js} +7 -7
  98. package/dist/{fleet-S5R4ZOQY.js → fleet-7WZEWRFA.js} +236 -377
  99. package/dist/fleet-commands-UVHWM76J.js +70 -0
  100. package/dist/fleet-graph-6ULH7PES.js +125 -0
  101. package/dist/fleet-new-RDVJLHHH.js +48 -0
  102. package/dist/{fleet-preflight-BHSNPBMH.js → fleet-preflight-J53T6CCE.js} +5 -5
  103. package/dist/fleet-validate-72PC4SLA.js +79 -0
  104. package/dist/{init-5DRU55YR.js → init-OG3TPGQG.js} +69 -57
  105. package/dist/library-CNTMPLRF.js +217 -0
  106. package/dist/{memory-7YKKR6UC.js → memory-6IS7F275.js} +52 -41
  107. package/dist/{models-ZPOLRU2C.js → models-ENRJDA5W.js} +37 -31
  108. package/dist/{monitor-US5F5YGZ.js → monitor-XLDVO7TN.js} +79 -42
  109. package/dist/{orchestrator-E2AL4T5N.js → orchestrator-6KSPYRHA.js} +4126 -762
  110. package/dist/{paths-E7KYAQWE.js → paths-DBXMZMDU.js} +6 -6
  111. package/dist/registry-LG64LTF4.js +11 -0
  112. package/dist/{reset-KZ652EK6.js → reset-RZ4ER727.js} +12 -12
  113. package/dist/{run-SRNBKDWD.js → run-Y2CNK5RU.js} +89 -76
  114. package/dist/{share-CGZE33UP.js → share-A55GYP6Z.js} +37 -13
  115. package/dist/{skills-S2X4DLY5.js → skills-ALC5J6AT.js} +29 -13
  116. package/dist/{skills-eval-W2GGIC4R.js → skills-eval-JPBEBYQU.js} +60 -48
  117. package/dist/support-MIETYA5E.js +38 -0
  118. package/dist/{targets-54SWINWB.js → targets-VGNXIR3S.js} +46 -36
  119. package/dist/{terminal-lease-SAIF2OGY.js → terminal-lease-WOBR64YA.js} +4 -4
  120. package/dist/{uninstall-BVLWXKBT.js → uninstall-ZJF5H5ZN.js} +9 -9
  121. package/dist/{upgrade-JKAR27XC.js → upgrade-FUSUAGHR.js} +31 -28
  122. package/dist/{usage-MSAWCLX4.js → usage-N4MKVHKD.js} +140 -68
  123. package/dist/verifiers-YAWOJ3H2.js +336 -0
  124. package/dist/{verify-X5HDROLA.js → verify-LTDHYBGY.js} +10 -9
  125. package/dist/{wiki-generate-GUSOQ6ZP.js → wiki-generate-6M7GHTBJ.js} +75 -62
  126. package/dist/worker/entry.js +67 -57
  127. package/docs/README.md +3 -2
  128. package/docs/acp.md +1 -1
  129. package/docs/alcf-provider.md +1 -1
  130. package/docs/architecture.md +2 -2
  131. package/docs/artifact-versions.md +11 -6
  132. package/docs/built-in-agents.md +26 -2
  133. package/docs/capacity-and-scheduling.md +1 -1
  134. package/docs/commands-and-modes.md +83 -3
  135. package/docs/configuration-and-targets.md +86 -4
  136. package/docs/context-engine.md +1 -1
  137. package/docs/development-pipeline.md +1 -1
  138. package/docs/dispatch-architecture-rationale.md +1 -1
  139. package/docs/documentation-coverage.md +3 -3
  140. package/docs/documentation-guide.md +3 -3
  141. package/docs/eval-runner.md +1 -1
  142. package/docs/evals-internal.md +1 -1
  143. package/docs/evidence-and-memory.md +74 -10
  144. package/docs/evolution.md +1 -1
  145. package/docs/exit-codes-and-output.md +4 -1
  146. package/docs/extensions-and-sharing.md +6 -2
  147. package/docs/fleet-demo-runbook.md +2 -2
  148. package/docs/fleet-dispatch.md +228 -16
  149. package/docs/git-commit-provenance.md +2 -2
  150. package/docs/glossary.md +22 -2
  151. package/docs/installation-and-lifecycle.md +2 -2
  152. package/docs/middleware-and-components.md +2 -1
  153. package/docs/model-catalog.md +1 -1
  154. package/docs/observability.md +56 -9
  155. package/docs/proactive-memory.md +1 -1
  156. package/docs/prompt-envelope-and-tools.md +4 -2
  157. package/docs/provider-adapter-cookbook.md +1 -1
  158. package/docs/release-cut-checklist.md +83 -65
  159. package/docs/resource-library.md +59 -0
  160. package/docs/safety-model.md +2 -2
  161. package/docs/scientific-validation.md +3 -3
  162. package/docs/session-lifecycle.md +37 -1
  163. package/docs/skills-marketplace.md +16 -3
  164. package/docs/tool-usage.md +14 -7
  165. package/docs/trace-store.md +1 -1
  166. package/docs/troubleshooting.md +1 -1
  167. package/docs/tui-design.md +1 -1
  168. package/docs/worker-dispatch-mechanics.md +3 -3
  169. package/package.json +1 -2
  170. package/src/cli/argv.ts +5 -0
  171. package/src/cli/configure.ts +107 -23
  172. package/src/cli/doctor.ts +5 -1
  173. package/src/cli/evidence.ts +30 -25
  174. package/src/cli/fleet-commands.ts +37 -0
  175. package/src/cli/fleet-graph.ts +102 -0
  176. package/src/cli/fleet-new.ts +36 -0
  177. package/src/cli/fleet-preflight.ts +111 -0
  178. package/src/cli/fleet-validate.ts +30 -0
  179. package/src/cli/fleet.ts +173 -335
  180. package/src/cli/index.ts +3 -1
  181. package/src/cli/library.ts +190 -0
  182. package/src/cli/share.ts +13 -1
  183. package/src/cli/shared.ts +1 -0
  184. package/src/cli/targets.ts +4 -1
  185. package/src/cli/usage.ts +111 -19
  186. package/src/cli/validate-model.ts +60 -5
  187. package/src/core/bus-events.ts +29 -0
  188. package/src/core/commit-attribution.ts +4 -4
  189. package/src/core/config.ts +130 -0
  190. package/src/core/defaults.ts +81 -0
  191. package/src/core/path-boundary.ts +100 -0
  192. package/src/domains/agents/builtins/architect.md +1 -0
  193. package/src/domains/agents/builtins/oracle.md +33 -0
  194. package/src/domains/agents/catalog.ts +13 -1
  195. package/src/domains/agents/extension.ts +2 -11
  196. package/src/domains/agents/fleet-contract.ts +304 -24
  197. package/src/domains/agents/index.ts +14 -0
  198. package/src/domains/agents/recipe.ts +7 -1
  199. package/src/domains/agents/registry.ts +73 -5
  200. package/src/domains/agents/result-contract.ts +360 -15
  201. package/src/domains/agents/write-boundary.ts +15 -50
  202. package/src/domains/config/classify.ts +4 -0
  203. package/src/domains/context/project-rules.ts +51 -1
  204. package/src/domains/dispatch/active-route-planner.ts +14 -0
  205. package/src/domains/dispatch/assignment-reconcile.ts +22 -5
  206. package/src/domains/dispatch/assignment-store.ts +151 -14
  207. package/src/domains/dispatch/backoff.ts +2 -1
  208. package/src/domains/dispatch/capability-match.ts +1 -0
  209. package/src/domains/dispatch/checkout-writer-lease.ts +175 -0
  210. package/src/domains/dispatch/contract.ts +48 -0
  211. package/src/domains/dispatch/delegation-plan.ts +164 -0
  212. package/src/domains/dispatch/execution-plan.ts +76 -5
  213. package/src/domains/dispatch/execution-role.ts +12 -2
  214. package/src/domains/dispatch/execution-scheduler.ts +183 -67
  215. package/src/domains/dispatch/extension.ts +412 -74
  216. package/src/domains/dispatch/fleet-gate.ts +14 -0
  217. package/src/domains/dispatch/fleet-plan.ts +63 -3
  218. package/src/domains/dispatch/fleet-run.ts +791 -0
  219. package/src/domains/dispatch/gate-role-prompts.ts +47 -0
  220. package/src/domains/dispatch/host-verification.ts +178 -0
  221. package/src/domains/dispatch/index.ts +41 -1
  222. package/src/domains/dispatch/intent-requirements.ts +40 -0
  223. package/src/domains/dispatch/intent.ts +235 -0
  224. package/src/domains/dispatch/path-scope.ts +370 -0
  225. package/src/domains/dispatch/receipt-integrity.ts +9 -4
  226. package/src/domains/dispatch/state.ts +36 -3
  227. package/src/domains/dispatch/types.ts +64 -12
  228. package/src/domains/dispatch/validation.ts +69 -6
  229. package/src/domains/dispatch/write-boundary-enforcer.ts +45 -0
  230. package/src/domains/dispatch/write-boundary.ts +201 -22
  231. package/src/domains/eval/metrics/evidence.ts +79 -2
  232. package/src/domains/eval/runners/clio-run.ts +12 -2
  233. package/src/domains/evidence/build.ts +69 -11
  234. package/src/domains/evidence/index.ts +21 -0
  235. package/src/domains/evidence/provenance.ts +46 -11
  236. package/src/domains/evidence/trust-projection.ts +274 -0
  237. package/src/domains/evidence/trust-status.ts +155 -18
  238. package/src/domains/evidence/types.ts +4 -0
  239. package/src/domains/extensions/discovery.ts +88 -1
  240. package/src/domains/extensions/resources.ts +20 -8
  241. package/src/domains/extensions/state.ts +6 -2
  242. package/src/domains/extensions/types.ts +4 -1
  243. package/src/domains/lifecycle/doctor.ts +140 -1
  244. package/src/domains/middleware/index.ts +15 -0
  245. package/src/domains/middleware/watchdog.ts +281 -0
  246. package/src/domains/observability/contract.ts +3 -1
  247. package/src/domains/observability/cost.ts +12 -1
  248. package/src/domains/observability/extension.ts +2 -2
  249. package/src/domains/observability/index.ts +10 -0
  250. package/src/domains/observability/out-of-turn-usage.ts +223 -0
  251. package/src/domains/prompts/contract.ts +3 -5
  252. package/src/domains/providers/extension.ts +30 -2
  253. package/src/domains/resources/common-loader.ts +3 -0
  254. package/src/domains/resources/index.ts +20 -0
  255. package/src/domains/resources/library.ts +326 -0
  256. package/src/domains/resources/prompts/loader.ts +119 -14
  257. package/src/domains/resources/skills/marketplace.ts +37 -12
  258. package/src/domains/safety/policy-engine.ts +5 -5
  259. package/src/domains/safety/run-effects.ts +64 -1
  260. package/src/domains/safety/scope.ts +7 -12
  261. package/src/domains/session/handoff.ts +629 -0
  262. package/src/domains/share/archive.ts +67 -2
  263. package/src/engine/acp/server.ts +4 -1
  264. package/src/engine/prompt-templates.ts +18 -1
  265. package/src/engine/worker-runtime.ts +6 -3
  266. package/src/entry/orchestrator.ts +37 -0
  267. package/src/interactive/bus-notices.ts +26 -0
  268. package/src/interactive/chat-loop.ts +235 -1
  269. package/src/interactive/chat-renderer.ts +22 -0
  270. package/src/interactive/cost-overlay.ts +31 -3
  271. package/src/interactive/council-dispatch.ts +30 -0
  272. package/src/interactive/council-grid.ts +213 -0
  273. package/src/interactive/council.ts +99 -0
  274. package/src/interactive/dispatch-board.ts +311 -17
  275. package/src/interactive/fleet-run-preview.ts +307 -0
  276. package/src/interactive/footer/notifications.ts +219 -0
  277. package/src/interactive/handoff-round.ts +56 -0
  278. package/src/interactive/interactive-application.ts +43 -1
  279. package/src/interactive/interactive-event-projection.ts +23 -1
  280. package/src/interactive/interactive-slash-runtime.ts +52 -2
  281. package/src/interactive/interactive-subscriptions.ts +14 -2
  282. package/src/interactive/oracle.ts +179 -0
  283. package/src/interactive/overlay-ask-user-lifecycle.ts +6 -0
  284. package/src/interactive/overlay-general-openers.ts +190 -1
  285. package/src/interactive/overlay-key-routing.ts +17 -1
  286. package/src/interactive/overlay-lifecycle.ts +41 -1
  287. package/src/interactive/overlay-permission-lifecycle.ts +10 -0
  288. package/src/interactive/overlay-resource-openers.ts +11 -3
  289. package/src/interactive/overlay-session-lifecycle.ts +234 -2
  290. package/src/interactive/overlays/fleet-run-approval.ts +208 -0
  291. package/src/interactive/overlays/handoff-review.ts +185 -0
  292. package/src/interactive/overlays/library-install-confirm.ts +151 -0
  293. package/src/interactive/overlays/list-overlay.ts +168 -2
  294. package/src/interactive/overlays/settings.ts +221 -30
  295. package/src/interactive/overlays/side-question.ts +139 -0
  296. package/src/interactive/overlays/skills-hub.ts +401 -15
  297. package/src/interactive/side-question.ts +171 -0
  298. package/src/interactive/slash-commands.ts +439 -7
  299. package/src/interactive/slash-spec.ts +19 -6
  300. package/src/interactive/theme/tokens.ts +30 -0
  301. package/src/interactive/turn-middleware.ts +15 -1
  302. package/src/interactive/view/artifacts.ts +42 -9
  303. package/src/interactive/view/view-overlay.ts +15 -3
  304. package/src/interactive/watchdog-run.ts +75 -0
  305. package/src/interactive/worker-receipts.ts +14 -2
  306. package/src/interactive/worker-share.ts +56 -1
  307. package/src/interactive/worker-stream.ts +15 -0
  308. package/src/tools/bootstrap.ts +3 -0
  309. package/src/tools/compete-worktrees.ts +13 -79
  310. package/src/tools/dispatch-admission.ts +251 -18
  311. package/src/tools/dispatch-arguments.ts +84 -1
  312. package/src/tools/dispatch-plan.ts +165 -6
  313. package/src/tools/dispatch-runner.ts +364 -23
  314. package/src/tools/dispatch-types.ts +20 -1
  315. package/src/tools/dispatch.ts +72 -2
  316. package/src/tools/monitor.ts +29 -0
  317. package/src/tools/profiles.ts +18 -4
  318. package/src/tools/task-worktree.ts +238 -0
  319. package/src/tools/verify/authoring.ts +61 -1
  320. package/src/tools/verify/scripts.ts +62 -0
  321. package/src/tools/worker-evidence.ts +21 -14
  322. package/src/worker/spec-contract.ts +3 -1
  323. package/dist/chunk-HC4CLZ2Y.js +0 -68
@@ -1,14 +1,29 @@
1
1
  import { resolveSessionCwd } from "../domains/session/cwd-fallback.js";
2
+ import type { DecisionLedgerEntry } from "../domains/session/entries.js";
3
+ import {
4
+ buildHandoffReadLedger,
5
+ HANDOFF_NOTE_CUSTOM_TYPE,
6
+ HANDOFF_SEED_CUSTOM_TYPE,
7
+ type HandoffNoteData,
8
+ type HandoffSeedData,
9
+ mergeHandoffDecisions,
10
+ parseHandoffExtraction,
11
+ renderHandoffDocument,
12
+ validateHandoffFiles,
13
+ validateHandoffGoal,
14
+ } from "../domains/session/handoff.js";
2
15
  import type { SessionContract, SessionEntry } from "../domains/session/index.js";
3
16
  import type { TUI } from "../engine/tui.js";
4
17
  import type { ChatLoop } from "./chat-loop.js";
5
18
  import type { ChatPanel } from "./chat-panel.js";
6
19
  import { rehydrateChatPanelFromTurns } from "./chat-renderer.js";
7
20
  import { emitCommandNotice } from "./command-fallbacks.js";
21
+ import { editTextExternally, resolveExternalEditor } from "./external-editor.js";
8
22
  import type { InteractiveNoticeLevel } from "./interactive-subscriptions.js";
9
23
  import { buildModelReplayAgentMessagesFromTurns } from "./model-session-replay.js";
10
24
  import type { OverlayTransitions } from "./overlay-transitions.js";
11
25
  import { openCwdFallbackOverlay } from "./overlays/cwd-fallback.js";
26
+ import { openHandoffReviewOverlay } from "./overlays/handoff-review.js";
12
27
  import { openMessagePickerOverlay } from "./overlays/message-picker.js";
13
28
  import { openSessionOverlay } from "./overlays/session-selector.js";
14
29
  import { openTreeOverlay } from "./overlays/tree-selector.js";
@@ -22,7 +37,7 @@ export interface OverlaySessionLifecycleDeps {
22
37
  tui: TUI;
23
38
  transitions: OverlayTransitions;
24
39
  session?: SessionContract;
25
- chat: Pick<ChatLoop, "cancel" | "isStreaming" | "resetForSession" | "whenSettled">;
40
+ chat: Pick<ChatLoop, "cancel" | "isStreaming" | "resetForSession" | "whenSettled" | "extractHandoff">;
26
41
  chatPanel: ChatPanel;
27
42
  /** The one transcript reset: the panel plus every view folded alongside it. */
28
43
  resetTranscript(): void;
@@ -30,6 +45,18 @@ export interface OverlaySessionLifecycleDeps {
30
45
  getSlashNotice(): SlashCommandContext["notice"];
31
46
  onResumeSession?(sessionId: string): void;
32
47
  onForkSession?(parentTurnId: string): void;
48
+ /**
49
+ * Mint a fresh session, the same hook `/new` uses. `/handoff` needs the one
50
+ * session-creation path the orchestrator owns rather than a second one.
51
+ */
52
+ onNewSession?(): void;
53
+ /** The session's settled decision board, which wins over extracted decisions. */
54
+ getDecisionBoard?(): ReadonlyArray<DecisionLedgerEntry>;
55
+ /** Stop and restart the terminal around an external editor child process. */
56
+ suspendTerminal?<T>(run: () => T): T;
57
+ resolveEditor?: typeof resolveExternalEditor;
58
+ editExternally?: typeof editTextExternally;
59
+ openHandoffReviewOverlay?: typeof openHandoffReviewOverlay;
33
60
  announceTaskMemorySeedOffer(): void;
34
61
  /**
35
62
  * Running usage totals, reseeded whenever the session under them changes.
@@ -45,6 +72,8 @@ export interface OverlaySessionLifecycleDeps {
45
72
  setLastTurnSummary?(summary: TurnSummary | null): void;
46
73
  refreshFooter(): void;
47
74
  requestRender(): void;
75
+ /** Live terminal width, so the handoff review box tracks the window. */
76
+ terminal?: { columns: number };
48
77
  stderr(text: string): void;
49
78
  notify(level: InteractiveNoticeLevel, text: string, key?: string): void;
50
79
  openSessionOverlay?: typeof openSessionOverlay;
@@ -57,6 +86,8 @@ export interface OverlaySessionLifecycle {
57
86
  openResume(): void;
58
87
  openTree(): void;
59
88
  openMessagePicker(): void;
89
+ /** `/handoff <goal>`: extract, review, and seed a successor session. */
90
+ startHandoff(goal: string): void;
60
91
  }
61
92
 
62
93
  /**
@@ -327,5 +358,206 @@ export function createOverlaySessionLifecycle(deps: OverlaySessionLifecycleDeps)
327
358
  deps.requestRender();
328
359
  }
329
360
 
330
- return { openResume, openTree, openMessagePicker: openMessagePickerState };
361
+ /**
362
+ * `/handoff <goal>`: carry this session's working state into a fresh one.
363
+ *
364
+ * A handoff is a session operation. It writes no memory promotion candidate,
365
+ * touches no memory record, and never calls the task-memory bank; the only
366
+ * things it writes are one seed entry plus replayed skill activations in the
367
+ * new session and one terminal note in the old one, and it writes none of
368
+ * them until the operator has accepted the document.
369
+ */
370
+ function startHandoff(goal: string): void {
371
+ if (deps.transitions.state !== "closed") return;
372
+ const notice = deps.getSlashNotice();
373
+ const verdict = validateHandoffGoal(goal);
374
+ if (!verdict.ok) {
375
+ emitCommandNotice(notice, "warn", "handoff", verdict.reason);
376
+ return;
377
+ }
378
+ if (!deps.session) {
379
+ emitCommandNotice(notice, "error", "handoff", "session contract unavailable");
380
+ return;
381
+ }
382
+ if (!deps.onNewSession) {
383
+ emitCommandNotice(notice, "error", "handoff", "no session-creation path is wired in this session");
384
+ return;
385
+ }
386
+ // Refused, never queued. The document describes a session that has
387
+ // stopped; a turn still in flight is about to change what it would say.
388
+ if (deps.chat.isStreaming()) {
389
+ emitCommandNotice(
390
+ notice,
391
+ "warn",
392
+ "handoff",
393
+ "a turn is in flight; /handoff cannot summarize a session that is still moving",
394
+ );
395
+ return;
396
+ }
397
+ const session = deps.session;
398
+ const fromSessionId = session.current()?.id ?? null;
399
+ if (fromSessionId === null) {
400
+ emitCommandNotice(notice, "warn", "handoff", "no current session to hand off; start one with /new or /resume");
401
+ return;
402
+ }
403
+ void runHandoffExtraction(session, fromSessionId, verdict.goal);
404
+ }
405
+
406
+ async function runHandoffExtraction(session: SessionContract, fromSessionId: string, goal: string): Promise<void> {
407
+ const notice = deps.getSlashNotice();
408
+ const outcome = await deps.chat.extractHandoff(goal);
409
+ if (outcome.status !== "answered") {
410
+ const reason = outcome.status === "aborted" ? "the extraction round was cancelled" : outcome.reason;
411
+ emitCommandNotice(notice, outcome.status === "failed" ? "error" : "warn", "handoff", reason);
412
+ return;
413
+ }
414
+ const parsed = parseHandoffExtraction(outcome.text);
415
+ if (!parsed.ok) {
416
+ emitCommandNotice(notice, "error", "handoff", parsed.reason);
417
+ return;
418
+ }
419
+ const meta = session.current();
420
+ const cwd = typeof meta?.cwd === "string" && meta.cwd.length > 0 ? meta.cwd : null;
421
+ const entries = deps.readStructuredEntries(fromSessionId);
422
+ // The ledger is what this session's own tool calls touched, folded through
423
+ // the active path so an abandoned `/tree` branch is not evidence.
424
+ const ledger = buildHandoffReadLedger(entries, { cwd, leafTurnId: meta?.pinnedLeafTurnId ?? null });
425
+ const files = validateHandoffFiles(parsed.result.extraction.files, ledger, cwd);
426
+ const decisions = mergeHandoffDecisions(parsed.result.extraction.decisions, deps.getDecisionBoard?.() ?? []);
427
+ const document = renderHandoffDocument({
428
+ goal,
429
+ fromSessionId,
430
+ decisions,
431
+ facts: parsed.result.extraction.facts,
432
+ files: files.kept,
433
+ droppedFiles: files.dropped,
434
+ commands: parsed.result.extraction.commands,
435
+ openQuestions: parsed.result.extraction.openQuestions,
436
+ truncations: parsed.result.truncations,
437
+ });
438
+ openHandoffReview(session, fromSessionId, goal, document);
439
+ }
440
+
441
+ /** Hand the document to `$EDITOR`, or say why it could not go. */
442
+ function editHandoffDocument(current: string): string | null {
443
+ const resolve = deps.resolveEditor ?? resolveExternalEditor;
444
+ const edit = deps.editExternally ?? editTextExternally;
445
+ const command = resolve();
446
+ if (!command) {
447
+ deps.notify("warning", "handoff: no external editor configured; set VISUAL or EDITOR", "handoff:no-editor");
448
+ return null;
449
+ }
450
+ const suspend = deps.suspendTerminal ?? (<T>(run: () => T): T => run());
451
+ const result = suspend(() => edit(current, command));
452
+ if (result.ok) return result.text ?? current;
453
+ if (result.error) deps.notify("warning", `handoff: ${result.error}`, "handoff:editor-failed");
454
+ return null;
455
+ }
456
+
457
+ function openHandoffReview(session: SessionContract, fromSessionId: string, goal: string, document: string): void {
458
+ if (deps.transitions.state !== "closed") return;
459
+ const openReview = deps.openHandoffReviewOverlay ?? openHandoffReviewOverlay;
460
+ deps.transitions.state = "handoff-review";
461
+ deps.transitions.handle = openReview(deps.tui, {
462
+ document,
463
+ goal,
464
+ columns: deps.terminal?.columns ?? 80,
465
+ onEdit: editHandoffDocument,
466
+ onAccept: (reviewed) => {
467
+ deps.transitions.close();
468
+ const text = reviewed.trim();
469
+ if (text.length === 0) {
470
+ deps.notify("warning", "handoff: the reviewed document was empty; nothing was written", "handoff:empty");
471
+ return;
472
+ }
473
+ seedHandoffSession(session, fromSessionId, goal, text);
474
+ },
475
+ // Esc. Nothing has been written yet, so cancel really is free. The
476
+ // overlay settles itself on cancel and answers no further key, so the
477
+ // transition has to close here or the review stays on screen inert.
478
+ onCancel: () => {
479
+ deps.transitions.close();
480
+ deps.notify("info", "handoff cancelled; nothing was written", "handoff:cancelled");
481
+ },
482
+ });
483
+ deps.requestRender();
484
+ }
485
+
486
+ /**
487
+ * Mint the successor session and open it on the reviewed document.
488
+ *
489
+ * Order matters. The old session's terminal note is appended while it is
490
+ * still current, because an append always lands in the current session. Then
491
+ * the new session is minted through the one creation path the orchestrator
492
+ * owns, the document goes in as bounded data labelled by its origin, and the
493
+ * old session's skill activations are replayed so loaded skills carry
494
+ * forward. The old session is otherwise untouched.
495
+ */
496
+ function seedHandoffSession(session: SessionContract, fromSessionId: string, goal: string, document: string): void {
497
+ const notice = deps.getSlashNotice();
498
+ const activations = session.current()?.skillActivations ?? [];
499
+ let toSessionId: string;
500
+ try {
501
+ deps.onNewSession?.();
502
+ const minted = session.current()?.id ?? null;
503
+ if (minted === null || minted === fromSessionId) throw new Error("the new session was not created");
504
+ toSessionId = minted;
505
+ session.appendEntry({
506
+ kind: "custom",
507
+ parentTurnId: null,
508
+ customType: HANDOFF_SEED_CUSTOM_TYPE,
509
+ display: true,
510
+ data: { fromSessionId, goal, document } satisfies HandoffSeedData,
511
+ });
512
+ for (const activation of activations) session.recordSkillActivation(activation);
513
+ } catch (error) {
514
+ emitCommandNotice(
515
+ notice,
516
+ "error",
517
+ "handoff",
518
+ `could not seed the new session: ${error instanceof Error ? error.message : String(error)}`,
519
+ );
520
+ return;
521
+ }
522
+ // The note names the target, so it can only be written once the target
523
+ // exists. Appends land in the current session, so the old session is made
524
+ // current for exactly one append and then handed back. A failure here
525
+ // costs the note and nothing else: the successor session is already
526
+ // seeded and is where the operator continues.
527
+ try {
528
+ session.switchBranch(fromSessionId);
529
+ session.appendEntry({
530
+ kind: "custom",
531
+ parentTurnId: null,
532
+ customType: HANDOFF_NOTE_CUSTOM_TYPE,
533
+ display: true,
534
+ data: { toSessionId, goal } satisfies HandoffNoteData,
535
+ });
536
+ } catch (error) {
537
+ deps.stderr(`[/handoff] handoff note failed: ${error instanceof Error ? error.message : String(error)}\n`);
538
+ } finally {
539
+ try {
540
+ session.switchBranch(toSessionId);
541
+ } catch (error) {
542
+ deps.stderr(
543
+ `[/handoff] could not return to the new session: ${error instanceof Error ? error.message : String(error)}\n`,
544
+ );
545
+ }
546
+ }
547
+ try {
548
+ const turns = deps.readStructuredEntries(toSessionId);
549
+ deps.resetTranscript();
550
+ rehydrateChatPanelFromTurns(deps.chatPanel, turns);
551
+ deps.chat.resetForSession(null, buildModelReplayAgentMessagesFromTurns(turns));
552
+ rescopeToBranch(session, turns, null);
553
+ } catch (error) {
554
+ deps.stderr(`[/handoff] seeding replay failed: ${error instanceof Error ? error.message : String(error)}\n`);
555
+ deps.chat.resetForSession(null);
556
+ }
557
+ emitCommandNotice(notice, "info", "handoff", `handed off to session ${toSessionId}`);
558
+ deps.refreshFooter();
559
+ deps.requestRender();
560
+ }
561
+
562
+ return { openResume, openTree, openMessagePicker: openMessagePickerState, startHandoff };
331
563
  }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * `/fleet run <name>` approval: the whole plan, before anything dispatches.
3
+ *
4
+ * The operator reads the compiled plan here and decides. Enter accepts and the
5
+ * run starts; Esc cancels and nothing is dispatched, nothing is written, and no
6
+ * ledger row exists. A contract that failed preflight opens the same overlay
7
+ * with its diagnostics and no accept key, so the only way out of a broken plan
8
+ * is to leave it.
9
+ *
10
+ * The body is a pure function of the projection so the layout is testable
11
+ * without a TUI.
12
+ */
13
+
14
+ import {
15
+ type Component,
16
+ isKeyRelease,
17
+ matchesKey,
18
+ type OverlayHandle,
19
+ type TUI,
20
+ truncateToWidth,
21
+ wrapTextWithAnsi,
22
+ } from "../../engine/tui.js";
23
+ import type { FleetRunPreview, FleetRunPreviewStep } from "../fleet-run-preview.js";
24
+ import { buildResponsiveHint, FocusBox, showClioOverlayFrame } from "../overlay-frame.js";
25
+ import { clioTheme, rule } from "../theme/index.js";
26
+
27
+ export const FLEET_RUN_APPROVAL_OVERLAY_TITLE = "Fleet run approval";
28
+
29
+ const FLEET_RUN_APPROVAL_MIN_WIDTH = 48;
30
+ const FLEET_RUN_APPROVAL_MAX_WIDTH = 110;
31
+ /** Rows of plan shown at once. Anything longer scrolls. */
32
+ export const FLEET_RUN_APPROVAL_VISIBLE_ROWS = 22;
33
+
34
+ function fleetRunApprovalOverlayWidth(columns: number): number {
35
+ return Math.max(FLEET_RUN_APPROVAL_MIN_WIDTH, Math.min(FLEET_RUN_APPROVAL_MAX_WIDTH, columns - 4));
36
+ }
37
+
38
+ /** What the overlay renders: an accepted-shaped plan or the reasons there is none. */
39
+ export type FleetRunApprovalSubject =
40
+ | { ok: true; preview: FleetRunPreview }
41
+ | { ok: false; name: string; diagnostics: ReadonlyArray<string> };
42
+
43
+ export interface OpenFleetRunApprovalOverlayOptions {
44
+ subject: FleetRunApprovalSubject;
45
+ /** Live terminal width, so the box tracks the window it opened in. */
46
+ columns: number;
47
+ /** Enter: dispatch this exact plan. Never called for a failed preflight. */
48
+ onAccept: () => void;
49
+ /** Esc: nothing is dispatched and nothing is written. */
50
+ onCancel: () => void;
51
+ }
52
+
53
+ /** The declared boundary as one phrase: an empty allowlist is a claim, not an absence. */
54
+ export function formatWriteBoundary(writes: ReadonlyArray<string> | undefined): string {
55
+ if (writes === undefined) return "writes unenforced";
56
+ if (writes.length === 0) return "writes nothing";
57
+ return `writes ${writes.join(", ")}`;
58
+ }
59
+
60
+ /** One step as its facts: kind, who runs it, where, its scope, and its boundary. */
61
+ export function formatFleetRunPreviewStep(step: FleetRunPreviewStep): string {
62
+ const loop = step.loop === undefined ? "" : ` (${step.loop.loopId} ${step.loop.role} ${step.loop.attempt})`;
63
+ if (step.kind === "code") {
64
+ const gate = step.gate === undefined ? "" : ` · gate path ${step.gate.path}`;
65
+ return `code ${step.stepId}${loop} · command ${step.commandId ?? "?"}${gate} · ${step.scope} · ${formatWriteBoundary(step.writes)}`;
66
+ }
67
+ const route = step.route === undefined ? "route unresolved" : `${step.route.targetId} ▸ ${step.route.wireModelId}`;
68
+ const node = step.route === undefined ? "" : ` · node ${step.route.nodeId}`;
69
+ const declaredRoute =
70
+ step.target !== undefined
71
+ ? ` · target ${step.target}`
72
+ : step.profile !== undefined
73
+ ? ` · profile ${step.profile}`
74
+ : "";
75
+ const gate =
76
+ step.gate !== undefined
77
+ ? ` · gate path ${step.gate.path} · run ${"commandId" in step.gate ? step.gate.commandId : "?"} · baseline ${"commandId" in step.gate ? step.gate.commandId : "?"}`
78
+ : "";
79
+ const dynamicPlan =
80
+ step.plan === undefined
81
+ ? ""
82
+ : ` · roster ${step.plan.roster.join(", ")} · maxTasks ${step.plan.maxTasks}${step.plan.proposals ? " · proposals" : ""}`;
83
+ return `agent ${step.stepId}${loop} · ${step.agentId ?? "?"} · ${route}${node}${declaredRoute}${gate}${dynamicPlan} · ${step.scope} · ${formatWriteBoundary(step.writes)}`;
84
+ }
85
+
86
+ function formatBudgetLine(preview: FleetRunPreview): string {
87
+ const contract =
88
+ preview.budget.contractUsd === null
89
+ ? "contract declares no ceiling"
90
+ : `contract ceiling $${preview.budget.contractUsd.toFixed(2)}`;
91
+ return `budget: admitted under $${preview.budget.ceilingUsd.toFixed(2)} session ceiling, $${preview.budget.currentUsd.toFixed(4)} spent, ${contract}`;
92
+ }
93
+
94
+ /**
95
+ * Render the body. Waves in order, each step with its facts, then the argv a
96
+ * code step will actually run, and last the budget the run is admitted under.
97
+ */
98
+ export function formatFleetRunApprovalBody(subject: FleetRunApprovalSubject, width: number, scroll: number): string[] {
99
+ const theme = clioTheme();
100
+ const contentWidth = Math.max(1, Math.floor(width));
101
+ const rows: string[] = [];
102
+
103
+ if (!subject.ok) {
104
+ rows.push(theme.fg("error", `${subject.name}: preflight failed; nothing will be dispatched`));
105
+ rows.push(rule(theme, contentWidth));
106
+ for (const diagnostic of subject.diagnostics) {
107
+ for (const line of diagnostic.split("\n")) {
108
+ for (const wrapped of wrapTextWithAnsi(theme.fg("muted", line), contentWidth)) rows.push(wrapped);
109
+ }
110
+ }
111
+ } else {
112
+ const { preview } = subject;
113
+ rows.push(theme.fg("dim", `${preview.name} · plan ${preview.planHash.slice(0, 12)}`));
114
+ rows.push(rule(theme, contentWidth));
115
+ for (const wave of preview.waves) {
116
+ rows.push(theme.fg("accent", `wave ${wave.index}`));
117
+ for (const step of wave.steps) {
118
+ for (const line of wrapTextWithAnsi(theme.fg("muted", ` ${formatFleetRunPreviewStep(step)}`), contentWidth)) {
119
+ rows.push(line);
120
+ }
121
+ if (step.argv !== undefined) {
122
+ rows.push(theme.fg("dim", truncateToWidth(` argv ${step.argv.join(" ")}`, contentWidth, "…", false)));
123
+ }
124
+ }
125
+ }
126
+ rows.push(rule(theme, contentWidth));
127
+ for (const line of wrapTextWithAnsi(theme.fg("info", formatBudgetLine(preview)), contentWidth)) rows.push(line);
128
+ }
129
+
130
+ const maxScroll = Math.max(0, rows.length - FLEET_RUN_APPROVAL_VISIBLE_ROWS);
131
+ const start = Math.max(0, Math.min(scroll, maxScroll));
132
+ const shown = rows.slice(start, start + FLEET_RUN_APPROVAL_VISIBLE_ROWS);
133
+ if (rows.length > FLEET_RUN_APPROVAL_VISIBLE_ROWS) {
134
+ shown.push(theme.fg("dim", `(${start + 1}-${start + shown.length} of ${rows.length} lines)`));
135
+ }
136
+ return shown;
137
+ }
138
+
139
+ class FleetRunApprovalBody implements Component {
140
+ scroll = 0;
141
+
142
+ constructor(private readonly subject: FleetRunApprovalSubject) {}
143
+
144
+ render(width: number): string[] {
145
+ return formatFleetRunApprovalBody(this.subject, width, this.scroll);
146
+ }
147
+
148
+ invalidate(): void {}
149
+ }
150
+
151
+ export function openFleetRunApprovalOverlay(tui: TUI, options: OpenFleetRunApprovalOverlayOptions): OverlayHandle {
152
+ const body = new FleetRunApprovalBody(options.subject);
153
+ const acceptable = options.subject.ok;
154
+ let settled = false;
155
+
156
+ const accept = (): void => {
157
+ if (settled || !acceptable) return;
158
+ settled = true;
159
+ options.onAccept();
160
+ };
161
+ const cancel = (): void => {
162
+ if (settled) return;
163
+ settled = true;
164
+ options.onCancel();
165
+ };
166
+
167
+ const focus = new FocusBox(body, {
168
+ // Keys are matched by name, never by raw bytes: under the kitty keyboard
169
+ // protocol Esc arrives as CSI 27 u, and a byte comparison against "\x1b"
170
+ // left the overlay unanswerable. Everything unmatched is swallowed.
171
+ onInput: (data: string): void => {
172
+ if (settled || isKeyRelease(data)) return;
173
+ if (matchesKey(data, "up")) {
174
+ body.scroll = Math.max(0, body.scroll - 1);
175
+ tui.requestRender();
176
+ return;
177
+ }
178
+ if (matchesKey(data, "down")) {
179
+ body.scroll += 1;
180
+ tui.requestRender();
181
+ return;
182
+ }
183
+ if (matchesKey(data, "enter")) {
184
+ accept();
185
+ return;
186
+ }
187
+ if (matchesKey(data, "escape")) cancel();
188
+ },
189
+ });
190
+
191
+ const handle = showClioOverlayFrame(tui, focus, {
192
+ anchor: "center",
193
+ width: fleetRunApprovalOverlayWidth(options.columns),
194
+ title: FLEET_RUN_APPROVAL_OVERLAY_TITLE,
195
+ footerHint: buildResponsiveHint(
196
+ [...(acceptable ? [{ key: "Enter", verb: "dispatch" }] : []), { key: "↑↓", verb: "scroll" }],
197
+ { key: "Esc", verb: "cancel" },
198
+ ),
199
+ });
200
+
201
+ return {
202
+ ...handle,
203
+ hide(): void {
204
+ cancel();
205
+ handle.hide();
206
+ },
207
+ };
208
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * `/handoff` review: the document, before anything is written anywhere.
3
+ *
4
+ * The operator reads the rendered Markdown here and chooses. Enter accepts the
5
+ * document as it stands, `e` hands it to `$EDITOR` through the same external
6
+ * editor flow the composer uses and comes back with whatever was saved, and Esc
7
+ * cancels the whole handoff. Cancel is the important one: nothing has been
8
+ * written at the point this overlay opens, so leaving it costs the extraction
9
+ * round and nothing else.
10
+ */
11
+
12
+ import {
13
+ type Component,
14
+ isKeyRelease,
15
+ matchesKey,
16
+ type OverlayHandle,
17
+ type TUI,
18
+ wrapTextWithAnsi,
19
+ } from "../../engine/tui.js";
20
+ import { buildResponsiveHint, FocusBox, showClioOverlayFrame } from "../overlay-frame.js";
21
+ import { clioTheme, rule } from "../theme/index.js";
22
+
23
+ export const HANDOFF_REVIEW_OVERLAY_TITLE = "Handoff review";
24
+
25
+ const HANDOFF_REVIEW_MIN_WIDTH = 44;
26
+ const HANDOFF_REVIEW_MAX_WIDTH = 100;
27
+ /** Rows of document shown at once. Anything longer scrolls. */
28
+ export const HANDOFF_REVIEW_VISIBLE_ROWS = 20;
29
+
30
+ export function handoffReviewOverlayWidth(columns: number): number {
31
+ return Math.max(HANDOFF_REVIEW_MIN_WIDTH, Math.min(HANDOFF_REVIEW_MAX_WIDTH, columns - 4));
32
+ }
33
+
34
+ export interface OpenHandoffReviewOverlayOptions {
35
+ document: string;
36
+ goal: string;
37
+ /** Live terminal width, so the box tracks the window it opened in. */
38
+ columns: number;
39
+ /**
40
+ * Hand the current document to `$EDITOR` and return what came back, or null
41
+ * when the edit was cancelled or no editor is configured. The caller owns
42
+ * stopping and restarting the TUI around the child process.
43
+ */
44
+ onEdit: (current: string) => string | null;
45
+ /** Enter: the operator accepts this exact text. */
46
+ onAccept: (document: string) => void;
47
+ /** Esc: the whole handoff is abandoned and nothing is written. */
48
+ onCancel: () => void;
49
+ }
50
+
51
+ export interface HandoffReviewOverlaySession extends OverlayHandle {
52
+ /** The document as it currently stands, including any external edit. */
53
+ document(): string;
54
+ }
55
+
56
+ /**
57
+ * Render the body. Pure so the layout is testable without a TUI: the goal, a
58
+ * rule, then the scrolled document window.
59
+ */
60
+ export function formatHandoffReviewBody(goal: string, document: string, width: number, scroll: number): string[] {
61
+ const theme = clioTheme();
62
+ const contentWidth = Math.max(1, Math.floor(width));
63
+ const lines: string[] = [];
64
+ for (const line of wrapTextWithAnsi(theme.fg("dim", `goal: ${goal}`), contentWidth)) lines.push(line);
65
+ lines.push(rule(theme, contentWidth));
66
+
67
+ const wrapped: string[] = [];
68
+ for (const raw of document.split("\n")) {
69
+ if (raw.length === 0) {
70
+ wrapped.push("");
71
+ continue;
72
+ }
73
+ for (const line of wrapTextWithAnsi(theme.fg("muted", raw), contentWidth)) wrapped.push(line);
74
+ }
75
+ const maxScroll = Math.max(0, wrapped.length - HANDOFF_REVIEW_VISIBLE_ROWS);
76
+ const start = Math.max(0, Math.min(scroll, maxScroll));
77
+ const window = wrapped.slice(start, start + HANDOFF_REVIEW_VISIBLE_ROWS);
78
+ for (const line of window) lines.push(line);
79
+ if (wrapped.length > HANDOFF_REVIEW_VISIBLE_ROWS) {
80
+ lines.push(theme.fg("dim", `(${start + 1}-${start + window.length} of ${wrapped.length} lines)`));
81
+ }
82
+ return lines;
83
+ }
84
+
85
+ class HandoffReviewBody implements Component {
86
+ scroll = 0;
87
+
88
+ constructor(
89
+ private readonly goal: string,
90
+ private text: string,
91
+ ) {}
92
+
93
+ setDocument(next: string): void {
94
+ this.text = next;
95
+ this.scroll = 0;
96
+ }
97
+
98
+ document(): string {
99
+ return this.text;
100
+ }
101
+
102
+ lineCount(): number {
103
+ return this.text.split("\n").length;
104
+ }
105
+
106
+ render(width: number): string[] {
107
+ return formatHandoffReviewBody(this.goal, this.text, width, this.scroll);
108
+ }
109
+
110
+ invalidate(): void {}
111
+ }
112
+
113
+ export function openHandoffReviewOverlay(
114
+ tui: TUI,
115
+ options: OpenHandoffReviewOverlayOptions,
116
+ ): HandoffReviewOverlaySession {
117
+ const body = new HandoffReviewBody(options.goal, options.document);
118
+ let settled = false;
119
+
120
+ const accept = (): void => {
121
+ if (settled) return;
122
+ settled = true;
123
+ options.onAccept(body.document());
124
+ };
125
+ const cancel = (): void => {
126
+ if (settled) return;
127
+ settled = true;
128
+ options.onCancel();
129
+ };
130
+
131
+ const focus = new FocusBox(body, {
132
+ // Keys are matched by name, never by raw bytes: under the kitty keyboard
133
+ // protocol Esc arrives as CSI 27 u, and a byte comparison against "\x1b"
134
+ // left the overlay unanswerable. Everything unmatched is swallowed.
135
+ onInput: (data: string): void => {
136
+ if (settled || isKeyRelease(data)) return;
137
+ if (matchesKey(data, "up")) {
138
+ body.scroll = Math.max(0, body.scroll - 1);
139
+ tui.requestRender();
140
+ return;
141
+ }
142
+ if (matchesKey(data, "down")) {
143
+ body.scroll = Math.min(body.lineCount(), body.scroll + 1);
144
+ tui.requestRender();
145
+ return;
146
+ }
147
+ if (matchesKey(data, "enter")) {
148
+ accept();
149
+ return;
150
+ }
151
+ if (matchesKey(data, "escape")) {
152
+ cancel();
153
+ return;
154
+ }
155
+ if (matchesKey(data, "e") || matchesKey(data, "shift+e")) {
156
+ const edited = options.onEdit(body.document());
157
+ if (edited !== null) body.setDocument(edited);
158
+ tui.requestRender(true);
159
+ }
160
+ },
161
+ });
162
+
163
+ const handle = showClioOverlayFrame(tui, focus, {
164
+ anchor: "center",
165
+ width: handoffReviewOverlayWidth(options.columns),
166
+ title: HANDOFF_REVIEW_OVERLAY_TITLE,
167
+ footerHint: buildResponsiveHint(
168
+ [
169
+ { key: "Enter", verb: "accept" },
170
+ { key: "e", verb: "edit" },
171
+ { key: "↑↓", verb: "scroll" },
172
+ ],
173
+ { key: "Esc", verb: "cancel" },
174
+ ),
175
+ });
176
+
177
+ return {
178
+ ...handle,
179
+ document: () => body.document(),
180
+ hide(): void {
181
+ cancel();
182
+ handle.hide();
183
+ },
184
+ };
185
+ }