@iowarp/clio-coder 0.5.0 → 0.5.2

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 (312) hide show
  1. package/.claude-plugin/marketplace.json +4 -4
  2. package/CHANGELOG.md +35 -0
  3. package/CONTRIBUTING.md +9 -0
  4. package/README.md +12 -9
  5. package/ROADMAP.md +30 -5
  6. package/dist/{acp-KS7ARGV6.js → acp-F6PGOFZ4.js} +2 -2
  7. package/dist/{agents-YOGJ2YEJ.js → agents-ZHP4R6EX.js} +22 -21
  8. package/dist/assets/codewiki.json +1 -1
  9. package/dist/{auth-CEDCO6O6.js → auth-N75U2H3Z.js} +4 -4
  10. package/dist/{chunk-D3KWFOUT.js → chunk-23UIXH6Z.js} +3 -3
  11. package/dist/{chunk-ZR5YA6ZF.js → chunk-36XVK5P5.js} +5 -5
  12. package/dist/{chunk-AVSR2HKI.js → chunk-3DZ4LPLS.js} +6 -6
  13. package/dist/{chunk-LYJJ6546.js → chunk-3GSPCWFX.js} +12 -12
  14. package/dist/{chunk-CRHPCPZP.js → chunk-4KC4CHKJ.js} +2 -2
  15. package/dist/{chunk-4VDHLCF5.js → chunk-4WJBWKMI.js} +2 -2
  16. package/dist/{chunk-KBUTSJSR.js → chunk-4XBYHXGK.js} +2 -2
  17. package/dist/{chunk-6W75D37F.js → chunk-53P775GE.js} +6 -3
  18. package/dist/{chunk-XZWSP67B.js → chunk-5APYAXCB.js} +2 -2
  19. package/dist/{chunk-D65E25JG.js → chunk-67HPYNTJ.js} +2 -2
  20. package/dist/{chunk-5JQR5B4D.js → chunk-6KV4KCQZ.js} +4 -4
  21. package/dist/{chunk-V4XJWDHR.js → chunk-6UKFRTXI.js} +2 -1
  22. package/dist/{chunk-NDPF2N2L.js → chunk-7DW7CJW2.js} +2 -2
  23. package/dist/{chunk-5KQEUIWM.js → chunk-7LUEQODV.js} +4 -4
  24. package/dist/{chunk-LNOXCGN2.js → chunk-7UFDIOBT.js} +3 -3
  25. package/dist/{chunk-DXSETN72.js → chunk-7WUGNN43.js} +2 -2
  26. package/dist/{chunk-V35KZDUZ.js → chunk-AA3GRZWS.js} +2 -2
  27. package/dist/{chunk-X4CEFOSG.js → chunk-ADO3OQIC.js} +4 -4
  28. package/dist/{chunk-UX2L5CUD.js → chunk-AI36V3ON.js} +3 -3
  29. package/dist/{chunk-B7GY6QER.js → chunk-B26OUMJW.js} +2 -2
  30. package/dist/{chunk-LAR7DX5E.js → chunk-B5TASTCK.js} +153 -62
  31. package/dist/{chunk-TGWZXJ7L.js → chunk-BEMQK5Q7.js} +92 -84
  32. package/dist/{chunk-RNDCROSG.js → chunk-CNXU7TR2.js} +3 -3
  33. package/dist/{chunk-3JLTRPN7.js → chunk-CVMUXT64.js} +35 -66
  34. package/dist/{chunk-733ZBT5P.js → chunk-E74MJF6G.js} +4 -4
  35. package/dist/{chunk-T2MDZES3.js → chunk-EBMTBE76.js} +3 -3
  36. package/dist/{chunk-OZ76TLGU.js → chunk-EEITU7S6.js} +2 -2
  37. package/dist/{chunk-GRHOXDKB.js → chunk-ELDJWJKM.js} +97 -14
  38. package/dist/{chunk-GCQFN7YI.js → chunk-EMYEQLWO.js} +2 -2
  39. package/dist/{chunk-LTWH2ACR.js → chunk-EQPFEDFV.js} +222 -127
  40. package/dist/{chunk-TDGYTWM6.js → chunk-F4I3H56H.js} +2 -2
  41. package/dist/chunk-F6KCJO3U.js +523 -0
  42. package/dist/{chunk-FGSQHUCK.js → chunk-F6VKWTTY.js} +5 -5
  43. package/dist/{chunk-UJD5MPP6.js → chunk-FLNXQQ5B.js} +2 -2
  44. package/dist/{chunk-EX57VVWW.js → chunk-FOSRJ2VZ.js} +4 -4
  45. package/dist/{chunk-ZARZ4POR.js → chunk-G5ZDQQE3.js} +3 -3
  46. package/dist/{chunk-HQVN7G4F.js → chunk-G62JBPXN.js} +7 -7
  47. package/dist/{chunk-ZJPORZTC.js → chunk-GIAI7A2K.js} +2 -2
  48. package/dist/chunk-GPTR6OTU.js +2551 -0
  49. package/dist/{chunk-EZD2BXGP.js → chunk-GUFQPTQK.js} +2 -2
  50. package/dist/{chunk-OPD7GL6F.js → chunk-H6FQVNZC.js} +8 -7
  51. package/dist/chunk-H7REJ7L5.js +80 -0
  52. package/dist/{chunk-EYTLV3W3.js → chunk-HTWTORF7.js} +2 -2
  53. package/dist/{chunk-DF6PI6GN.js → chunk-I6UKQPGS.js} +3 -3
  54. package/dist/{chunk-ISHUS7HC.js → chunk-JRW236JT.js} +3 -3
  55. package/dist/{chunk-DE2LU267.js → chunk-KKAJGT3B.js} +3 -2
  56. package/dist/{chunk-OV62D6KO.js → chunk-KQMYL5GR.js} +3 -3
  57. package/dist/{chunk-QDUUOZ3S.js → chunk-KWMFC7FN.js} +3 -3
  58. package/dist/{chunk-R7FGRZNF.js → chunk-LXYHCCIZ.js} +9 -9
  59. package/dist/{chunk-NFSK2VNU.js → chunk-LZMTVVN4.js} +2 -2
  60. package/dist/{chunk-62UK7AGW.js → chunk-M5PMRZSR.js} +397 -44
  61. package/dist/chunk-MMZMM6SW.js +131 -0
  62. package/dist/{chunk-FFYTC2KQ.js → chunk-MNABNEBX.js} +2 -2
  63. package/dist/{chunk-XMDBTYCH.js → chunk-N3CWOGVF.js} +3 -3
  64. package/dist/{chunk-VYRQHORQ.js → chunk-NMQSO6Z6.js} +4 -3
  65. package/dist/{chunk-R2UUNSNQ.js → chunk-O3ZAF7UY.js} +18 -5
  66. package/dist/{chunk-4SYHGVDE.js → chunk-OQNCLZI4.js} +4 -4
  67. package/dist/{chunk-EG4ARXPC.js → chunk-OX6QTA4N.js} +3 -3
  68. package/dist/{chunk-P4GNZPXO.js → chunk-OYUS3UZP.js} +2 -2
  69. package/dist/{chunk-OQ2EOAHA.js → chunk-PF36KOGI.js} +291 -112
  70. package/dist/{chunk-55RMDDFM.js → chunk-PGQG7ZMW.js} +2 -2
  71. package/dist/{chunk-CBAQPTDU.js → chunk-PIU6BXXW.js} +2 -2
  72. package/dist/{chunk-EKV4UBCM.js → chunk-PZBQUJ2F.js} +2 -2
  73. package/dist/{chunk-7RET77VB.js → chunk-Q2HKY32Y.js} +2 -2
  74. package/dist/{chunk-U6YTUVOX.js → chunk-Q4RMNWMZ.js} +4 -4
  75. package/dist/chunk-Q6FRR3AQ.js +48 -0
  76. package/dist/{chunk-6RJMYYIE.js → chunk-QJSIAJBY.js} +3 -3
  77. package/dist/{chunk-DOUISWEE.js → chunk-QOJ4ERMU.js} +5 -5
  78. package/dist/{chunk-IFPDAD7R.js → chunk-RWSRKXLT.js} +3 -3
  79. package/dist/{chunk-T33IYTZM.js → chunk-SAASVMIY.js} +3 -3
  80. package/dist/{chunk-2DTIWSAW.js → chunk-SEQDH6JC.js} +3 -3
  81. package/dist/{chunk-BRCOSE7O.js → chunk-SZUO5BSE.js} +2 -2
  82. package/dist/{chunk-HOEDA42N.js → chunk-T7HZ7KJF.js} +2 -2
  83. package/dist/{chunk-OJXOA6YU.js → chunk-TL6LNERH.js} +2 -2
  84. package/dist/{chunk-UCINIRHW.js → chunk-TPCZXWLT.js} +3 -3
  85. package/dist/{chunk-ZUKUCZYZ.js → chunk-TQ2KTH4A.js} +2 -2
  86. package/dist/{chunk-4WOOTKFD.js → chunk-U3FETNQB.js} +341 -20
  87. package/dist/{chunk-5B3RTYAK.js → chunk-UGDSB4AN.js} +2 -2
  88. package/dist/{chunk-VKHLUZNO.js → chunk-UZ7YBL43.js} +3 -3
  89. package/dist/{chunk-QNUQ7K7D.js → chunk-VKMSEO7Y.js} +4 -4
  90. package/dist/{chunk-IN7DGBVS.js → chunk-VLX5VZ35.js} +5 -5
  91. package/dist/{chunk-SJGS3GDI.js → chunk-WAGBMMNX.js} +5 -5
  92. package/dist/{chunk-BFOSV5EZ.js → chunk-WX2YCH7F.js} +2 -2
  93. package/dist/{chunk-3LT34CAM.js → chunk-XBIGUILU.js} +32 -17
  94. package/dist/{chunk-XDHUDE5K.js → chunk-XKYBFRWR.js} +5 -5
  95. package/dist/{chunk-U5QU5ZOD.js → chunk-XYPWFSU5.js} +3 -3
  96. package/dist/{chunk-YBUECLAF.js → chunk-YEJQDODA.js} +2 -2
  97. package/dist/{chunk-YQ6XEFVK.js → chunk-Z74OGONW.js} +39 -7
  98. package/dist/{chunk-I4ELN5BX.js → chunk-ZTNOEJRI.js} +2 -2
  99. package/dist/cli/index.js +26 -26
  100. package/dist/{clio-K2PBVCEM.js → clio-4IKFE25F.js} +2 -2
  101. package/dist/{clio-context-tools-GR4OHFSO.js → clio-context-tools-OHJXYX4R.js} +25 -23
  102. package/dist/{code-nav-4X3OI6DK.js → code-nav-XPM3MY5I.js} +7 -7
  103. package/dist/{config-Q5J7UBAB.js → config-343YS25T.js} +43 -41
  104. package/dist/{config-graph-P3555574.js → config-graph-WI263KDX.js} +43 -41
  105. package/dist/{configure-KIJ32ZO6.js → configure-GZDG4HER.js} +21 -21
  106. package/dist/{context-LFBQOSXT.js → context-4OFO7N7Y.js} +12 -12
  107. package/dist/{context-WBH3KSVR.js → context-JLK2RLOG.js} +42 -40
  108. package/dist/{context-NNLBES2Y.js → context-O6DIWCF6.js} +23 -21
  109. package/dist/{context-clear-YOHMQ6SQ.js → context-clear-MR7KOGQI.js} +42 -40
  110. package/dist/{context-working-set-MRAEVDNV.js → context-working-set-YZAD3KWO.js} +14 -13
  111. package/dist/{data-tool-JJPBGMQJ.js → data-tool-EKD7DFHG.js} +7 -7
  112. package/dist/{detail-RHJOTGN4.js → detail-YZPHFEVJ.js} +43 -41
  113. package/dist/{dispatch-runner-NC7PYNIO.js → dispatch-runner-VKVXVTWJ.js} +44 -42
  114. package/dist/{doctor-S5KZ2GTN.js → doctor-53AVQXAO.js} +17 -16
  115. package/dist/{doctor-deep-2EJQSHIZ.js → doctor-deep-BLRB2NCG.js} +5 -5
  116. package/dist/{eval-3D6M36N7.js → eval-P3BQSLQE.js} +21 -20
  117. package/dist/{evidence-XNVPPLVK.js → evidence-6LLI6SZH.js} +44 -42
  118. package/dist/{evidence-6IYN52ET.js → evidence-HTN3JLUO.js} +42 -40
  119. package/dist/{evidence-JVECPI3F.js → evidence-NJRE3R5H.js} +42 -40
  120. package/dist/{evolve-TLPUDJDM.js → evolve-WWZHJVJP.js} +42 -40
  121. package/dist/{fleet-WKVBF5SZ.js → fleet-GGZ4BVJE.js} +64 -62
  122. package/dist/{fleet-6RJWIOEO.js → fleet-QG3IIWWM.js} +44 -42
  123. package/dist/{fleet-commands-L6WFT7KZ.js → fleet-commands-J2ZIKISQ.js} +7 -7
  124. package/dist/{fleet-decisions-BTQMWU5A.js → fleet-decisions-Y2Q7375Q.js} +8 -8
  125. package/dist/{fleet-graph-553DZ67Q.js → fleet-graph-TCBCCCBP.js} +11 -11
  126. package/dist/{fleet-inspect-236FZPRB.js → fleet-inspect-KOLKBC56.js} +44 -42
  127. package/dist/{fleet-preflight-NZQHABWO.js → fleet-preflight-44UNCC4Y.js} +27 -25
  128. package/dist/{fleet-validate-7NLAE26R.js → fleet-validate-K2FXRZVA.js} +13 -13
  129. package/dist/{fleet-verify-E3RO6A5W.js → fleet-verify-AS444WDW.js} +42 -40
  130. package/dist/{fleet-view-KMZQY7YJ.js → fleet-view-X4JU4J6O.js} +43 -41
  131. package/dist/gui/ops-worker.js +9 -9
  132. package/dist/gui/reads-worker.js +9 -8
  133. package/dist/{init-DV5B6HQG.js → init-VSOWFHVL.js} +54 -52
  134. package/dist/{interactive-OXGYZYAS.js → interactive-JRCBENLZ.js} +1733 -577
  135. package/dist/{interop-J7UOBOSF.js → interop-JR6BCY7R.js} +10 -10
  136. package/dist/{inventory-OEGIL6PY.js → inventory-3JUGLVJI.js} +43 -41
  137. package/dist/{library-ROZAMUIL.js → library-7BOH3Y7N.js} +15 -15
  138. package/dist/{library-B3UPGY4V.js → library-7PFONXUI.js} +9 -9
  139. package/dist/{library-MXQA5DLT.js → library-TUYZMFIA.js} +7 -7
  140. package/dist/{library-import-2LTWOVBD.js → library-import-TJVXKLDK.js} +10 -10
  141. package/dist/{library-inventory-LJ3XHLWO.js → library-inventory-MD3AYTP4.js} +9 -9
  142. package/dist/{library-validation-DARHHUNW.js → library-validation-ZDLITEA3.js} +7 -7
  143. package/dist/{mcp-2GSPYQ54.js → mcp-EFCWUPUR.js} +3 -3
  144. package/dist/{memory-J3STUUIK.js → memory-Q2YVOKK5.js} +42 -40
  145. package/dist/{models-Y3GMFJ2V.js → models-M7BOCW7F.js} +17 -16
  146. package/dist/{monitor-LHNH577J.js → monitor-2HD3MC67.js} +47 -45
  147. package/dist/{orchestrator-DTAPXULA.js → orchestrator-OUW3ZN6Y.js} +1894 -226
  148. package/dist/{panes-A743ONZ5.js → panes-TTPPSYCL.js} +3 -3
  149. package/dist/{preload-OJBMAIXF.js → preload-A47C2NUF.js} +42 -40
  150. package/dist/{providers-CMETPZNS.js → providers-T43W7EL4.js} +4 -4
  151. package/dist/{resources-FY6XYFN2.js → resources-TP6X2V6W.js} +20 -11
  152. package/dist/{run-H64MEVTA.js → run-LDSBPMCS.js} +61 -59
  153. package/dist/{share-CAFMMQLM.js → share-URHKKMHR.js} +9 -9
  154. package/dist/{skills-ERKQUC7L.js → skills-E477MOOO.js} +13 -11
  155. package/dist/{skills-eval-CQ23MYQK.js → skills-eval-AIBR2L7H.js} +583 -93
  156. package/dist/{skills-inventory-TVUYJDVN.js → skills-inventory-HLMKY3R5.js} +13 -11
  157. package/dist/{slash-commands-ROG3KIFJ.js → slash-commands-LIIZKA5Y.js} +28 -26
  158. package/dist/{startup-background-KDWCQMWT.js → startup-background-JR2KDK6R.js} +43 -41
  159. package/dist/{steer-KKYXJSH4.js → steer-D7RTAPCM.js} +3 -3
  160. package/dist/{system-ZDW44ZGX.js → system-X2P5N4IY.js} +14 -13
  161. package/dist/{targets-AJWA6E7Z.js → targets-VFFAKUFM.js} +25 -24
  162. package/dist/{tasks-6L2JJRC5.js → tasks-ERAWZA22.js} +6 -6
  163. package/dist/{terminal-lease-YFUSDPAD.js → terminal-lease-V2N2BIKQ.js} +2 -2
  164. package/dist/{trace-LA5MVR4Y.js → trace-MA5BT7XY.js} +3 -3
  165. package/dist/{usage-XSILDHKF.js → usage-WYFY2L6M.js} +50 -48
  166. package/dist/{verifiers-ECA5GIHL.js → verifiers-TYIW2XKF.js} +7 -7
  167. package/dist/{verify-R6QQCCEB.js → verify-242UFYTP.js} +6 -6
  168. package/dist/{web-fetch-PVZNMBOB.js → web-fetch-LRAGYU6Y.js} +3 -3
  169. package/dist/{wiki-generate-RP22WP5K.js → wiki-generate-OFKOX2T6.js} +53 -51
  170. package/dist/worker/entry.js +31 -29
  171. package/docs/architecture/architecture.md +1 -0
  172. package/docs/architecture/context-engine.md +4 -2
  173. package/docs/architecture/observability.md +6 -4
  174. package/docs/architecture/prompt-envelope-and-tools.md +1 -1
  175. package/docs/architecture/session-lifecycle.md +1 -1
  176. package/docs/architecture/tui-design.md +12 -9
  177. package/docs/gui/parity/02-slash-and-surfaces.md +1 -1
  178. package/docs/guide/commands-and-modes.md +91 -5
  179. package/docs/guide/configuration-and-targets.md +2 -2
  180. package/docs/guide/context-continuity.md +49 -0
  181. package/docs/guide/proactive-memory.md +6 -3
  182. package/library/registry.yaml +292 -32
  183. package/library/skills/README.md +27 -2
  184. package/library/skills/context/context-handoff/SKILL.md +10 -4
  185. package/library/skills/meta/clio-coder-dev/SKILL.md +92 -88
  186. package/library/skills/meta/clio-coder-dev/evals.md +51 -46
  187. package/library/skills/meta/clio-coder-dev/plugin.json +2 -2
  188. package/library/skills/meta/clio-coder-dev/references/change-map.md +49 -0
  189. package/library/skills/meta/clio-coder-test/SKILL.md +89 -142
  190. package/library/skills/meta/clio-coder-test/evals.md +58 -62
  191. package/library/skills/meta/clio-coder-test/plugin.json +2 -2
  192. package/library/skills/meta/clio-coder-test/references/harness.md +3 -3
  193. package/library/skills/meta/clio-coder-test/references/lifecycle-validation.md +30 -0
  194. package/library/skills/meta/clio-coder-test/references/test-map.md +60 -89
  195. package/library/skills/registry.yaml +5 -5
  196. package/library/skills/skill-marketplace.json +12 -14
  197. package/package.json +1 -1
  198. package/src/cli/skills-eval.ts +1064 -54
  199. package/src/cli/usage.ts +2 -2
  200. package/src/core/clio-repo.ts +3 -0
  201. package/src/core/tool-names.ts +1 -0
  202. package/src/domains/context/budget/inspection.ts +9 -0
  203. package/src/domains/context/budget/live-view.ts +418 -0
  204. package/src/domains/context/budget/pressure.ts +393 -0
  205. package/src/domains/context/budget/request-fit.ts +15 -0
  206. package/src/domains/evidence/build.ts +24 -0
  207. package/src/domains/gateway/mcp/client.ts +1 -1
  208. package/src/domains/memory/commit-state.ts +160 -0
  209. package/src/domains/memory/operations.ts +18 -10
  210. package/src/domains/memory/prompt-cache.ts +95 -0
  211. package/src/domains/memory/prompt-section.ts +50 -7
  212. package/src/domains/memory/relevance.ts +93 -0
  213. package/src/domains/memory/restoration.ts +78 -0
  214. package/src/domains/memory/store.ts +39 -1
  215. package/src/domains/middleware/memory-intervention.ts +116 -24
  216. package/src/domains/observability/background-memory-usage.ts +1 -1
  217. package/src/domains/observability/cost.ts +3 -3
  218. package/src/domains/observability/extension.ts +1 -1
  219. package/src/domains/observability/metrics.ts +1 -1
  220. package/src/domains/prompts/compiler.ts +1 -1
  221. package/src/domains/prompts/extension.ts +38 -1
  222. package/src/domains/quota/anthropic-max-provider.ts +134 -0
  223. package/src/domains/quota/anthropic-usage.ts +220 -0
  224. package/src/domains/quota/antigravity-provider.ts +339 -0
  225. package/src/domains/quota/cache.ts +87 -0
  226. package/src/domains/quota/claude-code-provider.ts +169 -0
  227. package/src/domains/quota/codex-provider.ts +237 -0
  228. package/src/domains/quota/presentation.ts +240 -0
  229. package/src/domains/quota/registry.ts +23 -0
  230. package/src/domains/quota/service.ts +93 -0
  231. package/src/domains/quota/summary-feed.ts +86 -0
  232. package/src/domains/quota/types.ts +84 -0
  233. package/src/domains/resources/index.ts +11 -0
  234. package/src/domains/resources/skills/catalog-view.ts +571 -0
  235. package/src/domains/resources/skills/lexical-match.ts +136 -0
  236. package/src/domains/resources/skills/loader.ts +38 -0
  237. package/src/domains/resources/skills/promotion.ts +1 -55
  238. package/src/domains/resources/skills/provenance-pin.ts +50 -20
  239. package/src/domains/safety/action-classifier.ts +1 -0
  240. package/src/domains/session/compaction/branch-summary.ts +4 -1
  241. package/src/domains/session/compaction/compact.ts +18 -2
  242. package/src/domains/session/compaction/cut-point.ts +20 -1
  243. package/src/domains/session/compaction/tokens.ts +48 -3
  244. package/src/domains/session/context-accounting.ts +8 -1
  245. package/src/domains/session/continuity/carry.ts +59 -0
  246. package/src/domains/session/continuity/contract.ts +592 -0
  247. package/src/domains/session/continuity/evidence.ts +290 -0
  248. package/src/domains/session/continuity/fold.ts +1075 -0
  249. package/src/domains/session/continuity/note.ts +79 -0
  250. package/src/domains/session/continuity/operator-request.ts +104 -0
  251. package/src/domains/session/continuity/persistence.ts +408 -0
  252. package/src/domains/session/continuity/ports.ts +231 -0
  253. package/src/domains/session/continuity/projection.ts +538 -0
  254. package/src/domains/session/continuity/validate.ts +352 -0
  255. package/src/domains/session/entries.ts +50 -3
  256. package/src/domains/session/index.ts +48 -0
  257. package/src/domains/session/migrations/index.ts +7 -3
  258. package/src/domains/session/tree/fork.ts +15 -1
  259. package/src/domains/session/usage.ts +2 -2
  260. package/src/engine/acp/commands.ts +1 -1
  261. package/src/engine/agent.ts +133 -9
  262. package/src/engine/session.ts +13 -6
  263. package/src/entry/orchestrator.ts +116 -33
  264. package/src/interactive/chat-loop-messages.ts +2 -2
  265. package/src/interactive/chat-loop.ts +219 -63
  266. package/src/interactive/chat-renderer.ts +99 -8
  267. package/src/interactive/context-overlay.ts +24 -3
  268. package/src/interactive/continuity-controller.ts +526 -0
  269. package/src/interactive/dispatch-board.ts +21 -3
  270. package/src/interactive/footer/dashboard.ts +25 -1
  271. package/src/interactive/footer/key-hints.ts +2 -2
  272. package/src/interactive/footer/pages.ts +70 -27
  273. package/src/interactive/footer/widgets.ts +32 -7
  274. package/src/interactive/footer-panel.ts +1 -1
  275. package/src/interactive/interactive-application.ts +7 -2
  276. package/src/interactive/interactive-input-runtime.ts +2 -2
  277. package/src/interactive/interactive-presentation.ts +15 -0
  278. package/src/interactive/interactive-slash-runtime.ts +45 -8
  279. package/src/interactive/interactive-tickers.ts +7 -1
  280. package/src/interactive/model-session-replay.ts +130 -3
  281. package/src/interactive/output-reserve.ts +35 -0
  282. package/src/interactive/overlay-general-openers.ts +12 -8
  283. package/src/interactive/overlay-key-routing.ts +6 -9
  284. package/src/interactive/overlay-lifecycle.ts +10 -6
  285. package/src/interactive/overlay-session-lifecycle.ts +21 -14
  286. package/src/interactive/quota-view.ts +229 -0
  287. package/src/interactive/session-last-turn.ts +1 -1
  288. package/src/interactive/session-transcript.ts +2 -1
  289. package/src/interactive/session-usage-reseed.ts +2 -2
  290. package/src/interactive/side-question.ts +2 -2
  291. package/src/interactive/slash-commands.ts +28 -9
  292. package/src/interactive/turn-context.ts +686 -63
  293. package/src/interactive/turn-middleware.ts +39 -6
  294. package/src/interactive/turn-persistence.ts +15 -2
  295. package/src/interactive/turn-prewarm.ts +1 -1
  296. package/src/interactive/turn-runtime.ts +48 -5
  297. package/src/interactive/{cost-overlay.ts → usage-overlay.ts} +121 -33
  298. package/src/interactive/welcome-dashboard.ts +26 -3
  299. package/src/tools/agent-tools.ts +3 -1
  300. package/src/tools/bootstrap.ts +6 -0
  301. package/src/tools/builtin-tool-catalog.ts +9 -0
  302. package/src/tools/context/index.ts +148 -113
  303. package/src/tools/context/surface.ts +11 -7
  304. package/src/tools/core-bootstrap.ts +4 -0
  305. package/src/tools/observation.ts +8 -2
  306. package/src/tools/policy.ts +3 -0
  307. package/src/tools/self-compact.ts +31 -0
  308. package/src/tools/surface.ts +1 -0
  309. package/src/tools/tasks.ts +1 -1
  310. package/dist/chunk-CTFPFW3H.js +0 -44
  311. package/dist/chunk-EOJPDNUP.js +0 -1325
  312. package/dist/chunk-I4Y4WKZR.js +0 -386
@@ -0,0 +1,1075 @@
1
+ /**
2
+ * The pure validating continuity fold (CONTRACTS.md §3, §7, §8).
3
+ *
4
+ * Given one ordered applicable projection of the ledger, this returns the state
5
+ * of the selected transaction, the recallable note, the anomalies it found, and
6
+ * a **proposed** recovery action. It reads no clock, mints no id, touches no
7
+ * disk, calls no model, and never performs the action it proposes.
8
+ *
9
+ * The rules that decide what may extend a chain:
10
+ *
11
+ * - A real record extends the chain only as a validated successor:
12
+ * predecessor equals the head, sequence is head + 1, attempt moves only for
13
+ * `reducing`, and the phase is legal from the head's phase. A higher
14
+ * sequence number is never on its own a reason to advance, for a commit any
15
+ * more than for an event.
16
+ * - A summary carry is a projection of a chain whose middle was cut out of
17
+ * this input, so it may be ahead of the observed head. It may never
18
+ * contradict what was observed: it cannot reopen a terminal state, walk the
19
+ * lifecycle backwards, leave a pause without a resume, or disagree with the
20
+ * observed identity, note, policy or commit.
21
+ * - An exact original commit appended after a validated later carry is the one
22
+ * legitimate out-of-order case. It fills the commit's presence and does not
23
+ * move the head, so recovery can write the record it owes without regressing
24
+ * an acknowledgement or a pause on the next reload.
25
+ * - Missing-commit classification waits for the whole input, because an
26
+ * initial summary legitimately precedes its own commit, and is abandoned
27
+ * entirely when unreadable records could still hide it.
28
+ */
29
+
30
+ import {
31
+ type AcceptedNote,
32
+ type ContinuityAnomaly,
33
+ type ContinuityAuthority,
34
+ type ContinuityCarriedState,
35
+ type ContinuityCheckpointPayload,
36
+ type ContinuityCommitData,
37
+ type ContinuityCommitEntry,
38
+ type ContinuityFoldInput,
39
+ type ContinuityFoldResult,
40
+ type ContinuityOutcomeEvidence,
41
+ type ContinuityPhase,
42
+ type ContinuityPriorHandoff,
43
+ type ContinuityRecoveryAction,
44
+ HANDOFF_MAX_WINDOW_MS,
45
+ HANDOFF_SCHEMA_VERSION,
46
+ type HandoffEvent,
47
+ type HandoffEventPhase,
48
+ type HandoffIdentity,
49
+ type HandoffPolicy,
50
+ type HandoffResumeAuthority,
51
+ type HandoffTransactionEntry,
52
+ type MissingCommitReconstruction,
53
+ type OperatorResumeAuthorityEvidence,
54
+ type TerminalResponseEvidence,
55
+ } from "./contract.js";
56
+ import { verifyAcceptedNote } from "./note.js";
57
+ import {
58
+ canonicalJson,
59
+ isContinuityCarryingSummary,
60
+ isContinuityCheckpointPayload,
61
+ isContinuityCommitEntry,
62
+ isHandoffTransactionEntry,
63
+ } from "./validate.js";
64
+
65
+ /** Phases a real successor may legally follow. */
66
+ const LEGAL_PREDECESSORS: Readonly<Record<HandoffEventPhase | "ready", ReadonlySet<ContinuityPhase>>> = Object.freeze({
67
+ prepared: new Set<ContinuityPhase>(["absent"]),
68
+ reducing: new Set<ContinuityPhase>(["prepared", "failed", "resumed"]),
69
+ ready: new Set<ContinuityPhase>(["reducing"]),
70
+ delivered: new Set<ContinuityPhase>(["ready", "resumed"]),
71
+ acknowledged: new Set<ContinuityPhase>(["delivered"]),
72
+ paused: new Set<ContinuityPhase>(["prepared", "reducing", "ready", "delivered", "resumed"]),
73
+ failed: new Set<ContinuityPhase>(["prepared", "reducing", "resumed"]),
74
+ resumed: new Set<ContinuityPhase>(["paused", "failed"]),
75
+ });
76
+
77
+ /** Position in the linear lifecycle, for carry comparison. Side states are -1. */
78
+ const LIFECYCLE_RANK: Readonly<Record<ContinuityPhase, number>> = Object.freeze({
79
+ absent: 0,
80
+ prepared: 1,
81
+ reducing: 2,
82
+ ready: 3,
83
+ delivered: 4,
84
+ acknowledged: 5,
85
+ paused: -1,
86
+ failed: -1,
87
+ resumed: -1,
88
+ });
89
+
90
+ /** A usable durable timestamp: finite, non-negative, and an exact integer. */
91
+ function isUsableTimestamp(value: number): boolean {
92
+ return Number.isSafeInteger(value) && value >= 0;
93
+ }
94
+
95
+ interface ChainState {
96
+ handoffId: string;
97
+ rootPosition: number;
98
+ identity: HandoffIdentity | null;
99
+ accepted: AcceptedNote | null;
100
+ policy: HandoffPolicy | null;
101
+ commit: ContinuityCommitData | null;
102
+ head: { transition: ContinuityCarriedState["transition"]; event: HandoffEvent | { phase: "ready" } } | null;
103
+ activeResume: { entryId: string; authority: HandoffResumeAuthority } | null;
104
+ /** The evidence row that authorized `activeResume`, for the renewal base. */
105
+ resumeEvidence: OperatorResumeAuthorityEvidence | null;
106
+ delivery: { deliveryId: string; continuationTurnId: string } | null;
107
+ /** Ledger position of the delivery intent, when its record is retained. */
108
+ deliveryPosition: number | null;
109
+ carry: ContinuityCheckpointPayload | null;
110
+ commitPresent: boolean;
111
+ rootSeen: boolean;
112
+ seen: Map<string, string>;
113
+ anomalies: ContinuityAnomaly[];
114
+ poisoned: boolean;
115
+ }
116
+
117
+ function newChain(handoffId: string, position: number): ChainState {
118
+ return {
119
+ handoffId,
120
+ rootPosition: position,
121
+ identity: null,
122
+ accepted: null,
123
+ policy: null,
124
+ commit: null,
125
+ head: null,
126
+ activeResume: null,
127
+ resumeEvidence: null,
128
+ delivery: null,
129
+ deliveryPosition: null,
130
+ carry: null,
131
+ commitPresent: false,
132
+ rootSeen: false,
133
+ seen: new Map(),
134
+ anomalies: [],
135
+ poisoned: false,
136
+ };
137
+ }
138
+
139
+ function fail(chain: ChainState, kind: ContinuityAnomaly["kind"], entryId: string | null, detail: string): void {
140
+ chain.anomalies.push({ kind, entryId, detail });
141
+ chain.poisoned = true;
142
+ }
143
+
144
+ function headPhase(chain: ChainState): ContinuityPhase {
145
+ return chain.head ? (chain.head.event.phase as ContinuityPhase) : "absent";
146
+ }
147
+
148
+ function duplicateVerdict(chain: ChainState, entryId: string, payload: string): "new" | "identical" | "conflict" {
149
+ const previous = chain.seen.get(entryId);
150
+ if (previous === undefined) {
151
+ chain.seen.set(entryId, payload);
152
+ return "new";
153
+ }
154
+ return previous === payload ? "identical" : "conflict";
155
+ }
156
+
157
+ /** Immutable facts every copy of one transaction must agree on. */
158
+ function adoptImmutable(
159
+ chain: ChainState,
160
+ entryId: string,
161
+ identity: HandoffIdentity,
162
+ accepted: AcceptedNote,
163
+ policy: HandoffPolicy,
164
+ ): boolean {
165
+ if (chain.identity === null) {
166
+ chain.identity = identity;
167
+ } else if (canonicalJson(chain.identity) !== canonicalJson(identity)) {
168
+ fail(chain, "duplicate_conflict", entryId, "identity differs across copies of one handoff");
169
+ return false;
170
+ }
171
+ // Every initial authority source is verified, not only the second one: a
172
+ // carry-only note whose stored hash disagrees with its text must not become
173
+ // the chain's accepted note.
174
+ const verified = verifyAcceptedNote(accepted, chain.accepted ?? undefined);
175
+ if (!verified.ok) {
176
+ fail(chain, chain.accepted ? "conflicting_carry" : "unverified_note", entryId, `note rejected: ${verified.reason}`);
177
+ return false;
178
+ }
179
+ chain.accepted ??= accepted;
180
+ if (chain.policy === null) {
181
+ chain.policy = policy;
182
+ } else if (canonicalJson(chain.policy) !== canonicalJson(policy)) {
183
+ fail(chain, "policy_conflict", entryId, "policy differs across copies of one handoff");
184
+ return false;
185
+ }
186
+ return true;
187
+ }
188
+
189
+ function adoptCommit(chain: ChainState, entryId: string, commit: ContinuityCommitData): boolean {
190
+ if (chain.commit === null) {
191
+ chain.commit = commit;
192
+ return true;
193
+ }
194
+ if (canonicalJson(chain.commit) !== canonicalJson(commit)) {
195
+ fail(chain, "conflicting_carry", entryId, "commit data differs across copies of one handoff");
196
+ return false;
197
+ }
198
+ return true;
199
+ }
200
+
201
+ /**
202
+ * Validate a real successor against the observed head: legal phase, exact
203
+ * predecessor, sequence + 1, and the attempt rule. Used for events and for a
204
+ * commit alike, because "a larger sequence number is not proof of a valid
205
+ * chain" applies to both.
206
+ */
207
+ function validSuccessor(
208
+ chain: ChainState,
209
+ entryId: string,
210
+ phase: ContinuityPhase,
211
+ transition: ContinuityCarriedState["transition"],
212
+ ): boolean {
213
+ const from = headPhase(chain);
214
+ if (!LEGAL_PREDECESSORS[phase as HandoffEventPhase | "ready"].has(from)) {
215
+ fail(chain, "illegal_transition", entryId, `${from} -> ${phase} is not a legal transition`);
216
+ return false;
217
+ }
218
+ if (phase === "prepared") return true;
219
+ const head = chain.head;
220
+ if (!head) {
221
+ fail(chain, "broken_chain", entryId, `${phase} has no predecessor in this projection`);
222
+ return false;
223
+ }
224
+ if (transition.prevEntryId !== head.transition.entryId) {
225
+ fail(chain, "broken_chain", entryId, `predecessor ${transition.prevEntryId} is not the head`);
226
+ return false;
227
+ }
228
+ if (transition.sequence !== head.transition.sequence + 1) {
229
+ fail(chain, "broken_chain", entryId, `sequence ${transition.sequence} does not follow the head`);
230
+ return false;
231
+ }
232
+ const expectedAttempt = phase === "reducing" ? head.transition.attempt + 1 : head.transition.attempt;
233
+ if (transition.attempt !== expectedAttempt) {
234
+ fail(chain, "invalid_attempt", entryId, `attempt ${transition.attempt} should be ${expectedAttempt}`);
235
+ return false;
236
+ }
237
+ if (phase === "reducing" && chain.policy && transition.attempt > chain.policy.maxAttempts) {
238
+ fail(chain, "invalid_attempt", entryId, `attempt ${transition.attempt} exceeds the policy maximum`);
239
+ return false;
240
+ }
241
+ return true;
242
+ }
243
+
244
+ /**
245
+ * A successor of a resume must cite it and match the action it was granted. The
246
+ * same rule governs a real event and a one-step carried successor, because a
247
+ * carry that names the adjacent link is making the same claim a record would.
248
+ */
249
+ function resumeRefValid(chain: ChainState, entryId: string, event: HandoffEvent | { phase: "ready" }): boolean {
250
+ if (event.phase !== "reducing" && event.phase !== "delivered") return true;
251
+ const ref = event.resumeRef;
252
+ if (headPhase(chain) !== "resumed") {
253
+ if (ref === undefined) return true;
254
+ fail(chain, "missing_resume_ref", entryId, "resumeRef names a resume this event does not follow");
255
+ return false;
256
+ }
257
+ const resume = chain.activeResume;
258
+ if (!resume || ref !== resume.entryId) {
259
+ fail(chain, "missing_resume_ref", entryId, "event after a resume must name that resume");
260
+ return false;
261
+ }
262
+ const wanted = event.phase === "reducing" ? "reduce" : "deliver";
263
+ if (resume.authority.action !== wanted) {
264
+ fail(chain, "illegal_transition", entryId, `resume granted ${resume.authority.action}, not ${wanted}`);
265
+ return false;
266
+ }
267
+ return true;
268
+ }
269
+
270
+ interface ApplyContext {
271
+ positions: ReadonlyMap<string, number>;
272
+ /** The actual transaction records, by entry id, from this same input. */
273
+ records: ReadonlyMap<string, HandoffTransactionEntry>;
274
+ authorities: ReadonlyArray<OperatorResumeAuthorityEvidence>;
275
+ sessionId: string;
276
+ }
277
+
278
+ /**
279
+ * Bind a resume to a durable new operator control request. Every part of the
280
+ * binding is required, and the request must sit strictly between the head it
281
+ * answers and the resume that cites it: a request recorded before the pause
282
+ * cannot have answered it, and one recorded after the resume cannot have
283
+ * authorized it.
284
+ */
285
+ function authorizeResume(
286
+ chain: ChainState,
287
+ entry: HandoffTransactionEntry,
288
+ authority: HandoffResumeAuthority,
289
+ position: number,
290
+ context: ApplyContext,
291
+ ): OperatorResumeAuthorityEvidence | null {
292
+ // The answered head must be the resume's actual predecessor, so a request
293
+ // answering an older pause cannot authorize a later one.
294
+ if (entry.transition.prevEntryId !== authority.pausedOrFailedEntryId) {
295
+ fail(chain, "unmatched_resume", entry.turnId, "resume authority does not name its own predecessor");
296
+ return null;
297
+ }
298
+ if (authority.action === "deliver" && chain.commit === null) {
299
+ fail(chain, "illegal_transition", entry.turnId, "resume to deliver requires a valid commit");
300
+ return null;
301
+ }
302
+ // Once the reduction has committed there is nothing left to reduce; the
303
+ // recovery path past a commit is delivery.
304
+ if (authority.action === "reduce" && chain.commit !== null) {
305
+ fail(chain, "illegal_transition", entry.turnId, "resume to reduce is not legal once a commit exists");
306
+ return null;
307
+ }
308
+ const answered = context.positions.get(authority.pausedOrFailedEntryId);
309
+ if (answered === undefined) {
310
+ fail(chain, "unmatched_resume", entry.turnId, "the paused or failed head this resume answers is not in the input");
311
+ return null;
312
+ }
313
+ const matched = context.authorities.find(
314
+ (candidate) =>
315
+ candidate.operatorRequestEntryId === authority.operatorRequestEntryId &&
316
+ candidate.handoffId === chain.handoffId &&
317
+ candidate.action === authority.action &&
318
+ candidate.pausedOrFailedEntryId === authority.pausedOrFailedEntryId &&
319
+ candidate.originSessionId === context.sessionId &&
320
+ candidate.position > answered &&
321
+ candidate.position < position,
322
+ );
323
+ if (!matched) {
324
+ fail(
325
+ chain,
326
+ "unvalidated_resume_authority",
327
+ entry.turnId,
328
+ "no durable operator control request binds this handoff, action, head and position",
329
+ );
330
+ return null;
331
+ }
332
+ if (!isUsableTimestamp(matched.acceptedAtMs)) {
333
+ fail(chain, "unvalidated_resume_authority", entry.turnId, "the operator request has no usable accepted time");
334
+ return null;
335
+ }
336
+ return matched;
337
+ }
338
+
339
+ /**
340
+ * Validate a resume that arrived through a summary carry.
341
+ *
342
+ * The references it names must resolve to real records in this input: the
343
+ * answered paused/failed head and the resume itself, with the operator request
344
+ * sitting strictly between them.
345
+ *
346
+ * The summary that carries the payload is **never** a stand-in for the resume's
347
+ * own position. A request recorded before that summary is not thereby before the
348
+ * resume the summary describes, and a carried payload is not evidence of when
349
+ * the events it describes were written. A phase name is not authority either:
350
+ * when a required reference cannot be resolved the transaction stays
351
+ * recall-only and publishes no carry or reconstruction.
352
+ */
353
+ function carriedResumeAuthorized(
354
+ chain: ChainState,
355
+ entryId: string,
356
+ resume: { entryId: string; authority: HandoffResumeAuthority },
357
+ context: ApplyContext,
358
+ carriedPrevEntryId: string | null,
359
+ ): OperatorResumeAuthorityEvidence | null {
360
+ const refuse = (detail: string): null => {
361
+ fail(chain, "unvalidated_resume_authority", entryId, detail);
362
+ return null;
363
+ };
364
+ // When the carried head is the resume itself, its link must answer the head
365
+ // its authority names; a request for an older pause authorizes nothing here.
366
+ if (carriedPrevEntryId !== null) {
367
+ if (carriedPrevEntryId !== resume.authority.pausedOrFailedEntryId) {
368
+ return refuse("the carried resume does not follow the head its authority answers");
369
+ }
370
+ // A carried resumed head is a live request, so it obeys the same action
371
+ // rule a real one does. A historical reduce resume that happened before
372
+ // the commit and is merely retained under a later head is not this case.
373
+ if (resume.authority.action === "reduce" && chain.commit !== null) {
374
+ return refuse("a carried resume to reduce is not legal once a commit exists");
375
+ }
376
+ if (resume.authority.action === "deliver" && chain.commit === null) {
377
+ return refuse("a carried resume to deliver requires a valid commit");
378
+ }
379
+ }
380
+ // The referenced records are looked up and compared, not merely located by
381
+ // id. A carry that reuses a real resume's id while rewriting its authority to
382
+ // another genuine request names an event that never happened.
383
+ const resumeRecord = context.records.get(resume.entryId);
384
+ if (!resumeRecord || resumeRecord.event.phase !== "resumed") {
385
+ return refuse("the carried resume names no actual resumed record in this input");
386
+ }
387
+ if (resumeRecord.identity.handoffId !== chain.handoffId) {
388
+ return refuse("the referenced resumed record belongs to another handoff");
389
+ }
390
+ if (
391
+ canonicalJson(resumeRecord.event.authority) !== canonicalJson(resume.authority) ||
392
+ resumeRecord.transition.prevEntryId !== resume.authority.pausedOrFailedEntryId
393
+ ) {
394
+ return refuse("the carried resume disagrees with the actual resumed record it names");
395
+ }
396
+ const answeredRecord = context.records.get(resume.authority.pausedOrFailedEntryId);
397
+ if (!answeredRecord || (answeredRecord.event.phase !== "paused" && answeredRecord.event.phase !== "failed")) {
398
+ return refuse("the head this carried resume answers is not an actual paused or failed record");
399
+ }
400
+ if (answeredRecord.identity.handoffId !== chain.handoffId) {
401
+ return refuse("the answered paused or failed record belongs to another handoff");
402
+ }
403
+ const answered = context.positions.get(resume.authority.pausedOrFailedEntryId);
404
+ const upper = context.positions.get(resume.entryId);
405
+ if (answered === undefined || upper === undefined || upper <= answered) {
406
+ return refuse("the carried resume does not sit after the head it answers");
407
+ }
408
+ const matched = context.authorities.find(
409
+ (candidate) =>
410
+ candidate.operatorRequestEntryId === resume.authority.operatorRequestEntryId &&
411
+ candidate.handoffId === chain.handoffId &&
412
+ candidate.action === resume.authority.action &&
413
+ candidate.pausedOrFailedEntryId === resume.authority.pausedOrFailedEntryId &&
414
+ candidate.originSessionId === context.sessionId &&
415
+ isUsableTimestamp(candidate.acceptedAtMs) &&
416
+ candidate.position > answered &&
417
+ candidate.position < upper,
418
+ );
419
+ if (!matched) {
420
+ return refuse("no durable operator control request binds this carried resume's handoff, action, head and order");
421
+ }
422
+ return matched;
423
+ }
424
+
425
+ function applyTransactionEvent(
426
+ chain: ChainState,
427
+ entry: HandoffTransactionEntry,
428
+ position: number,
429
+ context: ApplyContext,
430
+ ): void {
431
+ const verdict = duplicateVerdict(chain, entry.turnId, canonicalJson(entry));
432
+ if (verdict === "identical") return;
433
+ if (verdict === "conflict") {
434
+ fail(chain, "duplicate_conflict", entry.turnId, `two different payloads share entry id ${entry.turnId}`);
435
+ return;
436
+ }
437
+
438
+ if (entry.event.phase === "prepared") {
439
+ if (!validSuccessor(chain, entry.turnId, "prepared", entry.transition)) return;
440
+ if (!adoptImmutable(chain, entry.turnId, entry.identity, entry.event.accepted, entry.event.policy)) return;
441
+ chain.rootSeen = true;
442
+ chain.rootPosition = position;
443
+ } else {
444
+ if (chain.identity === null) {
445
+ chain.identity = entry.identity;
446
+ } else if (canonicalJson(chain.identity) !== canonicalJson(entry.identity)) {
447
+ fail(chain, "duplicate_conflict", entry.turnId, "identity differs across records of one handoff");
448
+ return;
449
+ }
450
+ if (!validSuccessor(chain, entry.turnId, entry.event.phase, entry.transition)) return;
451
+ }
452
+
453
+ // A failure only reopens reduction for a provider failure, and only within
454
+ // the attempt budget. Every other failure reason is terminal for automatic
455
+ // work and needs an explicit resume.
456
+ if (entry.event.phase === "reducing" && headPhase(chain) === "failed") {
457
+ const failure = chain.head?.event as Extract<HandoffEvent, { phase: "failed" }>;
458
+ if (failure.reason !== "provider_failed") {
459
+ fail(chain, "illegal_transition", entry.turnId, `failed(${failure.reason}) may not resume reduction`);
460
+ return;
461
+ }
462
+ }
463
+
464
+ if (!resumeRefValid(chain, entry.turnId, entry.event)) return;
465
+
466
+ if (entry.event.phase === "resumed") {
467
+ const evidence = authorizeResume(chain, entry, entry.event.authority, position, context);
468
+ if (!evidence) return;
469
+ chain.activeResume = { entryId: entry.turnId, authority: entry.event.authority };
470
+ chain.resumeEvidence = evidence;
471
+ }
472
+
473
+ chain.head = { transition: entry.transition, event: entry.event };
474
+ if (entry.event.phase === "delivered") {
475
+ chain.delivery = { deliveryId: entry.event.deliveryId, continuationTurnId: entry.event.continuationTurnId };
476
+ chain.deliveryPosition = position;
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Apply a commit record. It is a validated successor of `reducing` like any
482
+ * other event, with one deliberate exception: a commit that exactly matches a
483
+ * commit a validated carry already described is the original being restored, so
484
+ * it records presence and leaves the head alone.
485
+ */
486
+ function applyCommit(chain: ChainState, entry: ContinuityCommitEntry): void {
487
+ const payload = entry.continuity;
488
+ const verdict = duplicateVerdict(chain, entry.turnId, canonicalJson(entry));
489
+ if (verdict === "identical") {
490
+ chain.commitPresent = true;
491
+ return;
492
+ }
493
+ if (verdict === "conflict") {
494
+ fail(chain, "duplicate_conflict", entry.turnId, `two different commit payloads share entry id ${entry.turnId}`);
495
+ return;
496
+ }
497
+ if (!adoptImmutable(chain, entry.turnId, payload.identity, payload.accepted, payload.policy)) return;
498
+
499
+ const known = chain.commit;
500
+ const restoresKnownCommit = known !== null && canonicalJson(known) === canonicalJson(payload.commit);
501
+ if (!adoptCommit(chain, entry.turnId, payload.commit)) return;
502
+
503
+ if (restoresKnownCommit) {
504
+ // The carry already proved this exact commit. Recording its presence is
505
+ // the whole effect; moving the head would regress the carried state.
506
+ chain.commitPresent = true;
507
+ return;
508
+ }
509
+ if (!validSuccessor(chain, entry.turnId, "ready", payload.commit.transition)) return;
510
+ chain.commitPresent = true;
511
+ chain.head = { transition: payload.commit.transition, event: { phase: "ready" } };
512
+ }
513
+
514
+ /**
515
+ * Whether a carried head may advance past an observed one.
516
+ *
517
+ * The intervening records were cut, so adjacency cannot be checked; what can be
518
+ * checked is that the carry does not claim something the observed head rules
519
+ * out. Linear phases must move strictly forward, because there is no legal
520
+ * `ready -> ready` or `delivered -> delivered`; a terminal state is not
521
+ * reopened; a pause is left only through a resume; and a failure reopens
522
+ * reduction only when it was a provider failure.
523
+ */
524
+ function carryMayAdvance(chain: ChainState, to: ContinuityPhase): boolean {
525
+ const from = headPhase(chain);
526
+ if (from === "absent") return true;
527
+ if (from === "acknowledged") return false;
528
+ if (from === "paused") return to === "resumed";
529
+ if (from === "failed") {
530
+ if (to === "resumed") return true;
531
+ if (to !== "reducing") return false;
532
+ const failure = chain.head?.event as Extract<HandoffEvent, { phase: "failed" }>;
533
+ return failure.reason === "provider_failed";
534
+ }
535
+ if (to === "paused" || to === "failed" || to === "resumed") return true;
536
+ return LIFECYCLE_RANK[to] > LIFECYCLE_RANK[from];
537
+ }
538
+
539
+ /**
540
+ * Apply a summary-carried payload. The carry describes a chain whose middle was
541
+ * cut out of this projection, so it may be ahead of the observed head; it may
542
+ * never contradict what was observed, and at the same sequence it must be the
543
+ * same state.
544
+ */
545
+ function applyCarry(
546
+ chain: ChainState,
547
+ payload: ContinuityCheckpointPayload,
548
+ entryId: string,
549
+ context: ApplyContext,
550
+ ): void {
551
+ const verdict = duplicateVerdict(chain, entryId, canonicalJson(payload));
552
+ if (verdict === "identical") return;
553
+ if (verdict === "conflict") {
554
+ fail(chain, "conflicting_carry", entryId, `two different carried payloads share entry id ${entryId}`);
555
+ return;
556
+ }
557
+ if (!adoptImmutable(chain, entryId, payload.identity, payload.accepted, payload.policy)) return;
558
+ if (!adoptCommit(chain, entryId, payload.commit)) return;
559
+
560
+ if (chain.carry && payload.state.transition.sequence === chain.carry.state.transition.sequence) {
561
+ if (canonicalJson(chain.carry.state) !== canonicalJson(payload.state)) {
562
+ fail(chain, "conflicting_carry", entryId, "two carries disagree about the state at the same sequence");
563
+ return;
564
+ }
565
+ }
566
+ if (chain.carry === null || payload.state.transition.sequence > chain.carry.state.transition.sequence) {
567
+ chain.carry = payload;
568
+ }
569
+
570
+ const head = chain.head;
571
+ const carriedPhase = payload.state.event.phase as ContinuityPhase;
572
+ let adjacent = false;
573
+ if (head) {
574
+ if (payload.state.transition.sequence === head.transition.sequence) {
575
+ // At the same position the two must be the same state: link, event and
576
+ // every retained field. A matching event under a different delivery or
577
+ // an invented resume is a conflicting copy, not an idempotent repeat.
578
+ const observed = {
579
+ transition: head.transition,
580
+ event: head.event,
581
+ delivery: chain.delivery,
582
+ activeResume: chain.activeResume,
583
+ };
584
+ const carried = {
585
+ transition: payload.state.transition,
586
+ event: payload.state.event,
587
+ delivery: payload.state.delivery,
588
+ activeResume: payload.state.activeResume,
589
+ };
590
+ if (canonicalJson(observed) !== canonicalJson(carried)) {
591
+ fail(chain, "conflicting_carry", entryId, "the carry contradicts the observed head at the same sequence");
592
+ }
593
+ return;
594
+ }
595
+ if (payload.state.transition.sequence < head.transition.sequence) return;
596
+
597
+ adjacent = payload.state.transition.sequence === head.transition.sequence + 1;
598
+ if (adjacent) {
599
+ // One step is one step whether a record or a carry describes it, so the
600
+ // real successor rules apply in full: exact predecessor, the attempt
601
+ // rule, and the legal phase.
602
+ if (!validSuccessor(chain, entryId, carriedPhase, payload.state.transition)) return;
603
+ } else {
604
+ if (!carryMayAdvance(chain, carriedPhase)) {
605
+ fail(chain, "fabricated_carry_extension", entryId, `a carry cannot move ${headPhase(chain)} to ${carriedPhase}`);
606
+ return;
607
+ }
608
+ // Attempts are journaled and never refunded, so a carry that advances
609
+ // the head while lowering the attempt count is another history.
610
+ if (payload.state.transition.attempt < head.transition.attempt) {
611
+ fail(chain, "invalid_attempt", entryId, "a carry cannot advance the head while refunding a spent attempt");
612
+ return;
613
+ }
614
+ }
615
+ }
616
+
617
+ // A carried resume is held to the same durable operator authority a real one
618
+ // is. Adopting the head first and checking later would let a phase name grant
619
+ // execution for a request that never existed.
620
+ const carriedResume = payload.state.activeResume;
621
+ if (carriedResume) {
622
+ const carriedPrev = carriedPhase === "resumed" ? payload.state.transition.prevEntryId : null;
623
+ const evidence = carriedResumeAuthorized(chain, entryId, carriedResume, context, carriedPrev);
624
+ if (!evidence) return;
625
+ chain.activeResume = carriedResume;
626
+ chain.resumeEvidence = evidence;
627
+ } else if (carriedPhase === "resumed") {
628
+ fail(chain, "unmatched_resume", entryId, "a carried resumed head retains no resume to validate");
629
+ return;
630
+ }
631
+
632
+ // The resume reference is checked against the head the carry follows, which
633
+ // is why it runs after the carried resume is in place and before the head
634
+ // moves on from it.
635
+ if (adjacent && !resumeRefValid(chain, entryId, payload.state.event)) return;
636
+
637
+ chain.head = { transition: payload.state.transition, event: payload.state.event };
638
+ if (payload.state.delivery) chain.delivery = payload.state.delivery;
639
+ }
640
+
641
+ function outcomeRefMatches(
642
+ commit: ContinuityCommitData,
643
+ identity: HandoffIdentity,
644
+ refs: ReadonlyArray<ContinuityOutcomeEvidence>,
645
+ ): boolean {
646
+ if (commit.outcome === "continuity_only") return true;
647
+ const expectedId = commit.outcome === "summarized" ? commit.summaryRef : commit.evictionRef;
648
+ if (expectedId === undefined) return false;
649
+ return refs.some(
650
+ (ref) =>
651
+ ref.entryId === expectedId &&
652
+ ref.outcome === commit.outcome &&
653
+ ref.handoffId === identity.handoffId &&
654
+ ref.commitId === identity.commitId,
655
+ );
656
+ }
657
+
658
+ /**
659
+ * Acknowledgement needs a terminal success that is durable **before** the
660
+ * acknowledgement and **after** the delivery it answers. Matching ids alone
661
+ * would accept a response recorded after the acknowledgement that cites it, or
662
+ * an older success for a newer delivery intent.
663
+ *
664
+ * Both bounds are required and both name real records in this input: the
665
+ * delivery intent's own position and the acknowledgement's own position. The
666
+ * summary that carried the state is never a stand-in for either, because a
667
+ * carried payload is not evidence of when those events were written. Without
668
+ * both bounds the order cannot be proven and the transaction stays recall-only.
669
+ */
670
+ function acknowledgementSupported(
671
+ event: Extract<HandoffEvent, { phase: "acknowledged" }>,
672
+ delivery: { deliveryId: string; continuationTurnId: string } | null,
673
+ responses: ReadonlyArray<TerminalResponseEvidence>,
674
+ bounds: { deliveredAfter: number | null; acknowledgedBefore: number | null },
675
+ ): boolean {
676
+ if (!delivery || delivery.deliveryId !== event.deliveryId) return false;
677
+ const { deliveredAfter, acknowledgedBefore } = bounds;
678
+ if (deliveredAfter === null || acknowledgedBefore === null) return false;
679
+ return responses.some(
680
+ (response) =>
681
+ response.entryId === event.terminalResponseEntryId &&
682
+ response.continuationTurnId === delivery.continuationTurnId &&
683
+ response.status === "success" &&
684
+ Number.isFinite(response.position) &&
685
+ response.position > deliveredAfter &&
686
+ response.position < acknowledgedBefore,
687
+ );
688
+ }
689
+
690
+ /**
691
+ * The window in force, clamped to §7's ceiling. A renewal is measured from when
692
+ * the operator control request was actually accepted, never from the deadline
693
+ * the record asks for: deriving the base backwards would let a far-future
694
+ * request renew itself without bound.
695
+ */
696
+ function effectiveDeadline(
697
+ policy: HandoffPolicy,
698
+ resume: HandoffResumeAuthority | null,
699
+ evidence: OperatorResumeAuthorityEvidence | null,
700
+ ): number {
701
+ if (resume && evidence) {
702
+ return Math.min(resume.automaticDeadlineAtMs, evidence.acceptedAtMs + HANDOFF_MAX_WINDOW_MS);
703
+ }
704
+ return Math.min(policy.automaticDeadlineAtMs, policy.preparedAtMs + HANDOFF_MAX_WINDOW_MS);
705
+ }
706
+
707
+ function priorProjection(chain: ChainState): ContinuityPriorHandoff {
708
+ return {
709
+ handoffId: chain.handoffId,
710
+ commitId: chain.identity?.commitId ?? "",
711
+ phase: headPhase(chain),
712
+ accepted: chain.accepted,
713
+ outcome: chain.commit?.outcome ?? null,
714
+ };
715
+ }
716
+
717
+ function applicable(identity: HandoffIdentity, input: ContinuityFoldInput, path: ReadonlySet<string>): boolean {
718
+ if (identity.originSessionId !== input.selection.sessionId) return false;
719
+ if (!path.has(identity.initiatingTurnId)) return false;
720
+ return identity.branchAnchorTurnId === null || path.has(identity.branchAnchorTurnId);
721
+ }
722
+
723
+ /** A record that claims to be continuity but does not validate. */
724
+ function looksLikeContinuity(value: unknown): boolean {
725
+ if (typeof value !== "object" || value === null) return false;
726
+ const record = value as Record<string, unknown>;
727
+ if (record.kind === "handoffTransaction" || record.kind === "continuityCommit") return true;
728
+ return record.kind === "compactionSummary" && record.continuity !== undefined;
729
+ }
730
+
731
+ export function foldContinuity(input: ContinuityFoldInput): ContinuityFoldResult {
732
+ const path = new Set(input.selection.pathTurnIds);
733
+ const chains = new Map<string, ChainState>();
734
+ const positions = new Map<string, number>();
735
+ const ownerByEntryId = new Map<string, string>();
736
+ /** Entry ids seen as real records here, so a duplicate cannot reorder them. */
737
+ const observed = new Set<string>();
738
+ const malformed: ContinuityAnomaly[] = [];
739
+ let inapplicable = 0;
740
+
741
+ const records = new Map<string, HandoffTransactionEntry>();
742
+ const context: ApplyContext = {
743
+ positions,
744
+ records,
745
+ authorities: input.evidence.resumeAuthorities,
746
+ sessionId: input.selection.sessionId,
747
+ };
748
+
749
+ const chainFor = (handoffId: string, position: number): ChainState => {
750
+ const existing = chains.get(handoffId);
751
+ if (existing) return existing;
752
+ const created = newChain(handoffId, position);
753
+ chains.set(handoffId, created);
754
+ return created;
755
+ };
756
+
757
+ // Entry ids are indexed first so a resume can be checked against the position
758
+ // of the head it answers regardless of scan order. The input is the full
759
+ // applicable ledger before the replay cut, so a record a reference names is
760
+ // present here or nowhere; there is no second coordinate system to reconcile.
761
+ for (let position = 0; position < input.entries.length; position += 1) {
762
+ const raw = input.entries[position];
763
+ if (isHandoffTransactionEntry(raw) || isContinuityCommitEntry(raw)) {
764
+ // First occurrence wins. An identical duplicate appended later must not
765
+ // reorder history and revoke authority that was already valid against
766
+ // the original position.
767
+ const entryId = (raw as { turnId: string }).turnId;
768
+ if (!observed.has(entryId)) {
769
+ observed.add(entryId);
770
+ positions.set(entryId, position);
771
+ if (isHandoffTransactionEntry(raw)) records.set(entryId, raw);
772
+ }
773
+ }
774
+ }
775
+
776
+ for (let position = 0; position < input.entries.length; position += 1) {
777
+ const raw = input.entries[position];
778
+ const transaction = isHandoffTransactionEntry(raw) ? raw : null;
779
+ const commit = !transaction && isContinuityCommitEntry(raw) ? raw : null;
780
+ const carrying = !transaction && !commit && isContinuityCarryingSummary(raw) ? raw : null;
781
+
782
+ if (!transaction && !commit && !carrying) {
783
+ // An unrelated kind is inert. A record that claims to be continuity and
784
+ // does not validate is not: ignoring it silently would let malformed
785
+ // evidence read as missing evidence.
786
+ if (looksLikeContinuity(raw)) {
787
+ malformed.push({
788
+ kind: "malformed_continuity_record",
789
+ entryId: typeof (raw as { turnId?: unknown }).turnId === "string" ? (raw as { turnId: string }).turnId : null,
790
+ detail: "a record claiming a continuity kind failed structural validation",
791
+ });
792
+ }
793
+ continue;
794
+ }
795
+
796
+ const identity = transaction?.identity ?? commit?.continuity.identity ?? carrying?.continuity.identity;
797
+ if (!identity || !applicable(identity, input, path)) {
798
+ inapplicable += 1;
799
+ continue;
800
+ }
801
+ const entryId = (transaction ?? commit ?? carrying)?.turnId as string;
802
+ const owner = ownerByEntryId.get(entryId);
803
+ if (owner !== undefined && owner !== identity.handoffId) {
804
+ const chain = chainFor(identity.handoffId, position);
805
+ fail(chain, "entry_id_reused", entryId, `entry id is also used by handoff ${owner}`);
806
+ const other = chains.get(owner);
807
+ if (other) fail(other, "entry_id_reused", entryId, `entry id is also used by handoff ${identity.handoffId}`);
808
+ continue;
809
+ }
810
+ ownerByEntryId.set(entryId, identity.handoffId);
811
+
812
+ const chain = chainFor(identity.handoffId, position);
813
+ if (transaction) applyTransactionEvent(chain, transaction, position, context);
814
+ else if (commit) applyCommit(chain, commit);
815
+ else if (carrying) applyCarry(chain, carrying.continuity, carrying.turnId, context);
816
+ }
817
+
818
+ const base: Pick<ContinuityFoldResult, "skipped"> = {
819
+ skipped: { inapplicable, unreadable: input.evidence.unreadableRecords },
820
+ };
821
+ if (chains.size === 0) {
822
+ return {
823
+ phase: "absent",
824
+ authority: "none",
825
+ identity: null,
826
+ accepted: null,
827
+ policy: null,
828
+ commit: null,
829
+ state: null,
830
+ attemptsSpent: 0,
831
+ effectiveDeadlineAtMs: null,
832
+ missingCommit: null,
833
+ validated: malformed.length === 0,
834
+ anomalies: malformed,
835
+ action: { kind: "none" },
836
+ priorHandoffs: [],
837
+ ...base,
838
+ };
839
+ }
840
+
841
+ // Successive compaction cycles leave several transactions behind. The
842
+ // newest chain root is the selected transaction; the rest are history whose
843
+ // notes stay recallable and which authorize nothing here. A duplicate of an
844
+ // older prepared record never moves its root, so it cannot re-elect itself.
845
+ const ordered = [...chains.values()].sort((a, b) => a.rootPosition - b.rootPosition);
846
+ const selected = ordered[ordered.length - 1] as ChainState;
847
+ const priorHandoffs = ordered.slice(0, -1).map(priorProjection);
848
+
849
+ return finalize(selected, input, positions, records, malformed, priorHandoffs, inapplicable);
850
+ }
851
+
852
+ function finalize(
853
+ chain: ChainState,
854
+ input: ContinuityFoldInput,
855
+ positions: ReadonlyMap<string, number>,
856
+ records: ReadonlyMap<string, HandoffTransactionEntry>,
857
+ malformed: ReadonlyArray<ContinuityAnomaly>,
858
+ priorHandoffs: ReadonlyArray<ContinuityPriorHandoff>,
859
+ inapplicable: number,
860
+ ): ContinuityFoldResult {
861
+ const anomalies: ContinuityAnomaly[] = [...malformed, ...chain.anomalies];
862
+ const unreadable = input.evidence.unreadableRecords;
863
+ const identity = chain.identity;
864
+ const policy = chain.policy;
865
+ let poisoned = chain.poisoned || malformed.length > 0;
866
+
867
+ // Unreadable records fail closed whether or not a commit is present: a line
868
+ // this projection could not parse can hide a pause, a conflicting duplicate,
869
+ // or the very evidence a reference needs. The note stays recallable; nothing
870
+ // authoritative is published from unresolved input.
871
+ if (unreadable > 0 && identity !== null) {
872
+ anomalies.push({
873
+ kind: "malformed_record",
874
+ entryId: null,
875
+ detail: `${unreadable} unreadable records leave this transaction's evidence unresolved`,
876
+ });
877
+ poisoned = true;
878
+ }
879
+
880
+ if (chain.commit && identity && !outcomeRefMatches(chain.commit, identity, input.evidence.outcomeRefs)) {
881
+ anomalies.push({
882
+ kind: unreadable > 0 ? "missing_commit_ref" : "unbound_outcome_ref",
883
+ entryId: chain.commit.entry.turnId,
884
+ detail: `no ${chain.commit.outcome} record is bound to commit ${identity.commitId}`,
885
+ });
886
+ poisoned = true;
887
+ }
888
+
889
+ const phase = headPhase(chain);
890
+ if (phase === "acknowledged") {
891
+ const event = chain.head?.event as Extract<HandoffEvent, { phase: "acknowledged" }>;
892
+ // Both bounds come from actual records whose payloads are compared, not
893
+ // from ids that merely resolve. When the head arrived through a carry, the
894
+ // delivery intent is the acknowledgement's own predecessor link.
895
+ const ackEntryId = chain.head?.transition.entryId ?? "";
896
+ const deliveryEntryId = chain.head?.transition.prevEntryId ?? "";
897
+ const ackRecord = records.get(ackEntryId);
898
+ const deliveryRecord = records.get(deliveryEntryId);
899
+ const ackMatches =
900
+ ackRecord?.event.phase === "acknowledged" &&
901
+ ackRecord.identity.handoffId === chain.handoffId &&
902
+ ackRecord.event.deliveryId === event.deliveryId &&
903
+ ackRecord.event.terminalResponseEntryId === event.terminalResponseEntryId;
904
+ const deliveryMatches =
905
+ deliveryRecord?.event.phase === "delivered" &&
906
+ deliveryRecord.identity.handoffId === chain.handoffId &&
907
+ deliveryRecord.event.deliveryId === (chain.delivery?.deliveryId ?? null) &&
908
+ deliveryRecord.event.continuationTurnId === (chain.delivery?.continuationTurnId ?? null);
909
+ const ackBounds = {
910
+ deliveredAfter: deliveryMatches ? (chain.deliveryPosition ?? positions.get(deliveryEntryId) ?? null) : null,
911
+ acknowledgedBefore: ackMatches ? (positions.get(ackEntryId) ?? null) : null,
912
+ };
913
+ if (!acknowledgementSupported(event, chain.delivery, input.evidence.terminalResponses, ackBounds)) {
914
+ anomalies.push({
915
+ kind: "acknowledgement_unsupported",
916
+ entryId: chain.head?.transition.entryId ?? null,
917
+ detail: "no terminal successful response matches this delivery",
918
+ });
919
+ poisoned = true;
920
+ }
921
+ }
922
+
923
+ const state: ContinuityCarriedState | null = chain.head
924
+ ? {
925
+ transition: chain.head.transition,
926
+ event: chain.head.event,
927
+ activeResume: chain.activeResume,
928
+ delivery: chain.delivery,
929
+ }
930
+ : null;
931
+
932
+ // Absence is concluded only from a complete, validated projection.
933
+ let missingCommit: MissingCommitReconstruction | null = null;
934
+ if (!chain.commitPresent && chain.carry && identity && state && !poisoned) {
935
+ {
936
+ const original = chain.carry.commit;
937
+ const rebuilt: ContinuityCommitEntry = {
938
+ kind: "continuityCommit",
939
+ turnId: original.entry.turnId,
940
+ parentTurnId: original.entry.parentTurnId,
941
+ timestamp: original.entry.timestamp,
942
+ continuity: {
943
+ schemaVersion: HANDOFF_SCHEMA_VERSION,
944
+ identity,
945
+ accepted: chain.carry.accepted,
946
+ policy: chain.carry.policy,
947
+ commit: original,
948
+ state: { transition: original.transition, event: { phase: "ready" }, activeResume: null, delivery: null },
949
+ },
950
+ };
951
+ // Never publish a reconstruction this module would refuse to read.
952
+ if (isContinuityCheckpointPayload(rebuilt.continuity)) {
953
+ missingCommit = { entry: rebuilt, carriedState: state };
954
+ } else {
955
+ anomalies.push({
956
+ kind: "conflicting_carry",
957
+ entryId: original.entry.turnId,
958
+ detail: "the reconstructed commit does not validate",
959
+ });
960
+ poisoned = true;
961
+ }
962
+ }
963
+ }
964
+
965
+ const attemptsSpent = chain.head?.transition.attempt ?? 0;
966
+ // A clock that sits before preparation, or before the operator request a
967
+ // renewal is measured from, cannot be used to decide a window. Either way
968
+ // the operator recovers; the fold never silently grants time.
969
+ const renewalBase = chain.resumeEvidence?.acceptedAtMs ?? null;
970
+ const clockAmbiguous =
971
+ !Number.isFinite(input.nowMs) ||
972
+ (policy !== null && input.nowMs < policy.preparedAtMs) ||
973
+ (renewalBase !== null && input.nowMs < renewalBase);
974
+ const deadline = policy
975
+ ? effectiveDeadline(policy, chain.activeResume?.authority ?? null, chain.resumeEvidence)
976
+ : null;
977
+ const expired = deadline !== null && !clockAmbiguous && input.nowMs >= deadline;
978
+
979
+ const action = resolveAction({
980
+ historical: input.selection.historical,
981
+ poisoned,
982
+ phase,
983
+ clockAmbiguous,
984
+ expired,
985
+ missingCommit,
986
+ attemptsSpent,
987
+ policy,
988
+ delivery: chain.delivery,
989
+ deliveryPosition: chain.deliveryPosition,
990
+ responses: input.evidence.terminalResponses,
991
+ committed: chain.commit !== null,
992
+ resumeAction: chain.activeResume?.authority.action ?? null,
993
+ });
994
+ // Authority follows the action, so the two can never disagree about whether
995
+ // automatic work may proceed. A transaction whose records were all rejected
996
+ // still has an identity and a note, so it is recall-only rather than absent.
997
+ const authority: ContinuityAuthority =
998
+ identity === null
999
+ ? "none"
1000
+ : action.kind === "none" && phase !== "absent" && phase !== "acknowledged"
1001
+ ? "execution"
1002
+ : "recall_only";
1003
+
1004
+ return {
1005
+ phase,
1006
+ authority,
1007
+ identity,
1008
+ accepted: chain.accepted,
1009
+ policy,
1010
+ commit: chain.commit,
1011
+ state,
1012
+ attemptsSpent,
1013
+ effectiveDeadlineAtMs: deadline,
1014
+ missingCommit,
1015
+ validated: !poisoned,
1016
+ anomalies,
1017
+ action,
1018
+ priorHandoffs,
1019
+ skipped: { inapplicable, unreadable },
1020
+ };
1021
+ }
1022
+
1023
+ function resolveAction(facts: {
1024
+ historical: boolean;
1025
+ poisoned: boolean;
1026
+ phase: ContinuityPhase;
1027
+ clockAmbiguous: boolean;
1028
+ expired: boolean;
1029
+ missingCommit: MissingCommitReconstruction | null;
1030
+ attemptsSpent: number;
1031
+ policy: HandoffPolicy | null;
1032
+ delivery: { deliveryId: string; continuationTurnId: string } | null;
1033
+ deliveryPosition: number | null;
1034
+ responses: ReadonlyArray<TerminalResponseEvidence>;
1035
+ committed: boolean;
1036
+ resumeAction: "reduce" | "deliver" | null;
1037
+ }): ContinuityRecoveryAction {
1038
+ // Disputed evidence is reported before anything else, including an empty
1039
+ // head: a chain whose only records were rejected has evidence to explain,
1040
+ // not nothing to say.
1041
+ if (facts.poisoned) return { kind: "recall_only", reason: "conflicting_evidence" };
1042
+ if (facts.phase === "absent") return { kind: "none" };
1043
+ if (facts.phase === "acknowledged") return { kind: "none" };
1044
+ // An inherited note is recallable in any phase; a fresh explicit request is
1045
+ // what establishes ownership in the new session.
1046
+ if (facts.historical) return { kind: "recall_only", reason: "historical_fork" };
1047
+ if (facts.phase === "paused") return { kind: "recall_only", reason: "paused" };
1048
+ if (facts.phase === "failed") return { kind: "recall_only", reason: "failed" };
1049
+ if (facts.clockAmbiguous) return { kind: "recall_only", reason: "clock_ambiguous" };
1050
+ if (facts.expired) return { kind: "fail", reason: "deadline_exceeded" };
1051
+
1052
+ if (facts.phase === "delivered") {
1053
+ // An older success for an earlier delivery does not resolve this one, and
1054
+ // an intent whose own position was cut cannot be ordered against anything.
1055
+ const supported =
1056
+ facts.deliveryPosition !== null &&
1057
+ facts.responses.some(
1058
+ (response) =>
1059
+ response.continuationTurnId === facts.delivery?.continuationTurnId &&
1060
+ response.status === "success" &&
1061
+ Number.isFinite(response.position) &&
1062
+ response.position > (facts.deliveryPosition as number),
1063
+ );
1064
+ return supported ? { kind: "none" } : { kind: "pause", reason: "delivery_uncertain" };
1065
+ }
1066
+ if (facts.missingCommit) return { kind: "reconstruct_commit", reconstruction: facts.missingCommit };
1067
+ // The attempt budget bounds reductions, not deliveries. Delivering a commit
1068
+ // the final attempt already produced, under an explicit operator request,
1069
+ // spends no attempt and is not a third reduction.
1070
+ const wouldReduce = !(facts.committed && facts.phase === "resumed" && facts.resumeAction === "deliver");
1071
+ if (facts.policy && facts.phase !== "ready" && wouldReduce && facts.attemptsSpent >= facts.policy.maxAttempts) {
1072
+ return { kind: "recall_only", reason: "attempts_exhausted" };
1073
+ }
1074
+ return { kind: "none" };
1075
+ }