@cleocode/core 2026.9.23 → 2026.9.24

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 (651) hide show
  1. package/dist/admin/import-tasks.d.ts.map +1 -1
  2. package/dist/admin/import-tasks.js +35 -6
  3. package/dist/admin/import-tasks.js.map +1 -1
  4. package/dist/admin/import.d.ts.map +1 -1
  5. package/dist/admin/import.js +25 -6
  6. package/dist/admin/import.js.map +1 -1
  7. package/dist/build-provenance.json +2 -2
  8. package/dist/cloud/http.d.ts +8 -0
  9. package/dist/cloud/http.d.ts.map +1 -1
  10. package/dist/cloud/http.js +8 -1
  11. package/dist/cloud/http.js.map +1 -1
  12. package/dist/cloud/index.d.ts +6 -1
  13. package/dist/cloud/index.d.ts.map +1 -1
  14. package/dist/cloud/index.js +6 -1
  15. package/dist/cloud/index.js.map +1 -1
  16. package/dist/cloud/nexus-auth.d.ts +169 -0
  17. package/dist/cloud/nexus-auth.d.ts.map +1 -0
  18. package/dist/cloud/nexus-auth.js +372 -0
  19. package/dist/cloud/nexus-auth.js.map +1 -0
  20. package/dist/cloud/nexus-credentials.d.ts +188 -0
  21. package/dist/cloud/nexus-credentials.d.ts.map +1 -0
  22. package/dist/cloud/nexus-credentials.js +323 -0
  23. package/dist/cloud/nexus-credentials.js.map +1 -0
  24. package/dist/cloud/nexus-link.d.ts +85 -0
  25. package/dist/cloud/nexus-link.d.ts.map +1 -0
  26. package/dist/cloud/nexus-link.js +226 -0
  27. package/dist/cloud/nexus-link.js.map +1 -0
  28. package/dist/cloud/presence.d.ts +88 -0
  29. package/dist/cloud/presence.d.ts.map +1 -0
  30. package/dist/cloud/presence.js +137 -0
  31. package/dist/cloud/presence.js.map +1 -0
  32. package/dist/cloud/projects.d.ts +3 -1
  33. package/dist/cloud/projects.d.ts.map +1 -1
  34. package/dist/cloud/projects.js +4 -2
  35. package/dist/cloud/projects.js.map +1 -1
  36. package/dist/crypto/credentials.d.ts +14 -4
  37. package/dist/crypto/credentials.d.ts.map +1 -1
  38. package/dist/crypto/credentials.js +15 -1
  39. package/dist/crypto/credentials.js.map +1 -1
  40. package/dist/decide/bench/dataset.d.ts +125 -0
  41. package/dist/decide/bench/dataset.d.ts.map +1 -0
  42. package/dist/decide/bench/dataset.js +457 -0
  43. package/dist/decide/bench/dataset.js.map +1 -0
  44. package/dist/decide/bench/index.d.ts +23 -0
  45. package/dist/decide/bench/index.d.ts.map +1 -0
  46. package/dist/decide/bench/index.js +23 -0
  47. package/dist/decide/bench/index.js.map +1 -0
  48. package/dist/decide/bench/metrics.d.ts +55 -0
  49. package/dist/decide/bench/metrics.d.ts.map +1 -0
  50. package/dist/decide/bench/metrics.js +100 -0
  51. package/dist/decide/bench/metrics.js.map +1 -0
  52. package/dist/decide/bench/operation.d.ts +119 -0
  53. package/dist/decide/bench/operation.d.ts.map +1 -0
  54. package/dist/decide/bench/operation.js +171 -0
  55. package/dist/decide/bench/operation.js.map +1 -0
  56. package/dist/decide/bench/profiles.d.ts +74 -0
  57. package/dist/decide/bench/profiles.d.ts.map +1 -0
  58. package/dist/decide/bench/profiles.js +131 -0
  59. package/dist/decide/bench/profiles.js.map +1 -0
  60. package/dist/decide/bench/questions.d.ts +40 -0
  61. package/dist/decide/bench/questions.d.ts.map +1 -0
  62. package/dist/decide/bench/questions.js +107 -0
  63. package/dist/decide/bench/questions.js.map +1 -0
  64. package/dist/decide/bench/report.d.ts +21 -0
  65. package/dist/decide/bench/report.d.ts.map +1 -0
  66. package/dist/decide/bench/report.js +120 -0
  67. package/dist/decide/bench/report.js.map +1 -0
  68. package/dist/decide/bench/runner.d.ts +203 -0
  69. package/dist/decide/bench/runner.d.ts.map +1 -0
  70. package/dist/decide/bench/runner.js +335 -0
  71. package/dist/decide/bench/runner.js.map +1 -0
  72. package/dist/decide/bench/source.d.ts +21 -0
  73. package/dist/decide/bench/source.d.ts.map +1 -0
  74. package/dist/decide/bench/source.js +67 -0
  75. package/dist/decide/bench/source.js.map +1 -0
  76. package/dist/decide/bench/spot-check.d.ts +117 -0
  77. package/dist/decide/bench/spot-check.d.ts.map +1 -0
  78. package/dist/decide/bench/spot-check.js +221 -0
  79. package/dist/decide/bench/spot-check.js.map +1 -0
  80. package/dist/decide/bench/stats.d.ts +57 -0
  81. package/dist/decide/bench/stats.d.ts.map +1 -0
  82. package/dist/decide/bench/stats.js +82 -0
  83. package/dist/decide/bench/stats.js.map +1 -0
  84. package/dist/decide/bench/types.d.ts +216 -0
  85. package/dist/decide/bench/types.d.ts.map +1 -0
  86. package/dist/decide/bench/types.js +104 -0
  87. package/dist/decide/bench/types.js.map +1 -0
  88. package/dist/decide/client.d.ts +35 -1
  89. package/dist/decide/client.d.ts.map +1 -1
  90. package/dist/decide/client.js +124 -30
  91. package/dist/decide/client.js.map +1 -1
  92. package/dist/decide/credentials.d.ts +177 -17
  93. package/dist/decide/credentials.d.ts.map +1 -1
  94. package/dist/decide/credentials.js +505 -41
  95. package/dist/decide/credentials.js.map +1 -1
  96. package/dist/decide/index.d.ts +8 -8
  97. package/dist/decide/index.d.ts.map +1 -1
  98. package/dist/decide/index.js +8 -8
  99. package/dist/decide/index.js.map +1 -1
  100. package/dist/decide/jev-wire.d.ts +25 -19
  101. package/dist/decide/jev-wire.d.ts.map +1 -1
  102. package/dist/decide/jev-wire.js +56 -31
  103. package/dist/decide/jev-wire.js.map +1 -1
  104. package/dist/decide/operations.d.ts +63 -12
  105. package/dist/decide/operations.d.ts.map +1 -1
  106. package/dist/decide/operations.js +116 -58
  107. package/dist/decide/operations.js.map +1 -1
  108. package/dist/decide/provider-state.d.ts +85 -12
  109. package/dist/decide/provider-state.d.ts.map +1 -1
  110. package/dist/decide/provider-state.js +154 -21
  111. package/dist/decide/provider-state.js.map +1 -1
  112. package/dist/decide/provider.d.ts +5 -0
  113. package/dist/decide/provider.d.ts.map +1 -1
  114. package/dist/decide/provider.js.map +1 -1
  115. package/dist/decide/providers.d.ts +15 -2
  116. package/dist/decide/providers.d.ts.map +1 -1
  117. package/dist/decide/providers.js +22 -6
  118. package/dist/decide/providers.js.map +1 -1
  119. package/dist/decide/sites/registry.d.ts +22 -0
  120. package/dist/decide/sites/registry.d.ts.map +1 -1
  121. package/dist/decide/sites/registry.js +23 -0
  122. package/dist/decide/sites/registry.js.map +1 -1
  123. package/dist/decide/wizard.d.ts +12 -4
  124. package/dist/decide/wizard.d.ts.map +1 -1
  125. package/dist/decide/wizard.js +79 -26
  126. package/dist/decide/wizard.js.map +1 -1
  127. package/dist/dispatch/mvi-projection.d.ts +15 -2
  128. package/dist/dispatch/mvi-projection.d.ts.map +1 -1
  129. package/dist/dispatch/mvi-projection.js +73 -12
  130. package/dist/dispatch/mvi-projection.js.map +1 -1
  131. package/dist/docs/adr-allocator.d.ts +2 -2
  132. package/dist/docs/adr-allocator.d.ts.map +1 -1
  133. package/dist/docs/adr-allocator.js +17 -4
  134. package/dist/docs/adr-allocator.js.map +1 -1
  135. package/dist/docs/blob-consolidation.js +1 -1
  136. package/dist/docs/blob-consolidation.js.map +1 -1
  137. package/dist/docs/display-alias.d.ts +1 -1
  138. package/dist/docs/display-alias.d.ts.map +1 -1
  139. package/dist/docs/display-alias.js +7 -4
  140. package/dist/docs/display-alias.js.map +1 -1
  141. package/dist/docs/docs-inconsistency-detector.js +1 -1
  142. package/dist/docs/docs-inconsistency-detector.js.map +1 -1
  143. package/dist/docs/docs-update.d.ts.map +1 -1
  144. package/dist/docs/docs-update.js +6 -1
  145. package/dist/docs/docs-update.js.map +1 -1
  146. package/dist/docs/export-document.js +2928 -1053
  147. package/dist/docs/export-document.js.map +3 -3
  148. package/dist/docs/numbering.js +1 -1
  149. package/dist/docs/numbering.js.map +1 -1
  150. package/dist/docs/supersede.d.ts.map +1 -1
  151. package/dist/docs/supersede.js +6 -3
  152. package/dist/docs/supersede.js.map +1 -1
  153. package/dist/docs/version-ssot.js +1 -1
  154. package/dist/docs/version-ssot.js.map +1 -1
  155. package/dist/doctor/db-substrate.d.ts.map +1 -1
  156. package/dist/doctor/db-substrate.js +39 -24
  157. package/dist/doctor/db-substrate.js.map +1 -1
  158. package/dist/doctor/knowledge-summary.d.ts +10 -3
  159. package/dist/doctor/knowledge-summary.d.ts.map +1 -1
  160. package/dist/doctor/knowledge-summary.js +10 -3
  161. package/dist/doctor/knowledge-summary.js.map +1 -1
  162. package/dist/doctor/project-identity.d.ts +128 -37
  163. package/dist/doctor/project-identity.d.ts.map +1 -1
  164. package/dist/doctor/project-identity.js +476 -63
  165. package/dist/doctor/project-identity.js.map +1 -1
  166. package/dist/doctor/twin-collapse.d.ts +52 -2
  167. package/dist/doctor/twin-collapse.d.ts.map +1 -1
  168. package/dist/doctor/twin-collapse.js +108 -4
  169. package/dist/doctor/twin-collapse.js.map +1 -1
  170. package/dist/error-catalog.js +2 -2
  171. package/dist/error-catalog.js.map +1 -1
  172. package/dist/error-registry.js +1 -1
  173. package/dist/error-registry.js.map +1 -1
  174. package/dist/gateway-client/generated/types.gen.d.ts +22 -2
  175. package/dist/gateway-client/generated/types.gen.d.ts.map +1 -1
  176. package/dist/index.d.ts +1 -1
  177. package/dist/index.d.ts.map +1 -1
  178. package/dist/index.js +1 -1
  179. package/dist/index.js.map +1 -1
  180. package/dist/init.d.ts.map +1 -1
  181. package/dist/init.js +20 -11
  182. package/dist/init.js.map +1 -1
  183. package/dist/inject/index.d.ts.map +1 -1
  184. package/dist/inject/index.js +2 -3
  185. package/dist/inject/index.js.map +1 -1
  186. package/dist/internal.d.ts +5 -4
  187. package/dist/internal.d.ts.map +1 -1
  188. package/dist/internal.js +6 -4
  189. package/dist/internal.js.map +1 -1
  190. package/dist/llm/credential-remove-entry.d.ts +59 -0
  191. package/dist/llm/credential-remove-entry.d.ts.map +1 -0
  192. package/dist/llm/credential-remove-entry.js +101 -0
  193. package/dist/llm/credential-remove-entry.js.map +1 -0
  194. package/dist/llm/oauth/device-code.d.ts +26 -6
  195. package/dist/llm/oauth/device-code.d.ts.map +1 -1
  196. package/dist/llm/oauth/device-code.js +44 -12
  197. package/dist/llm/oauth/device-code.js.map +1 -1
  198. package/dist/llm/plugin-facade.js +3017 -1142
  199. package/dist/llm/plugin-facade.js.map +3 -3
  200. package/dist/memory/attention.d.ts.map +1 -1
  201. package/dist/memory/attention.js +5 -3
  202. package/dist/memory/attention.js.map +1 -1
  203. package/dist/memory/decisions.d.ts +48 -3
  204. package/dist/memory/decisions.d.ts.map +1 -1
  205. package/dist/memory/decisions.js +98 -14
  206. package/dist/memory/decisions.js.map +1 -1
  207. package/dist/memory/learnings.d.ts +6 -6
  208. package/dist/memory/patterns.d.ts +4 -4
  209. package/dist/migration/manual/T10165-backfill-adr-index.d.ts.map +1 -1
  210. package/dist/migration/manual/T10165-backfill-adr-index.js +5 -1
  211. package/dist/migration/manual/T10165-backfill-adr-index.js.map +1 -1
  212. package/dist/nexus/fleet-status.d.ts +83 -0
  213. package/dist/nexus/fleet-status.d.ts.map +1 -0
  214. package/dist/nexus/fleet-status.js +414 -0
  215. package/dist/nexus/fleet-status.js.map +1 -0
  216. package/dist/nexus/git-state.d.ts +15 -0
  217. package/dist/nexus/git-state.d.ts.map +1 -1
  218. package/dist/nexus/git-state.js +67 -2
  219. package/dist/nexus/git-state.js.map +1 -1
  220. package/dist/nexus/identity.d.ts.map +1 -1
  221. package/dist/nexus/identity.js +4 -0
  222. package/dist/nexus/identity.js.map +1 -1
  223. package/dist/nexus/index.d.ts +4 -1
  224. package/dist/nexus/index.d.ts.map +1 -1
  225. package/dist/nexus/index.js +7 -1
  226. package/dist/nexus/index.js.map +1 -1
  227. package/dist/nexus/migrate-json-to-sqlite.d.ts +3 -2
  228. package/dist/nexus/migrate-json-to-sqlite.d.ts.map +1 -1
  229. package/dist/nexus/migrate-json-to-sqlite.js +9 -17
  230. package/dist/nexus/migrate-json-to-sqlite.js.map +1 -1
  231. package/dist/nexus/ops.d.ts +1 -0
  232. package/dist/nexus/ops.d.ts.map +1 -1
  233. package/dist/nexus/project-activity.d.ts +104 -0
  234. package/dist/nexus/project-activity.d.ts.map +1 -0
  235. package/dist/nexus/project-activity.js +150 -0
  236. package/dist/nexus/project-activity.js.map +1 -0
  237. package/dist/nexus/query.d.ts +2 -2
  238. package/dist/nexus/query.d.ts.map +1 -1
  239. package/dist/nexus/query.js +4 -19
  240. package/dist/nexus/query.js.map +1 -1
  241. package/dist/nexus/registry-errors.d.ts +61 -0
  242. package/dist/nexus/registry-errors.d.ts.map +1 -0
  243. package/dist/nexus/registry-errors.js +85 -0
  244. package/dist/nexus/registry-errors.js.map +1 -0
  245. package/dist/nexus/registry.d.ts +30 -5
  246. package/dist/nexus/registry.d.ts.map +1 -1
  247. package/dist/nexus/registry.js +115 -70
  248. package/dist/nexus/registry.js.map +1 -1
  249. package/dist/orchestrate/pivot.d.ts +4 -0
  250. package/dist/orchestrate/pivot.d.ts.map +1 -1
  251. package/dist/orchestrate/pivot.js +75 -12
  252. package/dist/orchestrate/pivot.js.map +1 -1
  253. package/dist/orchestrate/query-ops.d.ts.map +1 -1
  254. package/dist/orchestrate/query-ops.js +29 -27
  255. package/dist/orchestrate/query-ops.js.map +1 -1
  256. package/dist/orchestrate/spawn-ops.d.ts.map +1 -1
  257. package/dist/orchestrate/spawn-ops.js +29 -3
  258. package/dist/orchestrate/spawn-ops.js.map +1 -1
  259. package/dist/orchestration/bootstrap.d.ts.map +1 -1
  260. package/dist/orchestration/bootstrap.js +20 -14
  261. package/dist/orchestration/bootstrap.js.map +1 -1
  262. package/dist/orchestration/index.d.ts +15 -1
  263. package/dist/orchestration/index.d.ts.map +1 -1
  264. package/dist/orchestration/index.js +25 -6
  265. package/dist/orchestration/index.js.map +1 -1
  266. package/dist/orchestration/lead-rollup.d.ts +7 -1
  267. package/dist/orchestration/lead-rollup.d.ts.map +1 -1
  268. package/dist/orchestration/lead-rollup.js +6 -3
  269. package/dist/orchestration/lead-rollup.js.map +1 -1
  270. package/dist/orchestration/spawn-prompt.js +1 -1
  271. package/dist/orchestration/spawn-prompt.js.map +1 -1
  272. package/dist/orchestration/waves.d.ts +21 -10
  273. package/dist/orchestration/waves.d.ts.map +1 -1
  274. package/dist/orchestration/waves.js +38 -35
  275. package/dist/orchestration/waves.js.map +1 -1
  276. package/dist/paths.d.ts +9 -2
  277. package/dist/paths.d.ts.map +1 -1
  278. package/dist/paths.js +30 -13
  279. package/dist/paths.js.map +1 -1
  280. package/dist/phases/index.d.ts +1 -1
  281. package/dist/phases/index.d.ts.map +1 -1
  282. package/dist/phases/index.js +3 -4
  283. package/dist/phases/index.js.map +1 -1
  284. package/dist/project-info.d.ts +40 -3
  285. package/dist/project-info.d.ts.map +1 -1
  286. package/dist/project-info.js +74 -6
  287. package/dist/project-info.js.map +1 -1
  288. package/dist/project-lifecycle.d.ts +28 -7
  289. package/dist/project-lifecycle.d.ts.map +1 -1
  290. package/dist/project-lifecycle.js +114 -47
  291. package/dist/project-lifecycle.js.map +1 -1
  292. package/dist/project-scope.d.ts +70 -3
  293. package/dist/project-scope.d.ts.map +1 -1
  294. package/dist/project-scope.js +154 -15
  295. package/dist/project-scope.js.map +1 -1
  296. package/dist/release/open.d.ts +12 -2
  297. package/dist/release/open.d.ts.map +1 -1
  298. package/dist/release/open.js +32 -3
  299. package/dist/release/open.js.map +1 -1
  300. package/dist/release/preflight-skip.d.ts +73 -0
  301. package/dist/release/preflight-skip.d.ts.map +1 -0
  302. package/dist/release/preflight-skip.js +254 -0
  303. package/dist/release/preflight-skip.js.map +1 -0
  304. package/dist/render/decide/index.d.ts +51 -0
  305. package/dist/render/decide/index.d.ts.map +1 -0
  306. package/dist/render/decide/index.js +289 -0
  307. package/dist/render/decide/index.js.map +1 -0
  308. package/dist/render/index.d.ts +1 -0
  309. package/dist/render/index.d.ts.map +1 -1
  310. package/dist/render/index.js +1 -0
  311. package/dist/render/index.js.map +1 -1
  312. package/dist/render/nexus/graph/index.d.ts +5 -0
  313. package/dist/render/nexus/graph/index.d.ts.map +1 -1
  314. package/dist/render/nexus/graph/index.js +53 -0
  315. package/dist/render/nexus/graph/index.js.map +1 -1
  316. package/dist/scaffold/ensure-config.d.ts +14 -6
  317. package/dist/scaffold/ensure-config.d.ts.map +1 -1
  318. package/dist/scaffold/ensure-config.js +39 -17
  319. package/dist/scaffold/ensure-config.js.map +1 -1
  320. package/dist/scaffold/project-identity.d.ts +115 -23
  321. package/dist/scaffold/project-identity.d.ts.map +1 -1
  322. package/dist/scaffold/project-identity.js +196 -44
  323. package/dist/scaffold/project-identity.js.map +1 -1
  324. package/dist/selfimprove/fix-gen.js +3027 -1152
  325. package/dist/selfimprove/fix-gen.js.map +3 -3
  326. package/dist/sentient/cross-project-hygiene.d.ts +12 -6
  327. package/dist/sentient/cross-project-hygiene.d.ts.map +1 -1
  328. package/dist/sentient/cross-project-hygiene.js +15 -10
  329. package/dist/sentient/cross-project-hygiene.js.map +1 -1
  330. package/dist/session/engine-ops.d.ts +13 -4
  331. package/dist/session/engine-ops.d.ts.map +1 -1
  332. package/dist/session/engine-ops.js +38 -45
  333. package/dist/session/engine-ops.js.map +1 -1
  334. package/dist/sessions/briefing.d.ts +7 -3
  335. package/dist/sessions/briefing.d.ts.map +1 -1
  336. package/dist/sessions/briefing.js +10 -10
  337. package/dist/sessions/briefing.js.map +1 -1
  338. package/dist/sessions/drift-watchdog.d.ts.map +1 -1
  339. package/dist/sessions/drift-watchdog.js +11 -19
  340. package/dist/sessions/drift-watchdog.js.map +1 -1
  341. package/dist/sessions/focus-state-store.d.ts +103 -26
  342. package/dist/sessions/focus-state-store.d.ts.map +1 -1
  343. package/dist/sessions/focus-state-store.js +166 -37
  344. package/dist/sessions/focus-state-store.js.map +1 -1
  345. package/dist/sessions/handoff.d.ts.map +1 -1
  346. package/dist/sessions/handoff.js +9 -18
  347. package/dist/sessions/handoff.js.map +1 -1
  348. package/dist/sessions/index.d.ts +4 -2
  349. package/dist/sessions/index.d.ts.map +1 -1
  350. package/dist/sessions/index.js +35 -6
  351. package/dist/sessions/index.js.map +1 -1
  352. package/dist/sessions/session-drift.d.ts.map +1 -1
  353. package/dist/sessions/session-drift.js +4 -2
  354. package/dist/sessions/session-drift.js.map +1 -1
  355. package/dist/sessions/session-view.d.ts +8 -1
  356. package/dist/sessions/session-view.d.ts.map +1 -1
  357. package/dist/sessions/session-view.js +9 -2
  358. package/dist/sessions/session-view.js.map +1 -1
  359. package/dist/setup/config-json-merge.d.ts.map +1 -1
  360. package/dist/setup/config-json-merge.js +1 -0
  361. package/dist/setup/config-json-merge.js.map +1 -1
  362. package/dist/setup/index.d.ts +4 -2
  363. package/dist/setup/index.d.ts.map +1 -1
  364. package/dist/setup/index.js +6 -2
  365. package/dist/setup/index.js.map +1 -1
  366. package/dist/setup/sections/nexus-account.d.ts +32 -0
  367. package/dist/setup/sections/nexus-account.d.ts.map +1 -0
  368. package/dist/setup/sections/nexus-account.js +64 -0
  369. package/dist/setup/sections/nexus-account.js.map +1 -0
  370. package/dist/setup/wizard.d.ts +1 -1
  371. package/dist/setup/wizard.d.ts.map +1 -1
  372. package/dist/setup/wizard.js.map +1 -1
  373. package/dist/skills/orchestrator/startup.d.ts.map +1 -1
  374. package/dist/skills/orchestrator/startup.js +18 -31
  375. package/dist/skills/orchestrator/startup.js.map +1 -1
  376. package/dist/snapshot/index.d.ts.map +1 -1
  377. package/dist/snapshot/index.js +60 -53
  378. package/dist/snapshot/index.js.map +1 -1
  379. package/dist/spawn/agent-identity.d.ts +33 -1
  380. package/dist/spawn/agent-identity.d.ts.map +1 -1
  381. package/dist/spawn/agent-identity.js +53 -1
  382. package/dist/spawn/agent-identity.js.map +1 -1
  383. package/dist/spawn/index.d.ts +1 -1
  384. package/dist/spawn/index.d.ts.map +1 -1
  385. package/dist/spawn/index.js +1 -1
  386. package/dist/spawn/index.js.map +1 -1
  387. package/dist/stats/index.d.ts.map +1 -1
  388. package/dist/stats/index.js +3 -4
  389. package/dist/stats/index.js.map +1 -1
  390. package/dist/status/index.d.ts +12 -3
  391. package/dist/status/index.d.ts.map +1 -1
  392. package/dist/status/index.js +47 -5
  393. package/dist/status/index.js.map +1 -1
  394. package/dist/store/attachment-repair.d.ts.map +1 -1
  395. package/dist/store/attachment-repair.js +42 -3
  396. package/dist/store/attachment-repair.js.map +1 -1
  397. package/dist/store/attachment-store.d.ts.map +1 -1
  398. package/dist/store/attachment-store.js +30 -46
  399. package/dist/store/attachment-store.js.map +1 -1
  400. package/dist/store/backup-recover-brain.d.ts +4 -0
  401. package/dist/store/backup-recover-brain.d.ts.map +1 -1
  402. package/dist/store/backup-recover-brain.js +2 -0
  403. package/dist/store/backup-recover-brain.js.map +1 -1
  404. package/dist/store/backup-recover.d.ts +9 -0
  405. package/dist/store/backup-recover.d.ts.map +1 -1
  406. package/dist/store/backup-recover.js +28 -1
  407. package/dist/store/backup-recover.js.map +1 -1
  408. package/dist/store/backup-sidecar.d.ts +30 -0
  409. package/dist/store/backup-sidecar.d.ts.map +1 -1
  410. package/dist/store/backup-sidecar.js +90 -2
  411. package/dist/store/backup-sidecar.js.map +1 -1
  412. package/dist/store/blob-keep.d.ts +135 -0
  413. package/dist/store/blob-keep.d.ts.map +1 -0
  414. package/dist/store/blob-keep.js +283 -0
  415. package/dist/store/blob-keep.js.map +1 -0
  416. package/dist/store/converters.d.ts.map +1 -1
  417. package/dist/store/converters.js +11 -0
  418. package/dist/store/converters.js.map +1 -1
  419. package/dist/store/credential-transfer.d.ts +37 -5
  420. package/dist/store/credential-transfer.d.ts.map +1 -1
  421. package/dist/store/credential-transfer.js +73 -18
  422. package/dist/store/credential-transfer.js.map +1 -1
  423. package/dist/store/data-accessor.d.ts +1 -1
  424. package/dist/store/data-accessor.d.ts.map +1 -1
  425. package/dist/store/data-accessor.js.map +1 -1
  426. package/dist/store/db-helpers.d.ts +18 -0
  427. package/dist/store/db-helpers.d.ts.map +1 -1
  428. package/dist/store/db-helpers.js +46 -0
  429. package/dist/store/db-helpers.js.map +1 -1
  430. package/dist/store/exodus/on-open.d.ts.map +1 -1
  431. package/dist/store/exodus/on-open.js +35 -0
  432. package/dist/store/exodus/on-open.js.map +1 -1
  433. package/dist/store/exodus/runtime-targets.d.ts +3 -3
  434. package/dist/store/exodus/runtime-targets.js +3 -3
  435. package/dist/store/import-remap.d.ts +12 -1
  436. package/dist/store/import-remap.d.ts.map +1 -1
  437. package/dist/store/import-remap.js +14 -0
  438. package/dist/store/import-remap.js.map +1 -1
  439. package/dist/store/legacy-tasks-lineage.d.ts +3 -1
  440. package/dist/store/legacy-tasks-lineage.d.ts.map +1 -1
  441. package/dist/store/legacy-tasks-lineage.js +3 -1
  442. package/dist/store/legacy-tasks-lineage.js.map +1 -1
  443. package/dist/store/memory-accessor.js +1 -1
  444. package/dist/store/memory-accessor.js.map +1 -1
  445. package/dist/store/migration-manager.d.ts +61 -0
  446. package/dist/store/migration-manager.d.ts.map +1 -1
  447. package/dist/store/migration-manager.js +102 -4
  448. package/dist/store/migration-manager.js.map +1 -1
  449. package/dist/store/nexus-validation-schemas.d.ts +64 -0
  450. package/dist/store/nexus-validation-schemas.d.ts.map +1 -1
  451. package/dist/store/portable-bundle-import.d.ts +8 -0
  452. package/dist/store/portable-bundle-import.d.ts.map +1 -1
  453. package/dist/store/portable-bundle-import.js +10 -0
  454. package/dist/store/portable-bundle-import.js.map +1 -1
  455. package/dist/store/portable-bundle-scan.d.ts.map +1 -1
  456. package/dist/store/portable-bundle-scan.js +1 -0
  457. package/dist/store/portable-bundle-scan.js.map +1 -1
  458. package/dist/store/portable-bundle.d.ts.map +1 -1
  459. package/dist/store/portable-bundle.js +15 -14
  460. package/dist/store/portable-bundle.js.map +1 -1
  461. package/dist/store/pre-repair-snapshot.d.ts +4 -1
  462. package/dist/store/pre-repair-snapshot.d.ts.map +1 -1
  463. package/dist/store/pre-repair-snapshot.js +5 -1
  464. package/dist/store/pre-repair-snapshot.js.map +1 -1
  465. package/dist/store/regenerators.d.ts +4 -2
  466. package/dist/store/regenerators.d.ts.map +1 -1
  467. package/dist/store/regenerators.js +7 -3
  468. package/dist/store/regenerators.js.map +1 -1
  469. package/dist/store/repair-malformed-dbs.d.ts +4 -0
  470. package/dist/store/repair-malformed-dbs.d.ts.map +1 -1
  471. package/dist/store/repair-malformed-dbs.js +3 -0
  472. package/dist/store/repair-malformed-dbs.js.map +1 -1
  473. package/dist/store/safety-data-accessor.d.ts +8 -3
  474. package/dist/store/safety-data-accessor.d.ts.map +1 -1
  475. package/dist/store/safety-data-accessor.js +11 -4
  476. package/dist/store/safety-data-accessor.js.map +1 -1
  477. package/dist/store/schema/cleo-global/nexus.d.ts +48 -0
  478. package/dist/store/schema/cleo-global/nexus.d.ts.map +1 -1
  479. package/dist/store/schema/cleo-global/nexus.js +17 -0
  480. package/dist/store/schema/cleo-global/nexus.js.map +1 -1
  481. package/dist/store/schema/cleo-project/docs.d.ts +16 -0
  482. package/dist/store/schema/cleo-project/docs.d.ts.map +1 -1
  483. package/dist/store/schema/cleo-project/docs.js +10 -0
  484. package/dist/store/schema/cleo-project/docs.js.map +1 -1
  485. package/dist/store/schema/cleo-project/tasks-core.d.ts +80 -0
  486. package/dist/store/schema/cleo-project/tasks-core.d.ts.map +1 -1
  487. package/dist/store/schema/cleo-project/tasks-core.js +25 -1
  488. package/dist/store/schema/cleo-project/tasks-core.js.map +1 -1
  489. package/dist/store/schema/cleo-shared/brain.d.ts +1 -1
  490. package/dist/store/schema/memory-schema.d.ts +1 -1
  491. package/dist/store/schema/nexus-schema.d.ts +48 -0
  492. package/dist/store/schema/nexus-schema.d.ts.map +1 -1
  493. package/dist/store/schema/nexus-schema.js +28 -7
  494. package/dist/store/schema/nexus-schema.js.map +1 -1
  495. package/dist/store/session-store.d.ts +65 -0
  496. package/dist/store/session-store.d.ts.map +1 -1
  497. package/dist/store/session-store.js +108 -7
  498. package/dist/store/session-store.js.map +1 -1
  499. package/dist/store/sqlite-data-accessor.d.ts.map +1 -1
  500. package/dist/store/sqlite-data-accessor.js +245 -47
  501. package/dist/store/sqlite-data-accessor.js.map +1 -1
  502. package/dist/store/sqlite.d.ts +15 -3
  503. package/dist/store/sqlite.d.ts.map +1 -1
  504. package/dist/store/sqlite.js +52 -20
  505. package/dist/store/sqlite.js.map +1 -1
  506. package/dist/store/store-write-guard.d.ts +9 -3
  507. package/dist/store/store-write-guard.d.ts.map +1 -1
  508. package/dist/store/store-write-guard.js +25 -4
  509. package/dist/store/store-write-guard.js.map +1 -1
  510. package/dist/store/table-classification.d.ts +1 -1
  511. package/dist/store/table-classification.d.ts.map +1 -1
  512. package/dist/store/table-classification.js +34 -8
  513. package/dist/store/table-classification.js.map +1 -1
  514. package/dist/store/task-claim.d.ts +144 -0
  515. package/dist/store/task-claim.d.ts.map +1 -0
  516. package/dist/store/task-claim.js +195 -0
  517. package/dist/store/task-claim.js.map +1 -0
  518. package/dist/store/task-version.d.ts +43 -5
  519. package/dist/store/task-version.d.ts.map +1 -1
  520. package/dist/store/task-version.js +113 -17
  521. package/dist/store/task-version.js.map +1 -1
  522. package/dist/store/tasks-schema.d.ts +2 -0
  523. package/dist/store/tasks-schema.d.ts.map +1 -1
  524. package/dist/store/tasks-schema.js +1 -0
  525. package/dist/store/tasks-schema.js.map +1 -1
  526. package/dist/store/tasks-sqlite.js +1 -1
  527. package/dist/store/tasks-sqlite.js.map +1 -1
  528. package/dist/store/twin-collapse.d.ts +339 -22
  529. package/dist/store/twin-collapse.d.ts.map +1 -1
  530. package/dist/store/twin-collapse.js +1323 -48
  531. package/dist/store/twin-collapse.js.map +1 -1
  532. package/dist/store/umbrella-data-accessor.d.ts +8 -3
  533. package/dist/store/umbrella-data-accessor.d.ts.map +1 -1
  534. package/dist/store/umbrella-data-accessor.js +10 -4
  535. package/dist/store/umbrella-data-accessor.js.map +1 -1
  536. package/dist/store/validation-schemas.d.ts +161 -1
  537. package/dist/store/validation-schemas.d.ts.map +1 -1
  538. package/dist/store/with-retry.d.ts +36 -0
  539. package/dist/store/with-retry.d.ts.map +1 -1
  540. package/dist/store/with-retry.js +51 -0
  541. package/dist/store/with-retry.js.map +1 -1
  542. package/dist/store/worktree-build-guard.d.ts +13 -4
  543. package/dist/store/worktree-build-guard.d.ts.map +1 -1
  544. package/dist/store/worktree-build-guard.js +21 -10
  545. package/dist/store/worktree-build-guard.js.map +1 -1
  546. package/dist/store/worktree-isolation-guard.d.ts +121 -10
  547. package/dist/store/worktree-isolation-guard.d.ts.map +1 -1
  548. package/dist/store/worktree-isolation-guard.js +155 -34
  549. package/dist/store/worktree-isolation-guard.js.map +1 -1
  550. package/dist/system/backup.d.ts +7 -0
  551. package/dist/system/backup.d.ts.map +1 -1
  552. package/dist/system/backup.js +5 -0
  553. package/dist/system/backup.js.map +1 -1
  554. package/dist/system/health.d.ts.map +1 -1
  555. package/dist/system/health.js +3 -1
  556. package/dist/system/health.js.map +1 -1
  557. package/dist/system/inject-generate.d.ts.map +1 -1
  558. package/dist/system/inject-generate.js +23 -10
  559. package/dist/system/inject-generate.js.map +1 -1
  560. package/dist/system/project-health.d.ts +8 -0
  561. package/dist/system/project-health.d.ts.map +1 -1
  562. package/dist/system/project-health.js +13 -5
  563. package/dist/system/project-health.js.map +1 -1
  564. package/dist/task-tools/index.d.ts +1 -1
  565. package/dist/task-tools/index.d.ts.map +1 -1
  566. package/dist/task-tools/index.js +1 -1
  567. package/dist/task-tools/index.js.map +1 -1
  568. package/dist/task-tools/score-task-priority.d.ts +38 -0
  569. package/dist/task-tools/score-task-priority.d.ts.map +1 -1
  570. package/dist/task-tools/score-task-priority.js +51 -0
  571. package/dist/task-tools/score-task-priority.js.map +1 -1
  572. package/dist/task-work/claims.d.ts +215 -0
  573. package/dist/task-work/claims.d.ts.map +1 -0
  574. package/dist/task-work/claims.js +272 -0
  575. package/dist/task-work/claims.js.map +1 -0
  576. package/dist/task-work/index.d.ts +47 -3
  577. package/dist/task-work/index.d.ts.map +1 -1
  578. package/dist/task-work/index.js +74 -41
  579. package/dist/task-work/index.js.map +1 -1
  580. package/dist/tasks/add.d.ts.map +1 -1
  581. package/dist/tasks/add.js +3 -2
  582. package/dist/tasks/add.js.map +1 -1
  583. package/dist/tasks/analyze.d.ts.map +1 -1
  584. package/dist/tasks/analyze.js +3 -4
  585. package/dist/tasks/analyze.js.map +1 -1
  586. package/dist/tasks/change-set.d.ts +5 -0
  587. package/dist/tasks/change-set.d.ts.map +1 -1
  588. package/dist/tasks/change-set.js +59 -3
  589. package/dist/tasks/change-set.js.map +1 -1
  590. package/dist/tasks/complete.d.ts +16 -0
  591. package/dist/tasks/complete.d.ts.map +1 -1
  592. package/dist/tasks/complete.js +27 -7
  593. package/dist/tasks/complete.js.map +1 -1
  594. package/dist/tasks/component-pr.d.ts +162 -1
  595. package/dist/tasks/component-pr.d.ts.map +1 -1
  596. package/dist/tasks/component-pr.js +560 -59
  597. package/dist/tasks/component-pr.js.map +1 -1
  598. package/dist/tasks/done-record.d.ts +14 -1
  599. package/dist/tasks/done-record.d.ts.map +1 -1
  600. package/dist/tasks/done-record.js +37 -0
  601. package/dist/tasks/done-record.js.map +1 -1
  602. package/dist/tasks/duplicate-detector.d.ts +20 -1
  603. package/dist/tasks/duplicate-detector.d.ts.map +1 -1
  604. package/dist/tasks/duplicate-detector.js +10 -2
  605. package/dist/tasks/duplicate-detector.js.map +1 -1
  606. package/dist/tasks/engine-wrap-ops.d.ts +19 -10
  607. package/dist/tasks/engine-wrap-ops.d.ts.map +1 -1
  608. package/dist/tasks/engine-wrap-ops.js +38 -10
  609. package/dist/tasks/engine-wrap-ops.js.map +1 -1
  610. package/dist/tasks/graph-ops.d.ts +9 -2
  611. package/dist/tasks/graph-ops.d.ts.map +1 -1
  612. package/dist/tasks/graph-ops.js +24 -32
  613. package/dist/tasks/graph-ops.js.map +1 -1
  614. package/dist/tasks/plan.d.ts.map +1 -1
  615. package/dist/tasks/plan.js +16 -60
  616. package/dist/tasks/plan.js.map +1 -1
  617. package/dist/tasks/task-context.d.ts.map +1 -1
  618. package/dist/tasks/task-context.js +13 -0
  619. package/dist/tasks/task-context.js.map +1 -1
  620. package/dist/tasks/task-import.d.ts.map +1 -1
  621. package/dist/tasks/task-import.js +21 -12
  622. package/dist/tasks/task-import.js.map +1 -1
  623. package/dist/tasks/task-next.d.ts +25 -2
  624. package/dist/tasks/task-next.d.ts.map +1 -1
  625. package/dist/tasks/task-next.js +32 -13
  626. package/dist/tasks/task-next.js.map +1 -1
  627. package/dist/tasks/update.d.ts.map +1 -1
  628. package/dist/tasks/update.js +7 -5
  629. package/dist/tasks/update.js.map +1 -1
  630. package/dist/upgrade.d.ts +1 -1
  631. package/dist/upgrade.d.ts.map +1 -1
  632. package/dist/upgrade.js +48 -9
  633. package/dist/upgrade.js.map +1 -1
  634. package/dist/validation/doctor/checks.d.ts +20 -6
  635. package/dist/validation/doctor/checks.d.ts.map +1 -1
  636. package/dist/validation/doctor/checks.js +51 -8
  637. package/dist/validation/doctor/checks.js.map +1 -1
  638. package/dist/validation/validate-ops.d.ts.map +1 -1
  639. package/dist/validation/validate-ops.js +2 -3
  640. package/dist/validation/validate-ops.js.map +1 -1
  641. package/migrations/drizzle-cleo-global/20260929010000_t12512-registry-probed-opened/migration.sql +35 -0
  642. package/migrations/drizzle-cleo-global/20260929020000_t12721-git-state-head-committed-at/migration.sql +25 -0
  643. package/migrations/drizzle-cleo-project/20260929000000_t12535-docs-attachments-union-shape/migration.sql +21 -0
  644. package/migrations/drizzle-cleo-project/20260929120000_t12502-task-claim-leases/migration.sql +77 -0
  645. package/migrations/drizzle-cleo-project/20260929130000_t12736-claim-lease-iso-repair/migration.sql +37 -0
  646. package/package.json +23 -13
  647. package/schemas/project-info.schema.json +10 -4
  648. package/templates/CLEO-INJECTION.md +1 -1
  649. package/templates/CLEO-REFERENCE.md +3 -3
  650. package/templates/cleo-gitignore +11 -0
  651. package/templates/workflows/release-prepare.yml.tmpl +156 -10
@@ -45,13 +45,17 @@
45
45
  * | every other key (`schemaVersion`, `version`, `focus_state`, `focus_state:<session>`, `project_meta`, `project`, `parallel_state`, `activeSession`, `reconcile.<task>.release`, any unknown key) | none: the bare value |
46
46
  *
47
47
  * - **Initial collapse**, when the bare table holds at least one carried key:
48
- * every bare key is carried under the rules, and a key only the twin holds
49
- * is a frozen copy and is DROPPED (listed in the receipt, marker and log).
50
- * The exceptions are `task_id_sequence` and `sqlite_snapshot_gate` (a
51
- * counter must not move back) and the collapse keys. `file_meta` is dropped
52
- * only when the bare table lacks it; when both have it the field rule
53
- * applies. When the bare table holds no carried key (a fresh lineage, e.g.
54
- * after exodus landed legacy rows in the twin), nothing is dropped.
48
+ * every bare key is carried under the rules (the bare value wins every key
49
+ * both hold). A key only the twin holds is KEPT, never dropped (the union
50
+ * rule, T12535 audit 2026-09-29): it is listed as `kept` in the receipt and
51
+ * marker. When a key's twin value would be replaced by a different result,
52
+ * the twin value is copied to `twin_collapse_archive:<key>` in
53
+ * `tasks_schema_meta` (listed as `archived`; doctor shows it), so nothing
54
+ * unique is left only in the snapshot. `focus_state` merges instead: the
55
+ * bare value's current fields win and `sessionNotes` is the union of both,
56
+ * deduplicated by `(timestamp, note)` ({@link mergeFocusState}); its twin
57
+ * value is archived as well. The pre-collapse snapshot holds the store
58
+ * before any change and is pinned (never rotated).
55
59
  * - **Incremental re-merge**, per bare key whose hash changed since the last
56
60
  * merge:
57
61
  * - twin unchanged since the last merge → the bare value (counter fields
@@ -68,9 +72,13 @@
68
72
  * `sticky_tags` → `brain_sticky_tags` (rows are `(sticky_id, tag)`, both the
69
73
  * key, so a collision is two identical rows and one is kept):
70
74
  *
71
- * - Initial collapse, when the bare table has rows: the twin is made equal to
72
- * the bare set; frozen twin-only rows are dropped and listed. A bare tag whose
73
- * note no longer exists is not carried (`skipped`; it stays in the bare table).
75
+ * - Initial collapse: bare tags are added. A twin-only tag the note's
76
+ * `tags_json` names is kept (listed as `kept`). One it does not name leaves
77
+ * the junction (which follows `tags_json`, the value every reader displays)
78
+ * and is recorded under `twin_collapse_archive:sticky_tags` in
79
+ * `brain_schema_meta` (listed as `archived`); adding it to `tags_json` would
80
+ * change the tags the user sees. A bare tag whose note no longer exists is
81
+ * not carried (`skipped`; it stays in the bare table).
74
82
  * - Incremental re-merge, per sticky id whose bare tag-set hash changed: if
75
83
  * the twin's set for that id is unchanged since the last merge, the twin
76
84
  * set is made equal to the bare set (additions AND removals propagate);
@@ -82,12 +90,58 @@
82
90
  * tag filter can never disagree with the displayed tags. A conflict is still
83
91
  * recorded for `cleo doctor`.
84
92
  *
93
+ * `attachments` + `attachment_refs` → `docs_attachments` + `docs_attachment_refs`
94
+ * merge as ONE pair, in one transaction (a doc and its refs never diverge).
95
+ * The bare rows are authoritative over the columns both tables share, except
96
+ * `ref_count`, which is derived: after every merge it is recomputed as the
97
+ * number of refs in `docs_attachment_refs`, whoever wrote them (never carried,
98
+ * never max'd). Rows are keyed `d:<id>` and `r:<attachment_id, owner_type,
99
+ * owner_id>` in the marker hashes.
100
+ *
101
+ * - Initial collapse: the twins are made equal to the bare tables, even when
102
+ * the bare tables are empty (the older build never showed a twin-only row);
103
+ * frozen twin-only rows are dropped and listed.
104
+ * - Incremental re-merge, per bare doc whose hash moved (see {@link planDocs}):
105
+ * twin changed too → conflict, the twin wins; bare row gone → the twin row
106
+ * goes unless a ref or a supersedes link still names it (`kept`); the
107
+ * content (sha256) already belongs to another twin row → merged into that
108
+ * row: the bare id is aliased to it (`twin_collapse_alias:attachments`) and
109
+ * its refs follow; the slug already belongs to another twin row → carried
110
+ * under `<slug>-<n>` (`renamed`). Natural keys are checked against the state
111
+ * after the merge, so a merge never fails on them. Per bare ref whose hash
112
+ * moved: added → added (aliased), removed → removed unless this build
113
+ * changed it since.
114
+ * - Change detection: AFTER triggers on the bare tables (`t12535_track_*`)
115
+ * bump `twin_collapse_seq:attachments` on every write, the older build's
116
+ * included. An open compares that counter with the marker's instead of
117
+ * re-hashing every doc; a missing trigger falls back to the full compare.
118
+ * - Union shape: migration `20260929000000_t12535-docs-attachments-union-shape`
119
+ * adds the bare-only `display_alias` column and indexes; the UNIQUE slug and
120
+ * sha256 indexes are dropped and re-created by the merge itself around the
121
+ * row changes. Every merge ends with them in place, or rolls back.
122
+ * - The `supersedes` / `superseded_by` self-FKs are checked at commit
123
+ * (`PRAGMA defer_foreign_keys`), so a chain merges in any row order.
124
+ * - A bare row that violates a twin CHECK (e.g. a non-ISO `created_at`) fails
125
+ * the merge; the docs pair opens degraded (docs writes refused, everything
126
+ * else writable) and `cleo doctor twin-collapse` shows the cause.
127
+ * - Blobs: after each merge every twin doc's blob gets a keep-link (and is
128
+ * restored from it if the older build unlinked it), see `blob-keep.ts`.
129
+ * - Freeze (option (a), D11160): every merge installs BEFORE
130
+ * INSERT/UPDATE/DELETE triggers on the bare docs tables that abort an
131
+ * older build's write with the upgrade message ({@link docsFrozenMessage}).
132
+ * This build never writes them, so there is no bypass. Every open checks the freeze and change
133
+ * triggers and re-installs missing ones (a rebuild or restore drops them);
134
+ * `cleo doctor` reports them missing. The (b) defences above cover the time
135
+ * a freeze is missing.
136
+ *
85
137
  * ## Contract
86
138
  *
87
139
  * 1. **Snapshot first** (initial collapse only, only when the merge changes
88
140
  * the twin). The free space is checked before the `VACUUM INTO`. One
89
141
  * snapshot covers every pair collapsing in this open, registered as a
90
- * `migration` backup (`cleo backup list`, rotation).
142
+ * PINNED `migration` backup: `cleo backup list` shows it, rotation never
143
+ * deletes it, and a snapshot an earlier build wrote unpinned is pinned at
144
+ * the next open (`cleo doctor` lists an unpinned one).
91
145
  * 2. **Atomic.** Each pair merges, verifies and writes its marker in one
92
146
  * `BEGIN IMMEDIATE` transaction, the marker re-read under the write lock.
93
147
  * Any failure rolls back: both tables byte-identical.
@@ -125,9 +179,16 @@
125
179
  * @task T12535
126
180
  */
127
181
  import { createHash } from 'node:crypto';
182
+ import { existsSync } from 'node:fs';
183
+ import { basename, dirname } from 'node:path';
184
+ import { fileURLToPath } from 'node:url';
128
185
  import { ExitCode } from '@cleocode/contracts';
186
+ import { z } from 'zod';
129
187
  import { CleoError } from '../errors.js';
130
188
  import { getLogger } from '../logger.js';
189
+ import { getCleoVersion } from '../scaffold/ensure-config.js';
190
+ import { isPinnedBackup, pinBackup } from './backup-sidecar.js';
191
+ import { blobFileForRow, pinBlob, restoreBlob } from './blob-keep.js';
131
192
  import { planMigrationSnapshot, writeMigrationSnapshot } from './pre-repair-snapshot.js';
132
193
  const log = getLogger('twin-collapse');
133
194
  /** Prefix of the marker key holding a pair's collapse state. */
@@ -151,6 +212,8 @@ export const SCHEMA_META_NEVER_DELETED = new Set([
151
212
  'task_id_sequence',
152
213
  SNAPSHOT_GATE_KEY,
153
214
  ]);
215
+ /** Why a twin-collapse snapshot is pinned (never rotated). */
216
+ const SNAPSHOT_PIN_REASON = 'T12535 twin collapse: the store before the merge (only copy of any row it replaced)';
154
217
  /** Most conflicts kept in the marker (the doctor warning lists them). */
155
218
  const MAX_CONFLICTS = 50;
156
219
  const sha = (value) => createHash('sha256').update(value).digest('hex');
@@ -194,11 +257,23 @@ function readState(db, pair) {
194
257
  dropped: Array.isArray(parsed.dropped) ? parsed.dropped : [],
195
258
  conflicts: Array.isArray(parsed.conflicts) ? parsed.conflicts : [],
196
259
  conflictsAt: typeof parsed.conflictsAt === 'string' ? parsed.conflictsAt : null,
260
+ seq: typeof parsed.seq === 'string' ? parsed.seq : null,
261
+ merged: Array.isArray(parsed.merged) ? parsed.merged : [],
262
+ renamed: Array.isArray(parsed.renamed) ? parsed.renamed : [],
263
+ kept: Array.isArray(parsed.kept) ? parsed.kept : [],
264
+ archived: Array.isArray(parsed.archived) ? parsed.archived : [],
197
265
  };
198
266
  }
199
267
  /** Whether the bare side moved since the stored hashes (or no usable state exists). */
200
268
  function bareChanged(db, pair, state) {
201
- return !state?.hashes || !sameHashes(state.hashes.bare, pair.bareHashes(db));
269
+ if (!state?.hashes)
270
+ return true;
271
+ if (state.seq !== null && pair.changeSeq !== undefined) {
272
+ const seq = pair.changeSeq(db);
273
+ if (seq !== undefined)
274
+ return seq !== state.seq;
275
+ }
276
+ return !sameHashes(state.hashes.bare, pair.bareHashes(db));
202
277
  }
203
278
  /** Hashes of the last merge; a pre-release marker treats the current twin as unchanged. */
204
279
  function lastHashes(db, pair, state) {
@@ -315,6 +390,52 @@ function kvRows(db, table, schema = 'main') {
315
390
  const rows = db.prepare(`SELECT key, value FROM ${schema}.${table}`).all();
316
391
  return new Map(rows.map((r) => [r.key, r.value]));
317
392
  }
393
+ /** Prefix of the key holding a twin value the initial collapse replaced. */
394
+ export const TWIN_COLLAPSE_ARCHIVE_PREFIX = 'twin_collapse_archive:';
395
+ /**
396
+ * `focus_state` when both tables hold it: the bare value's current fields
397
+ * win, and the session-note history is the union of both
398
+ * (`sessionNotes`, deduplicated by `(timestamp, note)`, sorted by timestamp).
399
+ * The twin may hold months of notes the bare copy never saw (178 KB on the
400
+ * cleocode Linux store). A value that is not a JSON object stays the bare one
401
+ * (the replaced twin value is archived either way).
402
+ *
403
+ * @param bare - The bare value.
404
+ * @param twin - The twin value.
405
+ * @returns The merged value.
406
+ * @task T12535
407
+ */
408
+ export function mergeFocusState(bare, twin) {
409
+ const b = parseJson(bare);
410
+ const t = parseJson(twin);
411
+ if (b === null || typeof b !== 'object' || Array.isArray(b))
412
+ return bare;
413
+ const notesOf = (v) => v !== null &&
414
+ typeof v === 'object' &&
415
+ Array.isArray(v.sessionNotes)
416
+ ? v.sessionNotes
417
+ : [];
418
+ const bareNotes = notesOf(b);
419
+ const twinNotes = notesOf(t);
420
+ if (twinNotes.length === 0)
421
+ return bare;
422
+ const keyOf = (n) => {
423
+ const r = (n ?? {});
424
+ return `${String(r.timestamp ?? '')}\u0000${String(r.note ?? JSON.stringify(n))}`;
425
+ };
426
+ const seen = new Set();
427
+ const union = [];
428
+ for (const n of [...twinNotes, ...bareNotes]) {
429
+ const k = keyOf(n);
430
+ if (seen.has(k))
431
+ continue;
432
+ seen.add(k);
433
+ union.push(n);
434
+ }
435
+ const ts = (n) => String((n ?? {}).timestamp ?? '');
436
+ union.sort((x, y) => ts(x).localeCompare(ts(y)));
437
+ return JSON.stringify({ ...b, sessionNotes: union });
438
+ }
318
439
  /** Carried rows of a `schema_meta` table. */
319
440
  function carriedRows(db, table) {
320
441
  const all = kvRows(db, table);
@@ -324,7 +445,17 @@ function carriedRows(db, table) {
324
445
  return all;
325
446
  }
326
447
  const hashMap = (m) => Object.fromEntries([...m].map(([k, v]) => [k, sha(v)]));
327
- const emptyPlan = () => ({ set: new Map(), del: [], dropped: [], conflicts: [], skipped: 0 });
448
+ const emptyPlan = () => ({
449
+ set: new Map(),
450
+ del: [],
451
+ dropped: [],
452
+ conflicts: [],
453
+ skipped: 0,
454
+ merged: [],
455
+ renamed: [],
456
+ kept: [],
457
+ archived: [],
458
+ });
328
459
  function planSchemaMeta(db, state) {
329
460
  const plan = emptyPlan();
330
461
  const bare = carriedRows(db, 'schema_meta');
@@ -336,14 +467,24 @@ function planSchemaMeta(db, state) {
336
467
  if (state === undefined) {
337
468
  if (bare.size === 0)
338
469
  return plan;
339
- for (const [key, value] of bare)
340
- want(key, mergeSchemaMetaValue(key, value, twin.get(key)));
341
- for (const key of [...twin.keys()].sort()) {
342
- if (!bare.has(key) && !SCHEMA_META_NEVER_DELETED.has(key)) {
343
- plan.del.push(key);
344
- plan.dropped.push(key);
470
+ for (const [key, value] of [...bare].sort(([a], [b]) => a.localeCompare(b))) {
471
+ const current = twin.get(key);
472
+ const merged = key === 'focus_state' && current !== undefined
473
+ ? mergeFocusState(value, current)
474
+ : mergeSchemaMetaValue(key, value, current);
475
+ want(key, merged);
476
+ // The twin's own value would be replaced: keep a copy of it, so nothing
477
+ // unique is left only in the snapshot.
478
+ if (current !== undefined && merged !== null && merged !== current) {
479
+ plan.set.set(`${TWIN_COLLAPSE_ARCHIVE_PREFIX}${key}`, current);
480
+ plan.archived.push(key);
345
481
  }
346
482
  }
483
+ // The union rule: a key only the twin holds is carried over, never
484
+ // dropped (listed as kept). The bare value wins every key both hold.
485
+ for (const key of [...twin.keys()].sort())
486
+ if (!bare.has(key))
487
+ plan.kept.push(key);
347
488
  return plan;
348
489
  }
349
490
  const last = lastHashes(db, SCHEMA_META, state);
@@ -467,10 +608,32 @@ function planSticky(db, state) {
467
608
  }
468
609
  };
469
610
  if (state === undefined) {
470
- if (bare.size === 0)
471
- return plan;
472
- for (const id of [...new Set([...bare.keys(), ...twin.keys()])].sort())
473
- mirror(id, true);
611
+ // The union rule: bare tags are added. A twin-only tag the note's
612
+ // tags_json names is kept (listed as kept). One tags_json does not name is
613
+ // listed as archived: the junction follows tags_json (the value both
614
+ // builds write and every reader displays), so it leaves the junction, and
615
+ // its row is recorded under `twin_collapse_archive:sticky_tags`.
616
+ const tagsJson = db.prepare('SELECT tags_json FROM main.brain_sticky_notes WHERE id = ?');
617
+ for (const id of [...new Set([...bare.keys(), ...twin.keys()])].sort()) {
618
+ const b = new Set(bare.get(id) ?? []);
619
+ const t = new Set(twin.get(id) ?? []);
620
+ const alive = b.size === 0 || noteExists.get(id) !== undefined;
621
+ for (const tag of b) {
622
+ if (!alive)
623
+ plan.skipped++;
624
+ else if (!t.has(tag))
625
+ plan.set.set(`${id}\t${tag}`, '');
626
+ }
627
+ const named = new Set(tagsOf(tagsJson.get(id)?.tags_json ?? null));
628
+ for (const tag of t) {
629
+ if (b.has(tag))
630
+ continue;
631
+ if (named.has(tag))
632
+ plan.kept.push(`${id}\t${tag}`);
633
+ else
634
+ plan.archived.push(`${id}\t${tag}`);
635
+ }
636
+ }
474
637
  return plan;
475
638
  }
476
639
  const last = lastHashes(db, STICKY_TAGS, state);
@@ -572,6 +735,12 @@ const STICKY_TAGS = {
572
735
  const lingering = plan.del.filter(has).length;
573
736
  if (missing > 0 || lingering > 0)
574
737
  throw new Error(`sticky_tags collapse did not verify: ${missing} missing, ${lingering} not removed`);
738
+ if (plan.archived.length > 0) {
739
+ const key = `${TWIN_COLLAPSE_ARCHIVE_PREFIX}sticky_tags`;
740
+ const previous = parseJson(readKv(db, 'brain_schema_meta', key) ?? '[]');
741
+ const rows = new Set([...(Array.isArray(previous) ? previous : []), ...plan.archived]);
742
+ writeKv(db, 'brain_schema_meta', key, JSON.stringify([...rows].sort()));
743
+ }
575
744
  const synced = syncStickyJunction(db, 'main');
576
745
  return {
577
746
  inserted: counts.inserted + synced.inserted,
@@ -587,8 +756,669 @@ const STICKY_TAGS = {
587
756
  syncStickyJunction(db, 'temp');
588
757
  },
589
758
  };
759
+ // ── attachments + attachment_refs (docs) ─────────────────────────────────────
760
+ /** Key of the bare→twin id aliases (bare rows merged into a twin row by sha256). */
761
+ export const DOCS_ALIAS_KEY = 'twin_collapse_alias:attachments';
762
+ /** Key the bare-side change triggers bump on every write to the bare docs tables. */
763
+ export const DOCS_CHANGE_SEQ_KEY = 'twin_collapse_seq:attachments';
764
+ /** The bare docs tables (the change triggers watch both). */
765
+ const DOCS_BARE = ['attachments', 'attachment_refs'];
766
+ /** The docs twins (both shadowed in read-only-for-users mode). */
767
+ const DOCS_TWINS = ['docs_attachments', 'docs_attachment_refs'];
768
+ /** Columns recomputed after every merge, never carried (a count of the refs). */
769
+ const DOCS_DERIVED = new Set(['ref_count']);
770
+ /**
771
+ * The UNIQUE indexes the docs code relies on (union shape with the bare table).
772
+ * Dropped and re-created by every merge, inside its transaction.
773
+ */
774
+ const DOCS_UNIQUE_INDEXES = [
775
+ { name: 'uniq_docs_attachments_slug', on: '(slug) WHERE slug IS NOT NULL' },
776
+ { name: 'uniq_docs_attachments_sha256', on: '(sha256)' },
777
+ ];
778
+ /** Column names of a table, in declaration order. */
779
+ function columnsOf(db, schema, table) {
780
+ return db.prepare(`SELECT name FROM pragma_table_info(?, ?)`).all(table, schema).map((c) => c.name);
781
+ }
782
+ const quoteIdent = (name) => `"${name.replace(/"/g, '""')}"`;
783
+ /** Columns both tables hold, in the bare table's order, minus `skip`. */
784
+ function sharedColumns(db, bare, twin, skip = new Set()) {
785
+ const twinCols = new Set(columnsOf(db, 'main', twin));
786
+ return columnsOf(db, 'main', bare).filter((c) => twinCols.has(c) && !skip.has(c));
787
+ }
788
+ /** The carried doc columns (`ref_count` is derived). */
789
+ const docColumns = (db) => sharedColumns(db, 'attachments', 'docs_attachments', DOCS_DERIVED);
790
+ /** The carried ref columns. */
791
+ const refColumns = (db) => sharedColumns(db, 'attachment_refs', 'docs_attachment_refs');
792
+ /** Rows of a table keyed by the JSON of `key` columns (or the bare id for docs). */
793
+ function carriedRowsOf(db, schema, table, columns, key) {
794
+ const rows = db
795
+ .prepare(`SELECT ${columns.map(quoteIdent).join(', ')} FROM ${schema}.${quoteIdent(table)}`)
796
+ .all();
797
+ return new Map(rows.map((r) => [
798
+ key.length === 1 ? String(r[key[0]]) : JSON.stringify(key.map((k) => r[k])),
799
+ { json: JSON.stringify(columns.map((c) => r[c] ?? null)), values: r },
800
+ ]));
801
+ }
802
+ const REF_KEY = ['attachment_id', 'owner_type', 'owner_id'];
803
+ /** Docs (`d:<id>`) and refs (`r:<key>`) of one side. */
804
+ function docsSide(db, side) {
805
+ const [docTable, refTable] = side === 'bare' ? DOCS_BARE : DOCS_TWINS;
806
+ return {
807
+ docs: carriedRowsOf(db, 'main', docTable, docColumns(db), ['id']),
808
+ refs: carriedRowsOf(db, 'main', refTable, refColumns(db), REF_KEY),
809
+ };
810
+ }
811
+ function docsHashes(db, side) {
812
+ const { docs, refs } = docsSide(db, side);
813
+ return Object.fromEntries([
814
+ ...[...docs].map(([k, v]) => [`d:${k}`, sha(v.json)]),
815
+ ...[...refs].map(([k, v]) => [`r:${k}`, sha(v.json)]),
816
+ ]);
817
+ }
818
+ function readAliases(db) {
819
+ const raw = readKv(db, 'tasks_schema_meta', DOCS_ALIAS_KEY);
820
+ if (raw === undefined)
821
+ return {};
822
+ const parsed = parseJson(raw);
823
+ return parsed !== null && typeof parsed === 'object' ? parsed : {};
824
+ }
825
+ /** The change-trigger name for one bare table and operation. */
826
+ const trackTrigger = (table, op) => `t12535_track_${table}_${op.toLowerCase()}`;
827
+ /**
828
+ * AFTER triggers on the bare docs tables that bump {@link DOCS_CHANGE_SEQ_KEY}.
829
+ * They fire for every writer, the older build included, so an open detects an
830
+ * unchanged bare side by reading one row instead of re-hashing every doc.
831
+ */
832
+ function ensureDocsTracking(db) {
833
+ for (const table of DOCS_BARE) {
834
+ for (const op of SHADOW_WRITE_OPS) {
835
+ db.exec(`CREATE TRIGGER IF NOT EXISTS main.${trackTrigger(table, op)} AFTER ${op} ON ${table} BEGIN ` +
836
+ `INSERT OR IGNORE INTO tasks_schema_meta (key, value) VALUES ('${DOCS_CHANGE_SEQ_KEY}', '0'); ` +
837
+ `UPDATE tasks_schema_meta SET value = CAST(value AS INTEGER) + 1 WHERE key = '${DOCS_CHANGE_SEQ_KEY}'; END`);
838
+ }
839
+ }
840
+ }
841
+ /** The freeze-trigger name for one bare table and operation. */
842
+ const freezeTrigger = (table, op) => `t12535_freeze_${table}_${op.toLowerCase()}`;
843
+ /** Every freeze and change trigger the docs pair keeps on the bare tables. */
844
+ const docsGuardTriggers = () => DOCS_BARE.flatMap((t) => SHADOW_WRITE_OPS.flatMap((op) => [freezeTrigger(t, op), trackTrigger(t, op)]));
845
+ /**
846
+ * The message an older build's write to a frozen bare docs table aborts with.
847
+ * It names the release that moved the docs, what fails (docs, changesets, and
848
+ * the IVTR playbook provenance an older build attaches when a playbook run
849
+ * finalises), and the upgrade command.
850
+ *
851
+ * It must never read as contention or corruption to an older build: those
852
+ * retry on `sqlite_busy` / `database is locked` and restore backups on
853
+ * `database disk image is malformed`, so none of those words appear (pinned by
854
+ * a test, see {@link DOCS_FROZEN_FORBIDDEN}).
855
+ */
856
+ export function docsFrozenMessage(version) {
857
+ const needs = version === null ? 'the latest cleo' : `cleo ${version} or newer`;
858
+ return (`CLEO: docs moved to docs_attachments (T12535); this project needs ${needs} ` +
859
+ 'to write docs, changesets and IVTR playbook provenance. ' +
860
+ 'Run: npm i -g @cleocode/cleo@latest');
861
+ }
862
+ /** Key recording the version of the build that first froze this store's bare docs tables. */
863
+ export const DOCS_FROZEN_BY_KEY = 'twin_collapse_frozen_by:attachments';
864
+ /**
865
+ * The version the freeze message names: the first RELEASE build that froze
866
+ * (or later opened) this store, recorded under {@link DOCS_FROZEN_BY_KEY}.
867
+ *
868
+ * Why recorded, not a literal: the release that ships the freeze was not
869
+ * known when the code was written (2026.9.23 shipped without it). Why not the
870
+ * running build's version: a trigger outlives the build that wrote it, and a
871
+ * development build (a worktree, or the main checkout) reports the PREVIOUS
872
+ * release's version, which cannot read the store. So only a published build
873
+ * (running from an installed package, i.e. under `node_modules`) records its
874
+ * version, and never the unreadable `0.0.0` fallback. Until one does, the
875
+ * message names no version and points at `@latest`; the first release build
876
+ * to open the store records itself and the triggers are re-written.
877
+ *
878
+ * @param db - The project `cleo.db` connection.
879
+ * @returns The version to name, or `null` (no release build has frozen or
880
+ * opened this store yet).
881
+ * @task T12535
882
+ */
883
+ export function docsFrozenVersion(db) {
884
+ const recorded = readKv(db, 'tasks_schema_meta', DOCS_FROZEN_BY_KEY);
885
+ if (recorded !== undefined)
886
+ return recorded.replace(/[^0-9A-Za-z.+-]/g, '');
887
+ return releaseBuildVersion();
888
+ }
889
+ /** Test hook: what this process counts as (a published build, and its version). */
890
+ let freezeBuildOverride;
891
+ /**
892
+ * Test only: make this process count as a published build with `version`
893
+ * (or restore the real detection with `undefined`).
894
+ *
895
+ * @param override - The build to pretend to be.
896
+ * @task T12535
897
+ */
898
+ export function setFreezeBuildForTests(override) {
899
+ freezeBuildOverride = override;
900
+ }
901
+ /**
902
+ * This build's version when it is a published release, else `null`: running
903
+ * from an installed package (under `node_modules`) with a real version.
904
+ */
905
+ function releaseBuildVersion() {
906
+ const published = freezeBuildOverride?.published ??
907
+ fileURLToPath(import.meta.url)
908
+ .split(/[\\/]/)
909
+ .includes('node_modules');
910
+ if (!published)
911
+ return null;
912
+ const version = (freezeBuildOverride?.version ?? getCleoVersion()).replace(/[^0-9A-Za-z.+-]/g, '');
913
+ return /^\d+\.\d+\.\d+/.test(version) && version !== '0.0.0' ? version : null;
914
+ }
915
+ /** Phrases the freeze message must never contain (older builds' retry and recovery matchers). */
916
+ export const DOCS_FROZEN_FORBIDDEN = [
917
+ 'sqlite_busy',
918
+ 'database is locked',
919
+ 'database disk image is malformed',
920
+ 'malformed',
921
+ 'corrupt',
922
+ 'not a database',
923
+ ];
924
+ /**
925
+ * BEFORE INSERT/UPDATE/DELETE triggers on the bare docs tables that abort
926
+ * every write with {@link docsFrozenMessage} (option (a), owner decision
927
+ * D11160). SQLite enforces them whatever build writes, so the 2026.9.20 build
928
+ * can no longer write rows this build treats as frozen: no duplicate content
929
+ * or slug, no split ref counts, no stale-read deletes, no ADR number reuse.
930
+ * Its reads keep working (they show the frozen docs).
931
+ *
932
+ * There is no bypass: this build never writes the bare tables. A migration
933
+ * that rebuilds or drops them is not affected (DDL does not fire DML triggers;
934
+ * the triggers go with a renamed or dropped table and are re-installed at the
935
+ * next open, see {@link repairGuards}).
936
+ */
937
+ function ensureDocsFreeze(db) {
938
+ if (readKv(db, 'tasks_schema_meta', DOCS_FROZEN_BY_KEY) === undefined) {
939
+ const version = releaseBuildVersion();
940
+ if (version !== null)
941
+ writeKv(db, 'tasks_schema_meta', DOCS_FROZEN_BY_KEY, version);
942
+ }
943
+ for (const table of DOCS_BARE) {
944
+ for (const op of SHADOW_WRITE_OPS) {
945
+ const name = freezeTrigger(table, op);
946
+ // A trigger with another text (an older message or version) is replaced.
947
+ if (freezeSql(db, name) !== undefined && !freezeUpToDate(db, table, op))
948
+ db.exec(`DROP TRIGGER main.${name}`);
949
+ db.exec(`CREATE TRIGGER IF NOT EXISTS main.${name} BEFORE ${op} ON ${table} ` +
950
+ `BEGIN ${freezeBody(db)} END`);
951
+ }
952
+ }
953
+ }
954
+ /** The freeze trigger's body (it carries the message). */
955
+ const freezeBody = (db) => `SELECT RAISE(ABORT, '${docsFrozenMessage(docsFrozenVersion(db)).replace(/'/g, "''")}');`;
956
+ /** The stored SQL of a trigger, or `undefined` when it does not exist. */
957
+ function freezeSql(db, name) {
958
+ return db
959
+ .prepare("SELECT sql FROM main.sqlite_master WHERE type = 'trigger' AND name = ?")
960
+ .get(name)?.sql;
961
+ }
962
+ /** Whether a freeze trigger exists with the current table, event and message. */
963
+ function freezeUpToDate(db, table, op) {
964
+ const sql = freezeSql(db, freezeTrigger(table, op));
965
+ return sql?.includes(`BEFORE ${op} ON ${table} `) === true && sql.includes(freezeBody(db));
966
+ }
967
+ /** Whether every freeze and change trigger is in place. */
968
+ function docsGuardsIntact(db) {
969
+ const names = docsGuardTriggers();
970
+ const present = db
971
+ .prepare(`SELECT COUNT(*) AS n FROM main.sqlite_master WHERE type = 'trigger' AND name IN (${names.map(() => '?').join(', ')})`)
972
+ .get(...names).n;
973
+ if (present !== names.length)
974
+ return false;
975
+ return DOCS_BARE.every((t) => SHADOW_WRITE_OPS.every((op) => freezeUpToDate(db, t, op)));
976
+ }
977
+ /** Install the freeze and change triggers (idempotent). */
978
+ function ensureDocsGuards(db) {
979
+ ensureDocsFreeze(db);
980
+ ensureDocsTracking(db);
981
+ }
982
+ /** The bare side's change counter, or `undefined` when a change trigger is missing. */
983
+ function docsChangeSeq(db) {
984
+ const names = DOCS_BARE.flatMap((t) => SHADOW_WRITE_OPS.map((op) => trackTrigger(t, op)));
985
+ const present = db
986
+ .prepare(`SELECT COUNT(*) AS n FROM main.sqlite_master WHERE type = 'trigger' AND name IN (${names.map(() => '?').join(', ')})`)
987
+ .get(...names).n;
988
+ if (present !== names.length)
989
+ return undefined;
990
+ return readKv(db, 'tasks_schema_meta', DOCS_CHANGE_SEQ_KEY) ?? '0';
991
+ }
992
+ /** The first `<slug>-<n>` (n ≥ 2) no other twin row holds. */
993
+ function freeSlug(slug, owners, id) {
994
+ for (let n = 2;; n++) {
995
+ const candidate = `${slug}-${n}`;
996
+ const holder = owners.get(candidate);
997
+ if (holder === undefined || holder === id)
998
+ return candidate;
999
+ }
1000
+ }
1001
+ /**
1002
+ * The initial docs collapse: a union that never drops content.
1003
+ *
1004
+ * - A row whose id the bare table has: the bare row is authoritative. When the
1005
+ * twin's row under that id holds DIFFERENT bytes, the twin's version is
1006
+ * kept as its own row `<id>~twin-<sha8>` (no slug, superseded by the bare
1007
+ * row) and listed as merged, so no content leaves the twins (unless those
1008
+ * bytes are already another row's). A metadata-only difference (same sha256)
1009
+ * goes to the bare row.
1010
+ * - A twin-only row whose sha256 a bare row (or an earlier kept twin-only
1011
+ * row) holds, i.e. the same content under another id: merged into that row
1012
+ * (listed as merged), its refs and any supersedes link moved onto it.
1013
+ * - Any other twin-only row: kept under its own id. When a bare row holds its
1014
+ * slug, the bare (live) doc keeps the slug and this one is carried as
1015
+ * `<slug>-<n>` (listed as renamed).
1016
+ * - Refs: every bare ref and every twin ref (remapped onto merged ids) is kept.
1017
+ */
1018
+ function planInitialDocs(db, plan, bare, twin) {
1019
+ const columns = docColumns(db);
1020
+ const refCols = refColumns(db);
1021
+ const idAt = refCols.indexOf('attachment_id');
1022
+ const bareBySha = new Map();
1023
+ const slugOwner = new Map();
1024
+ for (const [id, row] of bare.docs) {
1025
+ bareBySha.set(String(row.values.sha256), id);
1026
+ if (typeof row.values.slug === 'string')
1027
+ slugOwner.set(row.values.slug, id);
1028
+ }
1029
+ for (const [id, row] of bare.docs)
1030
+ if (twin.docs.get(id)?.json !== row.json)
1031
+ plan.set.set(`d:${id}`, row.json);
1032
+ // An id both tables hold with DIFFERENT content: the bare row wins the id,
1033
+ // and the twin's version is kept as its own row (`<id>~twin-<sha8>`,
1034
+ // superseded by the bare row, no slug), so its bytes stay referenced and
1035
+ // visible. Same content (only metadata differs): the bare row wins.
1036
+ for (const [id, row] of bare.docs) {
1037
+ const twinRow = twin.docs.get(id);
1038
+ if (twinRow === undefined)
1039
+ continue;
1040
+ const content = String(twinRow.values.sha256);
1041
+ if (content === String(row.values.sha256))
1042
+ continue;
1043
+ const owner = bareBySha.get(content);
1044
+ if (owner !== undefined) {
1045
+ plan.merged.push(`${id} (twin version) -> ${owner}`);
1046
+ continue;
1047
+ }
1048
+ const archiveId = `${id}~twin-${content.slice(0, 8)}`;
1049
+ const values = {
1050
+ ...twinRow.values,
1051
+ id: archiveId,
1052
+ slug: null,
1053
+ lifecycle_status: 'superseded',
1054
+ superseded_by: id,
1055
+ };
1056
+ plan.set.set(`d:${archiveId}`, JSON.stringify(columns.map((c) => values[c] ?? null)));
1057
+ plan.merged.push(`${id} (twin version) -> ${archiveId}`);
1058
+ bareBySha.set(content, archiveId);
1059
+ }
1060
+ // Twin-only rows: merge same-content duplicates into the bare row, keep the rest.
1061
+ const mergedInto = new Map();
1062
+ const kept = [];
1063
+ for (const id of [...twin.docs.keys()].sort()) {
1064
+ if (bare.docs.has(id))
1065
+ continue;
1066
+ const row = twin.docs.get(id);
1067
+ const content = String(row.values.sha256);
1068
+ // The same content in a bare row, or in a twin-only row kept already
1069
+ // (a frozen twin had no UNIQUE sha256 index).
1070
+ const owner = bareBySha.get(content);
1071
+ if (owner !== undefined) {
1072
+ mergedInto.set(id, owner);
1073
+ plan.del.push(`d:${id}`);
1074
+ plan.merged.push(`${id} -> ${owner}`);
1075
+ }
1076
+ else {
1077
+ bareBySha.set(content, id);
1078
+ kept.push(id);
1079
+ }
1080
+ }
1081
+ for (const id of kept) {
1082
+ const row = twin.docs.get(id);
1083
+ const values = { ...row.values };
1084
+ let changed = false;
1085
+ if (typeof values.slug === 'string') {
1086
+ const holder = slugOwner.get(values.slug);
1087
+ if (holder !== undefined && holder !== id) {
1088
+ const next = freeSlug(values.slug, slugOwner, id);
1089
+ plan.renamed.push(`${id}: ${values.slug} -> ${next}`);
1090
+ values.slug = next;
1091
+ changed = true;
1092
+ }
1093
+ slugOwner.set(values.slug, id);
1094
+ }
1095
+ for (const col of ['supersedes', 'superseded_by']) {
1096
+ const target = values[col];
1097
+ if (typeof target === 'string' && mergedInto.has(target)) {
1098
+ values[col] = mergedInto.get(target);
1099
+ changed = true;
1100
+ }
1101
+ }
1102
+ if (changed)
1103
+ plan.set.set(`d:${id}`, JSON.stringify(columns.map((c) => values[c] ?? null)));
1104
+ }
1105
+ // Refs: the union, twin refs remapped onto merged ids.
1106
+ for (const [key, row] of bare.refs)
1107
+ if (twin.refs.get(key)?.json !== row.json)
1108
+ plan.set.set(`r:${key}`, row.json);
1109
+ for (const [key, row] of twin.refs) {
1110
+ const [attachmentId, ownerType, ownerId] = JSON.parse(key);
1111
+ const target = mergedInto.get(attachmentId);
1112
+ if (target === undefined)
1113
+ continue; // kept as it is
1114
+ plan.del.push(`r:${key}`);
1115
+ const moved = JSON.stringify([target, ownerType, ownerId]);
1116
+ if (bare.refs.has(moved) || plan.set.has(`r:${moved}`))
1117
+ continue;
1118
+ const values = JSON.parse(row.json);
1119
+ values[idAt] = target;
1120
+ plan.set.set(`r:${moved}`, JSON.stringify(values));
1121
+ }
1122
+ return plan;
1123
+ }
1124
+ /**
1125
+ * Plan the docs merge. Keys: `d:<twin id>` (doc rows) and `r:<JSON key>` (refs).
1126
+ *
1127
+ * Initial collapse, a union that never drops content ({@link planInitialDocs}):
1128
+ * bare rows are authoritative for their ids; twin-only rows are kept (on the
1129
+ * live cleocode store they are the whole docs history before 2026-06-03: ADRs,
1130
+ * specs, research, with live task refs), except a twin-only row whose content
1131
+ * a bare row holds, which is merged into that bare row; refs are united.
1132
+ *
1133
+ * Incremental re-merge, per bare doc whose row hash moved since the last merge
1134
+ * (its id translated through the aliases):
1135
+ * - twin row changed too → conflict, the twin wins;
1136
+ * - bare row gone → the twin row is deleted unless a ref (from either build)
1137
+ * or a supersedes link still names it;
1138
+ * - the content (sha256) is another twin row's → no new row: the bare id is
1139
+ * aliased to that row, and its refs follow it;
1140
+ * - the slug is another twin row's → the doc is carried under `<slug>-<n>`
1141
+ * (listed as renamed);
1142
+ * - otherwise the bare row is upserted.
1143
+ * Per bare ref whose hash moved: an added ref is added (under the aliased id)
1144
+ * unless the twin has it or its doc is gone; a removed ref is removed unless
1145
+ * this build changed it since.
1146
+ */
1147
+ function planDocs(db, state) {
1148
+ const plan = emptyPlan();
1149
+ const bare = docsSide(db, 'bare');
1150
+ const twin = docsSide(db, 'twin');
1151
+ if (state === undefined)
1152
+ return planInitialDocs(db, plan, bare, twin);
1153
+ const last = lastHashes(db, DOCS, state);
1154
+ const aliases = { ...readAliases(db) };
1155
+ plan.aliases = aliases;
1156
+ const hashOf = (row) => (row ? sha(row.json) : ABSENT);
1157
+ const columns = docColumns(db);
1158
+ const deletions = [];
1159
+ const candidates = [];
1160
+ const lastBareDocs = Object.keys(last.bare)
1161
+ .filter((k) => k.startsWith('d:'))
1162
+ .map((k) => k.slice(2));
1163
+ for (const b of [...new Set([...bare.docs.keys(), ...lastBareDocs])].sort()) {
1164
+ const bareRow = bare.docs.get(b);
1165
+ if (hashOf(bareRow) === (last.bare[`d:${b}`] ?? ABSENT))
1166
+ continue;
1167
+ const x = aliases[b] ?? b;
1168
+ const twinRow = twin.docs.get(x);
1169
+ if (hashOf(twinRow) !== (last.twin[`d:${x}`] ?? ABSENT)) {
1170
+ plan.conflicts.push(JSON.stringify([b]));
1171
+ continue;
1172
+ }
1173
+ if (bareRow === undefined) {
1174
+ if (twinRow !== undefined)
1175
+ deletions.push(x);
1176
+ }
1177
+ else {
1178
+ candidates.push({ b, x, twinRow });
1179
+ }
1180
+ }
1181
+ // Natural keys are checked against the state AFTER the merge: twin rows the
1182
+ // merge does not touch, plus the carried rows (so two docs swapping a slug
1183
+ // do not collide with each other's old values).
1184
+ const moving = new Set([...deletions, ...candidates.map((c) => c.x)]);
1185
+ const shaOwner = new Map();
1186
+ const slugOwner = new Map();
1187
+ for (const [id, row] of twin.docs) {
1188
+ if (moving.has(id))
1189
+ continue;
1190
+ shaOwner.set(String(row.values.sha256), id);
1191
+ if (typeof row.values.slug === 'string')
1192
+ slugOwner.set(row.values.slug, id);
1193
+ }
1194
+ for (const { b, x, twinRow } of candidates) {
1195
+ const values = { ...(bare.docs.get(b)?.values ?? {}), id: x };
1196
+ const content = String(values.sha256);
1197
+ const holder = shaOwner.get(content);
1198
+ if (holder !== undefined && holder !== x) {
1199
+ if (twinRow === undefined) {
1200
+ aliases[b] = holder;
1201
+ plan.merged.push(`${b} -> ${holder}`);
1202
+ }
1203
+ else {
1204
+ plan.conflicts.push(JSON.stringify([b]));
1205
+ // The twin row stays as it is: keep its keys owned.
1206
+ shaOwner.set(String(twinRow.values.sha256), x);
1207
+ if (typeof twinRow.values.slug === 'string')
1208
+ slugOwner.set(twinRow.values.slug, x);
1209
+ }
1210
+ continue;
1211
+ }
1212
+ if (typeof values.slug === 'string') {
1213
+ const slugHolder = slugOwner.get(values.slug);
1214
+ if (slugHolder !== undefined && slugHolder !== x) {
1215
+ const next = freeSlug(values.slug, slugOwner, x);
1216
+ plan.renamed.push(`${b}: ${values.slug} -> ${next}`);
1217
+ values.slug = next;
1218
+ }
1219
+ }
1220
+ shaOwner.set(content, x);
1221
+ if (typeof values.slug === 'string')
1222
+ slugOwner.set(values.slug, x);
1223
+ const json = JSON.stringify(columns.map((c) => values[c] ?? null));
1224
+ if (twinRow?.json !== json)
1225
+ plan.set.set(`d:${x}`, json);
1226
+ }
1227
+ const refCols = refColumns(db);
1228
+ const idAt = refCols.indexOf('attachment_id');
1229
+ const lastBareRefs = Object.keys(last.bare)
1230
+ .filter((k) => k.startsWith('r:'))
1231
+ .map((k) => k.slice(2));
1232
+ const docGoing = new Set(deletions);
1233
+ const docExists = (id) => (twin.docs.has(id) && !docGoing.has(id)) || plan.set.has(`d:${id}`);
1234
+ for (const key of [...new Set([...bare.refs.keys(), ...lastBareRefs])].sort()) {
1235
+ const bareRef = bare.refs.get(key);
1236
+ if (hashOf(bareRef) === (last.bare[`r:${key}`] ?? ABSENT))
1237
+ continue;
1238
+ const [attachmentId, ownerType, ownerId] = JSON.parse(key);
1239
+ const target = aliases[attachmentId] ?? attachmentId;
1240
+ const twinKey = JSON.stringify([target, ownerType, ownerId]);
1241
+ const twinRef = twin.refs.get(twinKey);
1242
+ if (bareRef !== undefined) {
1243
+ if (twinRef !== undefined)
1244
+ continue; // the twin already holds this ref
1245
+ if (!docExists(target)) {
1246
+ plan.skipped++;
1247
+ continue;
1248
+ }
1249
+ const values = JSON.parse(bareRef.json);
1250
+ values[idAt] = target;
1251
+ plan.set.set(`r:${twinKey}`, JSON.stringify(values));
1252
+ }
1253
+ else if (twinRef !== undefined && hashOf(twinRef) === (last.twin[`r:${twinKey}`] ?? ABSENT)) {
1254
+ plan.del.push(`r:${twinKey}`);
1255
+ }
1256
+ }
1257
+ // A doc row goes only when nothing names it any more.
1258
+ const refsAfter = new Map();
1259
+ for (const [key] of twin.refs) {
1260
+ if (plan.del.includes(`r:${key}`))
1261
+ continue;
1262
+ const id = JSON.parse(key)[0];
1263
+ refsAfter.set(id, (refsAfter.get(id) ?? 0) + 1);
1264
+ }
1265
+ for (const key of plan.set.keys()) {
1266
+ if (!key.startsWith('r:'))
1267
+ continue;
1268
+ const id = JSON.parse(key.slice(2))[0];
1269
+ refsAfter.set(id, (refsAfter.get(id) ?? 0) + 1);
1270
+ }
1271
+ const linked = new Set();
1272
+ for (const [id, row] of twin.docs) {
1273
+ if (docGoing.has(id))
1274
+ continue;
1275
+ for (const col of ['supersedes', 'superseded_by']) {
1276
+ const v = row.values[col];
1277
+ if (typeof v === 'string')
1278
+ linked.add(v);
1279
+ }
1280
+ }
1281
+ for (const id of deletions) {
1282
+ if ((refsAfter.get(id) ?? 0) > 0 || linked.has(id))
1283
+ plan.kept.push(id);
1284
+ else
1285
+ plan.del.push(`d:${id}`);
1286
+ }
1287
+ return plan;
1288
+ }
1289
+ /** Apply a docs plan to `schema` (`main`, or `temp` for the shadows). */
1290
+ function applyDocs(db, schema, plan) {
1291
+ const docCols = docColumns(db);
1292
+ const refCols = refColumns(db);
1293
+ const [docs, refs] = DOCS_TWINS;
1294
+ const before = new Set(db.prepare(`SELECT id FROM ${schema}.${docs}`).all().map((r) => r.id));
1295
+ const refsBefore = new Set(db
1296
+ .prepare(`SELECT attachment_id, owner_type, owner_id FROM ${schema}.${refs}`)
1297
+ .all().map((r) => JSON.stringify(REF_KEY.map((k) => r[k]))));
1298
+ const upsert = (table, cols, key) => db.prepare(`INSERT INTO ${schema}.${table} (${cols.map(quoteIdent).join(', ')}) ` +
1299
+ `VALUES (${cols.map(() => '?').join(', ')}) ` +
1300
+ `ON CONFLICT(${key.map(quoteIdent).join(', ')}) DO UPDATE SET ` +
1301
+ cols
1302
+ .filter((c) => !key.includes(c))
1303
+ .map((c) => `${quoteIdent(c)} = excluded.${quoteIdent(c)}`)
1304
+ .join(', '));
1305
+ const upsertDoc = upsert(docs, docCols, ['id']);
1306
+ const upsertRef = upsert(refs, refCols, REF_KEY);
1307
+ const delDoc = db.prepare(`DELETE FROM ${schema}.${docs} WHERE id = ?`);
1308
+ const delRef = db.prepare(`DELETE FROM ${schema}.${refs} WHERE attachment_id = ? AND owner_type = ? AND owner_id = ?`);
1309
+ let inserted = 0;
1310
+ let replaced = 0;
1311
+ let deleted = 0;
1312
+ const params = (json) => JSON.parse(json);
1313
+ for (const key of plan.del)
1314
+ if (key.startsWith('r:'))
1315
+ deleted += Number(delRef.run(...params(key.slice(2))).changes);
1316
+ for (const key of plan.del)
1317
+ if (key.startsWith('d:'))
1318
+ deleted += Number(delDoc.run(key.slice(2)).changes);
1319
+ for (const [key, json] of plan.set) {
1320
+ if (!key.startsWith('d:'))
1321
+ continue;
1322
+ upsertDoc.run(...params(json));
1323
+ if (before.has(key.slice(2)))
1324
+ replaced++;
1325
+ else
1326
+ inserted++;
1327
+ }
1328
+ for (const [key, json] of plan.set) {
1329
+ if (!key.startsWith('r:'))
1330
+ continue;
1331
+ upsertRef.run(...params(json));
1332
+ if (refsBefore.has(key.slice(2)))
1333
+ replaced++;
1334
+ else
1335
+ inserted++;
1336
+ }
1337
+ // `ref_count` is derived: the number of refs, whoever wrote them.
1338
+ db.exec(`UPDATE ${schema}.${docs} SET ref_count = ` +
1339
+ `(SELECT COUNT(*) FROM ${schema}.${refs} r WHERE r.attachment_id = ${docs}.id) ` +
1340
+ `WHERE ref_count IS NOT (SELECT COUNT(*) FROM ${schema}.${refs} r WHERE r.attachment_id = ${docs}.id)`);
1341
+ return { inserted, replaced, deleted };
1342
+ }
1343
+ /**
1344
+ * `attachments` + `attachment_refs` → `docs_attachments` + `docs_attachment_refs`,
1345
+ * merged as ONE pair in one transaction: a doc and its refs never diverge, and
1346
+ * `ref_count` is recomputed from the merged refs (never carried). See
1347
+ * {@link planDocs} for the rules. Every merge ends with the UNIQUE `slug` and
1348
+ * `sha256` indexes in place and the bare-side change triggers installed; after
1349
+ * the commit, every twin doc's blob is pinned (and restored if an older build
1350
+ * deleted it, see `blob-keep.ts`).
1351
+ */
1352
+ const DOCS = {
1353
+ table: 'attachments',
1354
+ twin: 'docs_attachments',
1355
+ kvTable: 'tasks_schema_meta',
1356
+ tables: [...DOCS_BARE, ...DOCS_TWINS, 'tasks_schema_meta'],
1357
+ shadows: DOCS_TWINS,
1358
+ bareHashes: (db) => docsHashes(db, 'bare'),
1359
+ twinHashes: (db) => docsHashes(db, 'twin'),
1360
+ changeSeq: docsChangeSeq,
1361
+ guardsIntact: docsGuardsIntact,
1362
+ installGuards: ensureDocsGuards,
1363
+ plan: planDocs,
1364
+ apply(db, plan) {
1365
+ // Rows may reference each other (self foreign keys); check at COMMIT.
1366
+ db.exec('PRAGMA defer_foreign_keys = ON');
1367
+ for (const index of DOCS_UNIQUE_INDEXES)
1368
+ db.exec(`DROP INDEX IF EXISTS main.${quoteIdent(index.name)}`);
1369
+ const counts = applyDocs(db, 'main', plan);
1370
+ for (const index of DOCS_UNIQUE_INDEXES)
1371
+ db.exec(`CREATE UNIQUE INDEX main.${quoteIdent(index.name)} ON docs_attachments ${index.on}`);
1372
+ if (plan.aliases !== undefined)
1373
+ writeKv(db, 'tasks_schema_meta', DOCS_ALIAS_KEY, JSON.stringify(plan.aliases));
1374
+ ensureDocsGuards(db);
1375
+ const after = docsSide(db, 'twin');
1376
+ for (const [key, json] of plan.set) {
1377
+ const got = key.startsWith('d:')
1378
+ ? after.docs.get(key.slice(2))?.json
1379
+ : after.refs.get(key.slice(2))?.json;
1380
+ if (got !== json)
1381
+ throw new Error(`attachments collapse did not verify for ${key}`);
1382
+ }
1383
+ for (const key of plan.del) {
1384
+ const present = key.startsWith('d:')
1385
+ ? after.docs.has(key.slice(2))
1386
+ : after.refs.has(key.slice(2));
1387
+ if (present)
1388
+ throw new Error(`attachments collapse did not verify the removal of ${key}`);
1389
+ }
1390
+ return counts;
1391
+ },
1392
+ shadow(db, plan) {
1393
+ const keys = {
1394
+ docs_attachments: ['id'],
1395
+ docs_attachment_refs: REF_KEY,
1396
+ };
1397
+ for (const twin of DOCS_TWINS) {
1398
+ db.exec(`CREATE TEMP TABLE IF NOT EXISTS ${twin} AS SELECT * FROM main.${twin} WHERE 0`);
1399
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS temp.${quoteIdent(`${twin}_shadow_key`)} ` +
1400
+ `ON ${twin} (${(keys[twin] ?? []).map(quoteIdent).join(', ')})`);
1401
+ db.exec(`DELETE FROM temp.${twin}`);
1402
+ db.exec(`INSERT INTO temp.${twin} SELECT * FROM main.${twin}`);
1403
+ }
1404
+ applyDocs(db, 'temp', plan);
1405
+ },
1406
+ afterCommit(db, _plan, dbPath) {
1407
+ const cleoDir = dirname(dbPath);
1408
+ const rows = db
1409
+ .prepare('SELECT sha256, attachment_json FROM main.docs_attachments')
1410
+ .all();
1411
+ for (const row of rows) {
1412
+ const primary = blobFileForRow(cleoDir, row.sha256, row.attachment_json);
1413
+ if (primary === null)
1414
+ continue;
1415
+ restoreBlob(cleoDir, row.sha256, primary);
1416
+ pinBlob(cleoDir, row.sha256, primary);
1417
+ }
1418
+ },
1419
+ };
590
1420
  /** The pairs this build collapses, in order. */
591
- const PAIRS = [SCHEMA_META, STICKY_TAGS];
1421
+ const PAIRS = [SCHEMA_META, STICKY_TAGS, DOCS];
592
1422
  /** Connections serving TEMP shadows because their collapse failed. */
593
1423
  const degraded = new WeakMap();
594
1424
  /**
@@ -603,22 +1433,29 @@ export function twinCollapseFailureOf(db) {
603
1433
  return degraded.get(db);
604
1434
  }
605
1435
  /**
606
- * Refuse a write before its first statement when the store is degraded by a
607
- * failed twin collapse. The write accessors of the collapsed tables
608
- * (`tasks_schema_meta`, and sticky notes and tags) call it at entry, so a
609
- * degraded store never takes a partial write (e.g. a sticky's `tags_json`
610
- * without its tag rows). The shadows' TEMP triggers stay as the backstop.
611
- *
612
- * @param handle - The connection the write would use (a `DatabaseSync`, or a
613
- * drizzle instance's `$client`); anything else is not checked.
614
- * @throws {CleoError} `E_TWIN_COLLAPSE_FAILED` when the connection is degraded.
1436
+ * Refuse a write before its first statement when the collapse of the table it
1437
+ * writes failed. The write accessors of the collapsed tables
1438
+ * (`tasks_schema_meta`, sticky notes and tags, the docs tables) call it at
1439
+ * entry, so a degraded store never takes a partial write (e.g. a sticky's
1440
+ * `tags_json` without its tag rows). Only the failed pairs are blocked: a
1441
+ * docs merge failure leaves task and sticky writes alone. The shadows' TEMP
1442
+ * triggers stay as the backstop.
1443
+ *
1444
+ * @param handle - The connection the write would use: the native
1445
+ * `DatabaseSync`, or a drizzle instance (its `$client` is checked).
1446
+ * `null`/`undefined` (no bound handle) is not checked.
1447
+ * @param table - The bare table whose pair the write belongs to.
1448
+ * @throws {CleoError} `E_TWIN_COLLAPSE_FAILED` when that pair's collapse failed.
615
1449
  * @task T12535
616
1450
  */
617
- export function assertTwinCollapseWritable(handle) {
618
- if (typeof handle !== 'object' || handle === null)
1451
+ export function assertTwinCollapseWritable(handle, table) {
1452
+ if (!handle)
1453
+ return;
1454
+ const native = '$client' in handle ? handle.$client : handle;
1455
+ if (typeof native !== 'object' || native === null)
619
1456
  return;
620
- const failure = degraded.get(handle);
621
- if (failure)
1457
+ const failure = degraded.get(native);
1458
+ if (failure?.tables.includes(table))
622
1459
  throw twinCollapseError(failure);
623
1460
  }
624
1461
  /**
@@ -639,10 +1476,16 @@ export function twinCollapseError(failure, cause) {
639
1476
  ? ` The snapshot needs ${failure.requiredBytes} bytes free` +
640
1477
  (failure.availableBytes === null ? '.' : ` (${failure.availableBytes} free).`)
641
1478
  : '';
1479
+ const snapshotProblem = failure.cause.startsWith(SNAPSHOT_FAILURE_PREFIX);
642
1480
  return new CleoError(ExitCode.TWIN_COLLAPSE_FAILED, `Twin collapse of ${failure.tables.join(', ')} failed (both tables unchanged; reads still ` +
643
- `work, writes are refused): ${failure.cause}.${where}${space} Run 'cleo doctor twin-collapse'.`, {
644
- fix: "Run 'cleo doctor twin-collapse' for details, clear the cause (free the space, make " +
645
- ".cleo/backups/sqlite writable), then 'cleo doctor twin-collapse --retry'.",
1481
+ `work, writes to them are refused): ${failure.cause}.${where}${space} ` +
1482
+ "Run 'cleo doctor twin-collapse'.", {
1483
+ fix: snapshotProblem
1484
+ ? 'The snapshot could not be written: free the space or make .cleo/backups/sqlite ' +
1485
+ "writable, then run 'cleo doctor twin-collapse --retry'."
1486
+ : 'The merge rejected a row of the bare table (the cause names the constraint, e.g. a ' +
1487
+ "CHECK or UNIQUE). Fix or remove that row, then run 'cleo doctor twin-collapse --retry'; " +
1488
+ "'cleo doctor twin-collapse' shows the details.",
646
1489
  details: { field: 'twinCollapse', ...failure },
647
1490
  cause,
648
1491
  });
@@ -659,6 +1502,8 @@ function recordFailure(db, pairs, failure) {
659
1502
  }
660
1503
  }
661
1504
  }
1505
+ /** Cause prefix of a failure to write the snapshot (before any merge). */
1506
+ const SNAPSHOT_FAILURE_PREFIX = 'snapshot not written: ';
662
1507
  /** Message a write into a sealed shadow aborts with. */
663
1508
  export const SHADOW_WRITE_REFUSED = 'E_TWIN_COLLAPSE_FAILED: store is read-only until the twin collapse succeeds; run cleo doctor twin-collapse';
664
1509
  const SHADOW_WRITE_OPS = ['INSERT', 'UPDATE', 'DELETE'];
@@ -684,7 +1529,8 @@ function sealShadow(db, twin) {
684
1529
  /** Drop the TEMP shadows (and their triggers) of a connection that is no longer degraded. */
685
1530
  function clearShadows(db) {
686
1531
  for (const pair of PAIRS)
687
- db.exec(`DROP TABLE IF EXISTS temp.${pair.twin}`);
1532
+ for (const shadow of pair.shadows ?? [pair.twin])
1533
+ db.exec(`DROP TABLE IF EXISTS temp.${shadow}`);
688
1534
  degraded.delete(db);
689
1535
  }
690
1536
  function receipt(pair, status, snapshotPath) {
@@ -699,10 +1545,14 @@ function receipt(pair, status, snapshotPath) {
699
1545
  skipped: 0,
700
1546
  dropped: [],
701
1547
  conflicts: [],
1548
+ merged: [],
1549
+ renamed: [],
1550
+ kept: [],
1551
+ archived: [],
702
1552
  };
703
1553
  }
704
1554
  /** Run one pair's merge in its own transaction. */
705
- function collapsePair(db, pair, snapshotPath) {
1555
+ function collapsePair(db, pair, snapshotPath, dbPath) {
706
1556
  db.exec('BEGIN IMMEDIATE');
707
1557
  try {
708
1558
  // Re-read under the write lock: another process may have merged since.
@@ -724,18 +1574,33 @@ function collapsePair(db, pair, snapshotPath) {
724
1574
  snapshot: state?.snapshot ?? snapshotPath,
725
1575
  hashes: { bare: pair.bareHashes(db), twin: pair.twinHashes(db) },
726
1576
  dropped: state === undefined ? plan.dropped : state.dropped,
1577
+ kept: state === undefined ? plan.kept.slice(0, MAX_CONFLICTS) : state.kept,
1578
+ archived: state === undefined ? plan.archived : state.archived,
727
1579
  conflicts: plan.conflicts.slice(0, MAX_CONFLICTS),
728
1580
  conflictsAt: plan.conflicts.length > 0 ? now : null,
1581
+ seq: pair.changeSeq?.(db) ?? null,
1582
+ merged: plan.merged.slice(0, MAX_CONFLICTS),
1583
+ renamed: plan.renamed.slice(0, MAX_CONFLICTS),
729
1584
  };
730
1585
  writeKv(db, pair.kvTable, `${TWIN_COLLAPSE_MARKER_PREFIX}${pair.table}`, JSON.stringify(next));
731
1586
  db.prepare(`DELETE FROM main.${pair.kvTable} WHERE key = ?`).run(`${TWIN_COLLAPSE_FAILURE_PREFIX}${pair.table}`);
732
1587
  db.exec('COMMIT');
1588
+ try {
1589
+ pair.afterCommit?.(db, plan, dbPath);
1590
+ }
1591
+ catch (error) {
1592
+ log.warn({ err: error }, `after-merge work for ${pair.table} failed (T12535)`);
1593
+ }
733
1594
  const done = {
734
1595
  ...receipt(pair, state === undefined ? 'initial' : 'incremental', next.snapshot),
735
1596
  ...counts,
736
1597
  skipped: plan.skipped,
737
1598
  dropped: plan.dropped,
738
1599
  conflicts: plan.conflicts,
1600
+ merged: plan.merged,
1601
+ renamed: plan.renamed,
1602
+ kept: plan.kept,
1603
+ archived: plan.archived,
739
1604
  };
740
1605
  if (counts.inserted + counts.replaced + counts.deleted > 0 || plan.conflicts.length > 0)
741
1606
  log.warn(done, `carried bare ${pair.table} into ${pair.twin} (${done.status}, T12535)`);
@@ -747,6 +1612,30 @@ function collapsePair(db, pair, snapshotPath) {
747
1612
  throw error;
748
1613
  }
749
1614
  }
1615
+ /**
1616
+ * Re-install a collapsed pair's bare-table triggers when something removed
1617
+ * them (a table rebuild, a restore, a journal probe that skipped a migration:
1618
+ * the T12541 class). One `sqlite_master` read per open; a write only when a
1619
+ * trigger is missing. Best effort: a failure is logged and `cleo doctor`
1620
+ * reports the missing triggers.
1621
+ */
1622
+ function repairGuards(db, pair) {
1623
+ if (!pair.guardsIntact || !pair.installGuards || pair.guardsIntact(db))
1624
+ return;
1625
+ if (db.isTransaction || degraded.has(db))
1626
+ return;
1627
+ try {
1628
+ db.exec('BEGIN IMMEDIATE');
1629
+ pair.installGuards(db);
1630
+ db.exec('COMMIT');
1631
+ log.warn({ table: pair.table }, `re-installed the bare ${pair.table} triggers (T12535)`);
1632
+ }
1633
+ catch (error) {
1634
+ if (db.isTransaction)
1635
+ db.exec('ROLLBACK');
1636
+ log.warn({ err: error, table: pair.table }, `could not re-install the bare triggers (T12535)`);
1637
+ }
1638
+ }
750
1639
  /**
751
1640
  * Collapse every bare/twin pair present in the store: the initial collapse
752
1641
  * (snapshot first) where no marker exists yet, an incremental re-merge of
@@ -774,10 +1663,17 @@ export function collapseTwinTables(nativeDb, dbPath, options = {}) {
774
1663
  continue;
775
1664
  }
776
1665
  const state = readState(nativeDb, pair);
1666
+ // A snapshot an earlier collapse took (a build before pinning) is pinned
1667
+ // now, so rotation never deletes it. Best effort; one sidecar read.
1668
+ if (state?.snapshot)
1669
+ pinBackup(state.snapshot, 'migration', SNAPSHOT_PIN_REASON);
777
1670
  if (bareChanged(nativeDb, pair, state))
778
1671
  pending.push(pair);
779
- else
1672
+ else {
1673
+ if (state !== undefined)
1674
+ repairGuards(nativeDb, pair);
780
1675
  byTable.set(pair.table, receipt(pair, 'unchanged', state?.snapshot ?? null));
1676
+ }
781
1677
  }
782
1678
  if (pending.length === 0) {
783
1679
  if (degraded.has(nativeDb))
@@ -795,9 +1691,11 @@ export function collapseTwinTables(nativeDb, dbPath, options = {}) {
795
1691
  // even that cannot be built, reads cannot be served correctly either.
796
1692
  try {
797
1693
  for (const pair of pairs) {
798
- unsealShadow(nativeDb, pair.twin);
1694
+ for (const shadow of pair.shadows ?? [pair.twin])
1695
+ unsealShadow(nativeDb, shadow);
799
1696
  pair.shadow(nativeDb, pair.plan(nativeDb, readState(nativeDb, pair)));
800
- sealShadow(nativeDb, pair.twin);
1697
+ for (const shadow of pair.shadows ?? [pair.twin])
1698
+ sealShadow(nativeDb, shadow);
801
1699
  }
802
1700
  }
803
1701
  catch (shadowError) {
@@ -818,12 +1716,12 @@ export function collapseTwinTables(nativeDb, dbPath, options = {}) {
818
1716
  if (needSnapshot.length > 0) {
819
1717
  const plan = planMigrationSnapshot(nativeDb, dbPath);
820
1718
  try {
821
- snapshotPath = writeMigrationSnapshot(nativeDb, plan, `T12535 twin collapse of ${needSnapshot.map((p) => p.table).join(', ')} (store before the merge)`);
1719
+ snapshotPath = writeMigrationSnapshot(nativeDb, plan, `T12535 twin collapse of ${needSnapshot.map((p) => p.table).join(', ')} (store before the merge)`, { pinnedReason: SNAPSHOT_PIN_REASON });
822
1720
  }
823
1721
  catch (error) {
824
1722
  fail(needSnapshot, {
825
1723
  tables: needSnapshot.map((p) => p.table),
826
- cause: `snapshot not written: ${error instanceof Error ? error.message : String(error)}`,
1724
+ cause: `${SNAPSHOT_FAILURE_PREFIX}${error instanceof Error ? error.message : String(error)}`,
827
1725
  snapshotPath: plan.snapshotPath,
828
1726
  snapshotWritten: false,
829
1727
  requiredBytes: plan.requiredBytes,
@@ -840,7 +1738,7 @@ export function collapseTwinTables(nativeDb, dbPath, options = {}) {
840
1738
  let firstError;
841
1739
  for (const pair of pending) {
842
1740
  try {
843
- byTable.set(pair.table, collapsePair(nativeDb, pair, snapshotPath));
1741
+ byTable.set(pair.table, collapsePair(nativeDb, pair, snapshotPath, dbPath));
844
1742
  }
845
1743
  catch (error) {
846
1744
  failed.push(pair);
@@ -864,6 +1762,12 @@ export function collapseTwinTables(nativeDb, dbPath, options = {}) {
864
1762
  }
865
1763
  return inOrder();
866
1764
  }
1765
+ /** Whether a collapse snapshot on disk is pinned, or `null` when there is none. */
1766
+ function snapshotPinnedOf(snapshot) {
1767
+ if (!snapshot || !existsSync(snapshot))
1768
+ return null;
1769
+ return isPinnedBackup(dirname(snapshot), basename(snapshot), 'migration');
1770
+ }
867
1771
  /**
868
1772
  * Report every pair's collapse state without writing (for `cleo doctor`).
869
1773
  *
@@ -883,6 +1787,13 @@ export function inspectTwinCollapse(db) {
883
1787
  changedSinceMerge: 0,
884
1788
  conflicts: [],
885
1789
  conflictsAt: null,
1790
+ merged: [],
1791
+ renamed: [],
1792
+ kept: [],
1793
+ archived: [],
1794
+ guardsIntact: null,
1795
+ snapshotPinned: null,
1796
+ snapshotMissing: false,
886
1797
  failure: null,
887
1798
  };
888
1799
  if (!pair.tables.every((t) => hasMainTable(db, t)))
@@ -924,8 +1835,372 @@ export function inspectTwinCollapse(db) {
924
1835
  changedSinceMerge: state === undefined ? 0 : changed,
925
1836
  conflicts: state?.conflicts ?? [],
926
1837
  conflictsAt: state?.conflictsAt ?? null,
1838
+ merged: state?.merged ?? [],
1839
+ renamed: state?.renamed ?? [],
1840
+ kept: state?.kept ?? [],
1841
+ archived: state?.archived ?? [],
1842
+ guardsIntact: state === undefined || pair.guardsIntact === undefined ? null : pair.guardsIntact(db),
1843
+ snapshotPinned: snapshotPinnedOf(state?.snapshot ?? null),
1844
+ snapshotMissing: typeof state?.snapshot === 'string' && !existsSync(state.snapshot),
927
1845
  failure,
928
1846
  };
929
1847
  });
930
1848
  }
1849
+ // ── recovery of what 2026.9.21 dropped or replaced (T12727) ─────────────────
1850
+ /** Prefix of a recovery receipt key: `twin_collapse_recovery:<recoveredAt>`. */
1851
+ export const TWIN_COLLAPSE_RECOVERY_PREFIX = 'twin_collapse_recovery:';
1852
+ /** Error code of a recovery or rollback that cannot proceed; nothing was written. */
1853
+ export const E_TWIN_COLLAPSE_RECOVER = 'E_TWIN_COLLAPSE_RECOVER';
1854
+ const STICKY_ARCHIVE_KEY = `${TWIN_COLLAPSE_ARCHIVE_PREFIX}sticky_tags`;
1855
+ /** A JSON object whose `sessionNotes` is an array (other fields pass through). */
1856
+ const FocusNotesShape = z.object({ sessionNotes: z.array(z.unknown()) }).passthrough();
1857
+ /** The sticky archive: `sticky_id\ttag` rows. */
1858
+ const StickyArchiveShape = z.array(z.string());
1859
+ /** Session notes of a `focus_state` value; `[]` when it has none or is not JSON. */
1860
+ function sessionNotesOf(value) {
1861
+ if (value === undefined)
1862
+ return [];
1863
+ const parsed = FocusNotesShape.safeParse(parseJson(value));
1864
+ return parsed.success ? parsed.data.sessionNotes : [];
1865
+ }
1866
+ /** The `(timestamp, note)` identity {@link mergeFocusState} deduplicates on. */
1867
+ function noteKey(n) {
1868
+ const r = (n !== null && typeof n === 'object' ? n : {});
1869
+ return `${String(r.timestamp ?? '')}\u0000${String(r.note ?? JSON.stringify(n))}`;
1870
+ }
1871
+ const recoveryWrites = new WeakMap();
1872
+ const RecoveryReceiptShape = z.object({
1873
+ task: z.literal('T12727'),
1874
+ id: z.string(),
1875
+ recoveredAt: z.string(),
1876
+ snapshot: z.string().nullable(),
1877
+ archived: z.array(z.object({ key: z.string(), archiveKey: z.string(), sha: z.string() })),
1878
+ focusNotesAdded: z.number(),
1879
+ stickyArchived: z.array(z.string()),
1880
+ stickyArchiveKey: z.string(),
1881
+ before: z.object({ focusState: z.string().nullable(), stickyArchive: z.string().nullable() }),
1882
+ after: z.object({ focusState: z.string().nullable(), stickyArchive: z.string().nullable() }),
1883
+ rolledBackAt: z.string().nullable(),
1884
+ });
1885
+ /** Parsed receipt rows, by key. */
1886
+ function recoveryReceipts(live) {
1887
+ const out = new Map();
1888
+ for (const [key, value] of kvRows(live, 'tasks_schema_meta')) {
1889
+ if (!key.startsWith(TWIN_COLLAPSE_RECOVERY_PREFIX))
1890
+ continue;
1891
+ const parsed = RecoveryReceiptShape.safeParse(parseJson(value));
1892
+ if (parsed.success)
1893
+ out.set(key, parsed.data);
1894
+ }
1895
+ return out;
1896
+ }
1897
+ /** Tag sets of a sticky junction; empty when the table is absent. */
1898
+ function stickySetsIfPresent(db, table) {
1899
+ return hasMainTable(db, table) ? stickySets(db, table) : new Map();
1900
+ }
1901
+ /**
1902
+ * Plan the recovery of the twin values a 2026.9.21 collapse dropped or
1903
+ * replaced, from the pre-collapse snapshot its marker records (T12727).
1904
+ *
1905
+ * The 9.21 initial collapse was bare-authoritative: it deleted keys and
1906
+ * sticky tags only the twin held and overwrote twin values with the bare
1907
+ * ones, keeping them only in that snapshot. This compares the snapshot's
1908
+ * twin and bare tables (read-only; a pair whose table the snapshot lacks is
1909
+ * skipped) and plans, for every twin value that is not live and not archived:
1910
+ * - `schema_meta`: the twin value is written to `twin_collapse_archive:<key>`
1911
+ * (a suffixed key when that name already holds another value, so nothing is
1912
+ * overwritten); for `focus_state` the twin's session notes are also merged
1913
+ * into the live value ({@link mergeFocusState}: live current fields win,
1914
+ * notes deduplicated by `(timestamp, note)` and sorted);
1915
+ * - `sticky_tags`: twin-only tags are added to
1916
+ * `twin_collapse_archive:sticky_tags` in `brain_schema_meta` (a suffixed key
1917
+ * when that row is not a list of tags).
1918
+ * The notes are merged once: not again after an apply that was not rolled
1919
+ * back, nor when the twin value is already archived, so notes pruned after a
1920
+ * recovery stay pruned. A second apply plans nothing.
1921
+ *
1922
+ * @param live - The project `cleo.db` connection (read-only is enough).
1923
+ * @param snapshot - The snapshot, opened read-only, or `null` when missing.
1924
+ * @returns The plan.
1925
+ * @task T12727
1926
+ */
1927
+ export function planTwinCollapseRecovery(live, snapshot) {
1928
+ const state = readState(live, SCHEMA_META);
1929
+ const snapshotPath = state?.snapshot ?? null;
1930
+ const liveKv = kvRows(live, 'tasks_schema_meta');
1931
+ const receipts = [...recoveryReceipts(live)]
1932
+ .filter(([, r]) => r.rolledBackAt === null)
1933
+ .map(([k]) => k)
1934
+ .sort();
1935
+ const empty = {
1936
+ snapshot: snapshotPath,
1937
+ snapshotExists: snapshot !== null,
1938
+ archive: [],
1939
+ focusNotesAdded: 0,
1940
+ stickyArchived: [],
1941
+ stickyArchiveKey: STICKY_ARCHIVE_KEY,
1942
+ receipts,
1943
+ changes: false,
1944
+ };
1945
+ if (snapshot === null)
1946
+ return empty;
1947
+ // A pair whose bare or twin table the snapshot lacks had nothing to collapse.
1948
+ const kvPair = hasMainTable(snapshot, 'schema_meta') && hasMainTable(snapshot, 'tasks_schema_meta');
1949
+ const sBare = kvPair ? carriedRows(snapshot, 'schema_meta') : new Map();
1950
+ const sTwin = kvPair ? carriedRows(snapshot, 'tasks_schema_meta') : new Map();
1951
+ const kv = new Map();
1952
+ const archive = [];
1953
+ const archivedValues = new Set([...liveKv].filter(([k]) => k.startsWith(TWIN_COLLAPSE_ARCHIVE_PREFIX)).map(([, v]) => v));
1954
+ const focusBefore = liveKv.get('focus_state') ?? null;
1955
+ let focusState = null;
1956
+ let focusNotesAdded = 0;
1957
+ for (const [key, twinValue] of [...sTwin].sort(([a], [b]) => a.localeCompare(b))) {
1958
+ if (sBare.get(key) === twinValue)
1959
+ continue; // not replaced
1960
+ const reason = sBare.has(key) ? 'replaced' : 'dropped';
1961
+ const done = liveKv.get(key) === twinValue || archivedValues.has(twinValue);
1962
+ if (key === 'focus_state' && focusBefore !== null && !done && receipts.length === 0) {
1963
+ const merged = mergeFocusState(focusBefore, twinValue);
1964
+ if (merged !== focusBefore) {
1965
+ focusNotesAdded = sessionNotesOf(merged).length - sessionNotesOf(focusBefore).length;
1966
+ focusState = merged;
1967
+ }
1968
+ }
1969
+ if (done)
1970
+ continue;
1971
+ let archiveKey = `${TWIN_COLLAPSE_ARCHIVE_PREFIX}${key}`;
1972
+ if (liveKv.has(archiveKey))
1973
+ archiveKey = `${archiveKey}:${sha(twinValue).slice(0, 8)}`;
1974
+ kv.set(archiveKey, twinValue);
1975
+ archive.push({ key, archiveKey, reason, length: twinValue.length });
1976
+ }
1977
+ // Sticky tags only the twin junction held in the snapshot.
1978
+ const stickyPair = hasMainTable(snapshot, 'sticky_tags') && hasMainTable(snapshot, 'brain_sticky_tags');
1979
+ const sb = stickyPair ? stickySets(snapshot, 'sticky_tags') : new Map();
1980
+ const st = stickyPair ? stickySets(snapshot, 'brain_sticky_tags') : new Map();
1981
+ const liveTags = stickySetsIfPresent(live, 'brain_sticky_tags');
1982
+ const stickyRaw = hasMainTable(live, 'brain_schema_meta')
1983
+ ? readKv(live, 'brain_schema_meta', STICKY_ARCHIVE_KEY)
1984
+ : undefined;
1985
+ const stickyParsed = StickyArchiveShape.safeParse(parseJson(stickyRaw ?? '[]'));
1986
+ // A row that is not a list of tags is someone's data: never overwrite it.
1987
+ const stickyArchiveKey = stickyParsed.success
1988
+ ? STICKY_ARCHIVE_KEY
1989
+ : `${STICKY_ARCHIVE_KEY}:${sha(stickyRaw ?? '').slice(0, 8)}`;
1990
+ const previousRaw = stickyParsed.success
1991
+ ? stickyRaw
1992
+ : readKv(live, 'brain_schema_meta', stickyArchiveKey);
1993
+ const previous = StickyArchiveShape.safeParse(parseJson(previousRaw ?? '[]'));
1994
+ const archivedTags = new Set(previous.success ? previous.data : []);
1995
+ const stickyArchived = [];
1996
+ for (const [id, tags] of st) {
1997
+ const bare = new Set(sb.get(id) ?? []);
1998
+ const now = new Set(liveTags.get(id) ?? []);
1999
+ for (const tag of tags) {
2000
+ const row = `${id}\t${tag}`;
2001
+ if (bare.has(tag) || now.has(tag) || archivedTags.has(row))
2002
+ continue;
2003
+ stickyArchived.push(row);
2004
+ }
2005
+ }
2006
+ stickyArchived.sort();
2007
+ const stickyArchive = stickyArchived.length > 0
2008
+ ? JSON.stringify([...new Set([...archivedTags, ...stickyArchived])].sort())
2009
+ : null;
2010
+ const plan = {
2011
+ ...empty,
2012
+ archive,
2013
+ focusNotesAdded,
2014
+ stickyArchived,
2015
+ stickyArchiveKey,
2016
+ changes: kv.size > 0 || focusState !== null || stickyArchive !== null,
2017
+ };
2018
+ recoveryWrites.set(plan, {
2019
+ snapshot,
2020
+ kv,
2021
+ focusBefore,
2022
+ focusState,
2023
+ stickyBefore: previousRaw ?? null,
2024
+ stickyArchive,
2025
+ });
2026
+ return plan;
2027
+ }
2028
+ /**
2029
+ * Apply a recovery in one `BEGIN IMMEDIATE` transaction on the live store.
2030
+ *
2031
+ * The plan is recomputed INSIDE the transaction from the live values read
2032
+ * under the write lock, so a note written after `plan` was built is merged,
2033
+ * never lost; `plan` is the preview the caller showed. Writes the archive
2034
+ * keys, the merged `focus_state`, the sticky archive, and a new receipt.
2035
+ * Nothing is written when there is nothing to recover. Never touches the
2036
+ * snapshot.
2037
+ *
2038
+ * @param live - The project `cleo.db` connection, outside a transaction.
2039
+ * @param plan - From {@link planTwinCollapseRecovery} on the same connection.
2040
+ * @returns The plan applied and its receipt (`null` when nothing was written).
2041
+ * @throws {CleoError} {@link E_TWIN_COLLAPSE_RECOVER} when the recorded
2042
+ * snapshot is missing (nothing is written).
2043
+ * @task T12727
2044
+ */
2045
+ export function applyTwinCollapseRecovery(live, plan) {
2046
+ if (plan.snapshot === null)
2047
+ return { plan, receipt: null };
2048
+ const preview = recoveryWrites.get(plan);
2049
+ if (!plan.snapshotExists || preview === undefined)
2050
+ throw missingSnapshot(plan.snapshot);
2051
+ live.exec('BEGIN IMMEDIATE');
2052
+ try {
2053
+ const fresh = planTwinCollapseRecovery(live, preview.snapshot);
2054
+ const writes = recoveryWrites.get(fresh);
2055
+ if (!fresh.changes || writes === undefined) {
2056
+ live.exec('ROLLBACK');
2057
+ return { plan: fresh, receipt: null };
2058
+ }
2059
+ const recoveredAt = new Date().toISOString();
2060
+ let id = `${TWIN_COLLAPSE_RECOVERY_PREFIX}${recoveredAt}`;
2061
+ for (let n = 2; readKv(live, 'tasks_schema_meta', id) !== undefined; n++)
2062
+ id = `${TWIN_COLLAPSE_RECOVERY_PREFIX}${recoveredAt}:${n}`;
2063
+ const receipt = {
2064
+ task: 'T12727',
2065
+ id,
2066
+ recoveredAt,
2067
+ snapshot: fresh.snapshot,
2068
+ archived: fresh.archive.map((a) => ({
2069
+ key: a.key,
2070
+ archiveKey: a.archiveKey,
2071
+ sha: sha(writes.kv.get(a.archiveKey) ?? ''),
2072
+ })),
2073
+ focusNotesAdded: fresh.focusNotesAdded,
2074
+ stickyArchived: [...fresh.stickyArchived],
2075
+ stickyArchiveKey: fresh.stickyArchiveKey,
2076
+ before: {
2077
+ focusState: writes.focusState === null ? null : writes.focusBefore,
2078
+ stickyArchive: writes.stickyArchive === null ? null : writes.stickyBefore,
2079
+ },
2080
+ after: {
2081
+ focusState: writes.focusState === null ? null : sha(writes.focusState),
2082
+ stickyArchive: writes.stickyArchive === null ? null : sha(writes.stickyArchive),
2083
+ },
2084
+ rolledBackAt: null,
2085
+ };
2086
+ for (const [key, value] of writes.kv)
2087
+ writeKv(live, 'tasks_schema_meta', key, value);
2088
+ if (writes.focusState !== null)
2089
+ writeKv(live, 'tasks_schema_meta', 'focus_state', writes.focusState);
2090
+ if (writes.stickyArchive !== null)
2091
+ writeKv(live, 'brain_schema_meta', fresh.stickyArchiveKey, writes.stickyArchive);
2092
+ writeKv(live, 'tasks_schema_meta', id, JSON.stringify(receipt));
2093
+ live.exec('COMMIT');
2094
+ return { plan: fresh, receipt };
2095
+ }
2096
+ catch (error) {
2097
+ if (live.isTransaction)
2098
+ live.exec('ROLLBACK');
2099
+ throw error;
2100
+ }
2101
+ }
2102
+ /** The error for a recorded snapshot that is not on disk. */
2103
+ export function missingSnapshot(path) {
2104
+ return new CleoError(ExitCode.NOT_FOUND, `${E_TWIN_COLLAPSE_RECOVER}: the pre-collapse snapshot ${path ?? '(none recorded)'} is missing; nothing was recovered`, {
2105
+ fix: 'Restore that file from a backup of .cleo/backups/sqlite/ and re-run; without it there is nothing to recover from.',
2106
+ details: { field: 'snapshot', actual: path },
2107
+ });
2108
+ }
2109
+ /**
2110
+ * Undo one recovery apply (`cleo doctor twin-collapse --rollback <id>`), in
2111
+ * one `BEGIN IMMEDIATE` transaction:
2112
+ * - archive keys it wrote are deleted when they still hold that value;
2113
+ * - `focus_state` is restored to its pre-merge value when unchanged since,
2114
+ * else only the notes the recovery added are removed (notes written since
2115
+ * stay);
2116
+ * - the sticky archive is restored when unchanged since, else only the rows
2117
+ * the recovery added are removed;
2118
+ * - the receipt is kept, stamped `rolledBackAt`.
2119
+ *
2120
+ * @param live - The project `cleo.db` connection, outside a transaction.
2121
+ * @param id - The receipt key (`twin_collapse_recovery:<recoveredAt>`).
2122
+ * @returns What was undone.
2123
+ * @throws {CleoError} {@link E_TWIN_COLLAPSE_RECOVER} when the receipt is
2124
+ * unknown or already rolled back (nothing is written).
2125
+ * @task T12727
2126
+ */
2127
+ export function rollbackTwinCollapseRecovery(live, id) {
2128
+ live.exec('BEGIN IMMEDIATE');
2129
+ try {
2130
+ const receipt = recoveryReceipts(live).get(id);
2131
+ if (receipt === undefined || receipt.rolledBackAt !== null)
2132
+ throw new CleoError(ExitCode.NOT_FOUND, `${E_TWIN_COLLAPSE_RECOVER}: ${receipt === undefined ? 'no recovery receipt' : 'already rolled back:'} ${id}; nothing was changed`, {
2133
+ fix: 'cleo doctor twin-collapse --recover --dry-run # lists the receipts not rolled back',
2134
+ });
2135
+ const archiveDeleted = [];
2136
+ const archiveKept = [];
2137
+ let addedNotes = new Set();
2138
+ for (const a of receipt.archived) {
2139
+ const value = readKv(live, 'tasks_schema_meta', a.archiveKey);
2140
+ if (value === undefined || sha(value) !== a.sha) {
2141
+ archiveKept.push(a.archiveKey);
2142
+ continue;
2143
+ }
2144
+ if (a.key === 'focus_state') {
2145
+ const before = new Set(sessionNotesOf(receipt.before.focusState ?? undefined).map(noteKey));
2146
+ addedNotes = new Set(sessionNotesOf(value)
2147
+ .map(noteKey)
2148
+ .filter((k) => !before.has(k)));
2149
+ }
2150
+ live.prepare('DELETE FROM main.tasks_schema_meta WHERE key = ?').run(a.archiveKey);
2151
+ archiveDeleted.push(a.archiveKey);
2152
+ }
2153
+ let focusState = 'none';
2154
+ let notesRemoved = 0;
2155
+ if (receipt.after.focusState !== null && receipt.before.focusState !== null) {
2156
+ const current = readKv(live, 'tasks_schema_meta', 'focus_state');
2157
+ if (current !== undefined && sha(current) === receipt.after.focusState) {
2158
+ notesRemoved =
2159
+ sessionNotesOf(current).length - sessionNotesOf(receipt.before.focusState).length;
2160
+ writeKv(live, 'tasks_schema_meta', 'focus_state', receipt.before.focusState);
2161
+ focusState = 'restored';
2162
+ }
2163
+ else if (current !== undefined) {
2164
+ const parsed = FocusNotesShape.safeParse(parseJson(current));
2165
+ if (parsed.success) {
2166
+ const kept = parsed.data.sessionNotes.filter((n) => !addedNotes.has(noteKey(n)));
2167
+ notesRemoved = parsed.data.sessionNotes.length - kept.length;
2168
+ if (notesRemoved > 0)
2169
+ writeKv(live, 'tasks_schema_meta', 'focus_state', JSON.stringify({ ...parsed.data, sessionNotes: kept }));
2170
+ focusState = 'notes-removed';
2171
+ }
2172
+ }
2173
+ }
2174
+ let stickyRemoved = 0;
2175
+ if (receipt.after.stickyArchive !== null) {
2176
+ const current = readKv(live, 'brain_schema_meta', receipt.stickyArchiveKey);
2177
+ if (current !== undefined && sha(current) === receipt.after.stickyArchive) {
2178
+ stickyRemoved = receipt.stickyArchived.length;
2179
+ if (receipt.before.stickyArchive === null)
2180
+ live
2181
+ .prepare('DELETE FROM main.brain_schema_meta WHERE key = ?')
2182
+ .run(receipt.stickyArchiveKey);
2183
+ else
2184
+ writeKv(live, 'brain_schema_meta', receipt.stickyArchiveKey, receipt.before.stickyArchive);
2185
+ }
2186
+ else if (current !== undefined) {
2187
+ const parsed = StickyArchiveShape.safeParse(parseJson(current));
2188
+ if (parsed.success) {
2189
+ const added = new Set(receipt.stickyArchived);
2190
+ const kept = parsed.data.filter((r) => !added.has(r));
2191
+ stickyRemoved = parsed.data.length - kept.length;
2192
+ writeKv(live, 'brain_schema_meta', receipt.stickyArchiveKey, JSON.stringify(kept));
2193
+ }
2194
+ }
2195
+ }
2196
+ writeKv(live, 'tasks_schema_meta', id, JSON.stringify({ ...receipt, rolledBackAt: new Date().toISOString() }));
2197
+ live.exec('COMMIT');
2198
+ return { id, archiveDeleted, archiveKept, focusState, notesRemoved, stickyRemoved };
2199
+ }
2200
+ catch (error) {
2201
+ if (live.isTransaction)
2202
+ live.exec('ROLLBACK');
2203
+ throw error;
2204
+ }
2205
+ }
931
2206
  //# sourceMappingURL=twin-collapse.js.map