@cleocode/core 2026.5.133 → 2026.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (560) hide show
  1. package/binaries/README.md +49 -27
  2. package/dist/agents/{agent-registry.d.ts → agent-capacity-tracker.d.ts} +2 -2
  3. package/dist/agents/agent-capacity-tracker.d.ts.map +1 -0
  4. package/dist/agents/{agent-registry.js → agent-capacity-tracker.js} +2 -2
  5. package/dist/agents/agent-capacity-tracker.js.map +1 -0
  6. package/dist/agents/index.d.ts +1 -1
  7. package/dist/agents/index.d.ts.map +1 -1
  8. package/dist/agents/index.js +4 -2
  9. package/dist/agents/index.js.map +1 -1
  10. package/dist/agents/seed-install.d.ts +1 -1
  11. package/dist/agents/seed-install.d.ts.map +1 -1
  12. package/dist/agents/seed-install.js +42 -36
  13. package/dist/agents/seed-install.js.map +1 -1
  14. package/dist/caamp-export.d.ts +18 -0
  15. package/dist/caamp-export.d.ts.map +1 -0
  16. package/dist/caamp-export.js +18 -0
  17. package/dist/caamp-export.js.map +1 -0
  18. package/dist/conduit/local-transport.d.ts +1 -1
  19. package/dist/conduit/local-transport.d.ts.map +1 -1
  20. package/dist/conduit/local-transport.js +69 -43
  21. package/dist/conduit/local-transport.js.map +1 -1
  22. package/dist/dispatch/mutate-projection.d.ts.map +1 -1
  23. package/dist/dispatch/mutate-projection.js +11 -0
  24. package/dist/dispatch/mutate-projection.js.map +1 -1
  25. package/dist/docs/docs-read-model.d.ts +7 -0
  26. package/dist/docs/docs-read-model.d.ts.map +1 -1
  27. package/dist/docs/docs-read-model.js +5 -0
  28. package/dist/docs/docs-read-model.js.map +1 -1
  29. package/dist/docs/supersede.d.ts.map +1 -1
  30. package/dist/docs/supersede.js +12 -7
  31. package/dist/docs/supersede.js.map +1 -1
  32. package/dist/doctor/db-substrate.d.ts.map +1 -1
  33. package/dist/doctor/db-substrate.js +10 -9
  34. package/dist/doctor/db-substrate.js.map +1 -1
  35. package/dist/git-shim-export.d.ts +18 -0
  36. package/dist/git-shim-export.d.ts.map +1 -0
  37. package/dist/git-shim-export.js +18 -0
  38. package/dist/git-shim-export.js.map +1 -0
  39. package/dist/hooks/payload-schemas.d.ts +2 -2
  40. package/dist/init.d.ts.map +1 -1
  41. package/dist/init.js +39 -32
  42. package/dist/init.js.map +1 -1
  43. package/dist/internal.d.ts +11 -3
  44. package/dist/internal.d.ts.map +1 -1
  45. package/dist/internal.js +14 -5
  46. package/dist/internal.js.map +1 -1
  47. package/dist/lafs-export.d.ts +18 -0
  48. package/dist/lafs-export.d.ts.map +1 -0
  49. package/dist/lafs-export.js +18 -0
  50. package/dist/lafs-export.js.map +1 -0
  51. package/dist/lifecycle/effective-stage.js +1 -1
  52. package/dist/lifecycle/index.js +1 -1
  53. package/dist/lifecycle/index.js.map +1 -1
  54. package/dist/lifecycle/rollup.js +1 -1
  55. package/dist/llm/credential-pool.d.ts +17 -0
  56. package/dist/llm/credential-pool.d.ts.map +1 -1
  57. package/dist/llm/credential-pool.js +40 -1
  58. package/dist/llm/credential-pool.js.map +1 -1
  59. package/dist/llm/plugin-facade.d.ts.map +1 -1
  60. package/dist/llm/plugin-facade.js +11 -19
  61. package/dist/llm/plugin-facade.js.map +1 -1
  62. package/dist/llm/role-executor.d.ts +8 -0
  63. package/dist/llm/role-executor.d.ts.map +1 -1
  64. package/dist/llm/role-executor.js +96 -4
  65. package/dist/llm/role-executor.js.map +1 -1
  66. package/dist/llm/role-resolver.d.ts.map +1 -1
  67. package/dist/llm/role-resolver.js +56 -1
  68. package/dist/llm/role-resolver.js.map +1 -1
  69. package/dist/llm/transports/codex-oauth-headers.d.ts +51 -0
  70. package/dist/llm/transports/codex-oauth-headers.d.ts.map +1 -0
  71. package/dist/llm/transports/codex-oauth-headers.js +89 -0
  72. package/dist/llm/transports/codex-oauth-headers.js.map +1 -0
  73. package/dist/memory/claude-mem-migration.d.ts.map +1 -1
  74. package/dist/memory/claude-mem-migration.js +1 -3
  75. package/dist/memory/claude-mem-migration.js.map +1 -1
  76. package/dist/memory/decisions.d.ts.map +1 -1
  77. package/dist/memory/decisions.js +77 -23
  78. package/dist/memory/decisions.js.map +1 -1
  79. package/dist/memory/graph-memory-bridge.d.ts.map +1 -1
  80. package/dist/memory/graph-memory-bridge.js +12 -6
  81. package/dist/memory/graph-memory-bridge.js.map +1 -1
  82. package/dist/memory/learnings.d.ts +2 -2
  83. package/dist/memory/nexus-plasticity.d.ts +21 -9
  84. package/dist/memory/nexus-plasticity.d.ts.map +1 -1
  85. package/dist/memory/nexus-plasticity.js +44 -22
  86. package/dist/memory/nexus-plasticity.js.map +1 -1
  87. package/dist/memory/patterns.d.ts +2 -2
  88. package/dist/memory/redaction.d.ts +19 -3
  89. package/dist/memory/redaction.d.ts.map +1 -1
  90. package/dist/memory/redaction.js +22 -94
  91. package/dist/memory/redaction.js.map +1 -1
  92. package/dist/metrics/token-service.d.ts +8 -2
  93. package/dist/metrics/token-service.d.ts.map +1 -1
  94. package/dist/metrics/token-service.js +1 -1
  95. package/dist/metrics/token-service.js.map +1 -1
  96. package/dist/nexus/analyze-orchestrator.d.ts.map +1 -1
  97. package/dist/nexus/analyze-orchestrator.js +6 -8
  98. package/dist/nexus/analyze-orchestrator.js.map +1 -1
  99. package/dist/nexus/api-extractors/http-extractor.d.ts.map +1 -1
  100. package/dist/nexus/api-extractors/http-extractor.js +3 -3
  101. package/dist/nexus/api-extractors/http-extractor.js.map +1 -1
  102. package/dist/nexus/clusters.d.ts.map +1 -1
  103. package/dist/nexus/clusters.js +3 -2
  104. package/dist/nexus/clusters.js.map +1 -1
  105. package/dist/nexus/context.d.ts.map +1 -1
  106. package/dist/nexus/context.js +10 -16
  107. package/dist/nexus/context.js.map +1 -1
  108. package/dist/nexus/diff.d.ts.map +1 -1
  109. package/dist/nexus/diff.js +6 -4
  110. package/dist/nexus/diff.js.map +1 -1
  111. package/dist/nexus/export.d.ts.map +1 -1
  112. package/dist/nexus/export.js +7 -4
  113. package/dist/nexus/export.js.map +1 -1
  114. package/dist/nexus/flows.d.ts.map +1 -1
  115. package/dist/nexus/flows.js +3 -1
  116. package/dist/nexus/flows.js.map +1 -1
  117. package/dist/nexus/impact.d.ts +1 -1
  118. package/dist/nexus/impact.d.ts.map +1 -1
  119. package/dist/nexus/impact.js +31 -17
  120. package/dist/nexus/impact.js.map +1 -1
  121. package/dist/nexus/living-brain.d.ts.map +1 -1
  122. package/dist/nexus/living-brain.js +27 -15
  123. package/dist/nexus/living-brain.js.map +1 -1
  124. package/dist/nexus/nexus-bridge.d.ts.map +1 -1
  125. package/dist/nexus/nexus-bridge.js +28 -29
  126. package/dist/nexus/nexus-bridge.js.map +1 -1
  127. package/dist/nexus/plasticity-queries.d.ts +4 -2
  128. package/dist/nexus/plasticity-queries.d.ts.map +1 -1
  129. package/dist/nexus/plasticity-queries.js +27 -15
  130. package/dist/nexus/plasticity-queries.js.map +1 -1
  131. package/dist/nexus/query.d.ts.map +1 -1
  132. package/dist/nexus/query.js +6 -2
  133. package/dist/nexus/query.js.map +1 -1
  134. package/dist/nexus/registry.d.ts.map +1 -1
  135. package/dist/nexus/registry.js +65 -30
  136. package/dist/nexus/registry.js.map +1 -1
  137. package/dist/nexus/route-analysis.d.ts +3 -2
  138. package/dist/nexus/route-analysis.d.ts.map +1 -1
  139. package/dist/nexus/route-analysis.js +11 -10
  140. package/dist/nexus/route-analysis.js.map +1 -1
  141. package/dist/nexus/sigil.d.ts.map +1 -1
  142. package/dist/nexus/sigil.js +60 -13
  143. package/dist/nexus/sigil.js.map +1 -1
  144. package/dist/nexus/user-profile.d.ts +2 -1
  145. package/dist/nexus/user-profile.d.ts.map +1 -1
  146. package/dist/nexus/user-profile.js +8 -4
  147. package/dist/nexus/user-profile.js.map +1 -1
  148. package/dist/orchestrate/index.d.ts +1 -1
  149. package/dist/orchestrate/index.d.ts.map +1 -1
  150. package/dist/orchestrate/index.js +1 -1
  151. package/dist/orchestrate/index.js.map +1 -1
  152. package/dist/orchestrate/plan.d.ts +3 -3
  153. package/dist/orchestrate/plan.d.ts.map +1 -1
  154. package/dist/orchestrate/plan.js +7 -7
  155. package/dist/orchestrate/plan.js.map +1 -1
  156. package/dist/orchestrate/spawn-ops.js +2 -2
  157. package/dist/orchestrate/spawn-ops.js.map +1 -1
  158. package/dist/orchestration/classify.d.ts +2 -2
  159. package/dist/orchestration/classify.js +3 -3
  160. package/dist/orchestration/classify.js.map +1 -1
  161. package/dist/orchestration/validate-spawn.d.ts.map +1 -1
  162. package/dist/orchestration/validate-spawn.js +5 -5
  163. package/dist/orchestration/validate-spawn.js.map +1 -1
  164. package/dist/paths-export.d.ts +18 -0
  165. package/dist/paths-export.d.ts.map +1 -0
  166. package/dist/paths-export.js +18 -0
  167. package/dist/paths-export.js.map +1 -0
  168. package/dist/paths.d.ts.map +1 -1
  169. package/dist/paths.js +24 -11
  170. package/dist/paths.js.map +1 -1
  171. package/dist/playbooks/index.d.ts +1 -0
  172. package/dist/playbooks/index.d.ts.map +1 -1
  173. package/dist/playbooks/index.js +4 -0
  174. package/dist/playbooks/index.js.map +1 -1
  175. package/dist/playbooks/skill-node-executor.d.ts +155 -0
  176. package/dist/playbooks/skill-node-executor.d.ts.map +1 -0
  177. package/dist/playbooks/skill-node-executor.js +156 -0
  178. package/dist/playbooks/skill-node-executor.js.map +1 -0
  179. package/dist/repair.d.ts +3 -7
  180. package/dist/repair.d.ts.map +1 -1
  181. package/dist/repair.js +5 -43
  182. package/dist/repair.js.map +1 -1
  183. package/dist/sagas/migrate-containment.js +7 -7
  184. package/dist/sagas/migrate-containment.js.map +1 -1
  185. package/dist/scaffold/ensure-dirs.d.ts +8 -2
  186. package/dist/scaffold/ensure-dirs.d.ts.map +1 -1
  187. package/dist/scaffold/ensure-dirs.js +24 -11
  188. package/dist/scaffold/ensure-dirs.js.map +1 -1
  189. package/dist/scaffold/project-detection.d.ts +5 -1
  190. package/dist/scaffold/project-detection.d.ts.map +1 -1
  191. package/dist/scaffold/project-detection.js +9 -5
  192. package/dist/scaffold/project-detection.js.map +1 -1
  193. package/dist/sentient/hygiene-scan.js +6 -6
  194. package/dist/sentient/hygiene-scan.js.map +1 -1
  195. package/dist/sentient/proposal-dedup.js +2 -2
  196. package/dist/sentient/proposal-rate-limiter.js +1 -1
  197. package/dist/sentient/propose-tick.js +5 -5
  198. package/dist/sentient/propose-tick.js.map +1 -1
  199. package/dist/sentient/stage-drift-tick.js +3 -3
  200. package/dist/sentient/stage-drift-tick.js.map +1 -1
  201. package/dist/sequence/index.d.ts.map +1 -1
  202. package/dist/sequence/index.js +6 -2
  203. package/dist/sequence/index.js.map +1 -1
  204. package/dist/setup/sections/verification.js +2 -2
  205. package/dist/setup/sections/verification.js.map +1 -1
  206. package/dist/shutdown.d.ts +81 -0
  207. package/dist/shutdown.d.ts.map +1 -0
  208. package/dist/shutdown.js +105 -0
  209. package/dist/shutdown.js.map +1 -0
  210. package/dist/skills/index.d.ts +2 -0
  211. package/dist/skills/index.d.ts.map +1 -1
  212. package/dist/skills/index.js +1 -0
  213. package/dist/skills/index.js.map +1 -1
  214. package/dist/skills/skill-executor-adapter.d.ts +136 -0
  215. package/dist/skills/skill-executor-adapter.d.ts.map +1 -0
  216. package/dist/skills/skill-executor-adapter.js +137 -0
  217. package/dist/skills/skill-executor-adapter.js.map +1 -0
  218. package/dist/skills/usage-recorder.d.ts.map +1 -1
  219. package/dist/skills/usage-recorder.js +30 -0
  220. package/dist/skills/usage-recorder.js.map +1 -1
  221. package/dist/skills-export.d.ts +23 -0
  222. package/dist/skills-export.d.ts.map +1 -0
  223. package/dist/skills-export.js +23 -0
  224. package/dist/skills-export.js.map +1 -0
  225. package/dist/stats/index.d.ts.map +1 -1
  226. package/dist/stats/index.js +8 -3
  227. package/dist/stats/index.js.map +1 -1
  228. package/dist/store/agent-doctor.d.ts +3 -3
  229. package/dist/store/agent-doctor.d.ts.map +1 -1
  230. package/dist/store/agent-doctor.js +18 -13
  231. package/dist/store/agent-doctor.js.map +1 -1
  232. package/dist/store/agent-install.d.ts +6 -5
  233. package/dist/store/agent-install.d.ts.map +1 -1
  234. package/dist/store/agent-install.js +20 -16
  235. package/dist/store/agent-install.js.map +1 -1
  236. package/dist/store/agent-registry-accessor.d.ts +66 -28
  237. package/dist/store/agent-registry-accessor.d.ts.map +1 -1
  238. package/dist/store/agent-registry-accessor.js +248 -167
  239. package/dist/store/agent-registry-accessor.js.map +1 -1
  240. package/dist/store/agent-registry-store.d.ts +242 -0
  241. package/dist/store/agent-registry-store.d.ts.map +1 -0
  242. package/dist/store/agent-registry-store.js +501 -0
  243. package/dist/store/agent-registry-store.js.map +1 -0
  244. package/dist/store/agent-resolver.d.ts +8 -8
  245. package/dist/store/agent-resolver.d.ts.map +1 -1
  246. package/dist/store/agent-resolver.js +19 -17
  247. package/dist/store/agent-resolver.js.map +1 -1
  248. package/dist/store/backup-pack.d.ts.map +1 -1
  249. package/dist/store/backup-pack.js +24 -8
  250. package/dist/store/backup-pack.js.map +1 -1
  251. package/dist/store/conduit-sqlite.d.ts +181 -74
  252. package/dist/store/conduit-sqlite.d.ts.map +1 -1
  253. package/dist/store/conduit-sqlite.js +307 -528
  254. package/dist/store/conduit-sqlite.js.map +1 -1
  255. package/dist/store/cross-db-cleanup.d.ts +5 -5
  256. package/dist/store/cross-db-cleanup.d.ts.map +1 -1
  257. package/dist/store/cross-db-cleanup.js +12 -10
  258. package/dist/store/cross-db-cleanup.js.map +1 -1
  259. package/dist/store/data-accessor.d.ts +4 -4
  260. package/dist/store/data-accessor.js +5 -5
  261. package/dist/store/data-accessor.js.map +1 -1
  262. package/dist/store/data-safety-central.d.ts +83 -1
  263. package/dist/store/data-safety-central.d.ts.map +1 -1
  264. package/dist/store/data-safety-central.js +257 -0
  265. package/dist/store/data-safety-central.js.map +1 -1
  266. package/dist/store/db-helpers.d.ts +8 -2
  267. package/dist/store/db-helpers.d.ts.map +1 -1
  268. package/dist/store/db-helpers.js +6 -2
  269. package/dist/store/db-helpers.js.map +1 -1
  270. package/dist/store/dual-scope-db.d.ts +46 -4
  271. package/dist/store/dual-scope-db.d.ts.map +1 -1
  272. package/dist/store/dual-scope-db.js +103 -9
  273. package/dist/store/dual-scope-db.js.map +1 -1
  274. package/dist/store/exodus/__fixtures__/representative-fixture.d.ts +116 -0
  275. package/dist/store/exodus/__fixtures__/representative-fixture.d.ts.map +1 -0
  276. package/dist/store/exodus/__fixtures__/representative-fixture.js +274 -0
  277. package/dist/store/exodus/__fixtures__/representative-fixture.js.map +1 -0
  278. package/dist/store/exodus/index.d.ts +18 -0
  279. package/dist/store/exodus/index.d.ts.map +1 -0
  280. package/dist/store/exodus/index.js +18 -0
  281. package/dist/store/exodus/index.js.map +1 -0
  282. package/dist/store/exodus/migrate.d.ts +160 -0
  283. package/dist/store/exodus/migrate.d.ts.map +1 -0
  284. package/dist/store/exodus/migrate.js +1220 -0
  285. package/dist/store/exodus/migrate.js.map +1 -0
  286. package/dist/store/exodus/on-open.d.ts +189 -0
  287. package/dist/store/exodus/on-open.d.ts.map +1 -0
  288. package/dist/store/exodus/on-open.js +464 -0
  289. package/dist/store/exodus/on-open.js.map +1 -0
  290. package/dist/store/exodus/plan.d.ts +44 -0
  291. package/dist/store/exodus/plan.d.ts.map +1 -0
  292. package/dist/store/exodus/plan.js +178 -0
  293. package/dist/store/exodus/plan.js.map +1 -0
  294. package/dist/store/exodus/status.d.ts +22 -0
  295. package/dist/store/exodus/status.d.ts.map +1 -0
  296. package/dist/store/exodus/status.js +88 -0
  297. package/dist/store/exodus/status.js.map +1 -0
  298. package/dist/store/exodus/table-name-map.d.ts +173 -0
  299. package/dist/store/exodus/table-name-map.d.ts.map +1 -0
  300. package/dist/store/exodus/table-name-map.js +660 -0
  301. package/dist/store/exodus/table-name-map.js.map +1 -0
  302. package/dist/store/exodus/types.d.ts +169 -0
  303. package/dist/store/exodus/types.d.ts.map +1 -0
  304. package/dist/store/exodus/types.js +21 -0
  305. package/dist/store/exodus/types.js.map +1 -0
  306. package/dist/store/exodus/verify-migration.d.ts +72 -0
  307. package/dist/store/exodus/verify-migration.d.ts.map +1 -0
  308. package/dist/store/exodus/verify-migration.js +678 -0
  309. package/dist/store/exodus/verify-migration.js.map +1 -0
  310. package/dist/store/exodus/verify.d.ts +58 -0
  311. package/dist/store/exodus/verify.d.ts.map +1 -0
  312. package/dist/store/exodus/verify.js +74 -0
  313. package/dist/store/exodus/verify.js.map +1 -0
  314. package/dist/store/index.d.ts +2 -3
  315. package/dist/store/index.d.ts.map +1 -1
  316. package/dist/store/index.js +2 -3
  317. package/dist/store/index.js.map +1 -1
  318. package/dist/store/memory-accessor.d.ts +31 -0
  319. package/dist/store/memory-accessor.d.ts.map +1 -1
  320. package/dist/store/memory-accessor.js +38 -0
  321. package/dist/store/memory-accessor.js.map +1 -1
  322. package/dist/store/memory-sqlite.d.ts +86 -13
  323. package/dist/store/memory-sqlite.d.ts.map +1 -1
  324. package/dist/store/memory-sqlite.js +326 -528
  325. package/dist/store/memory-sqlite.js.map +1 -1
  326. package/dist/store/migrate-signaldock-to-conduit.d.ts +1 -1
  327. package/dist/store/migrate-signaldock-to-conduit.d.ts.map +1 -1
  328. package/dist/store/migrate-signaldock-to-conduit.js +126 -35
  329. package/dist/store/migrate-signaldock-to-conduit.js.map +1 -1
  330. package/dist/store/migration-manager.d.ts +49 -0
  331. package/dist/store/migration-manager.d.ts.map +1 -1
  332. package/dist/store/migration-manager.js +167 -67
  333. package/dist/store/migration-manager.js.map +1 -1
  334. package/dist/store/migration-sqlite.d.ts +1 -1
  335. package/dist/store/migration-sqlite.d.ts.map +1 -1
  336. package/dist/store/migration-sqlite.js +32 -3
  337. package/dist/store/migration-sqlite.js.map +1 -1
  338. package/dist/store/nexus-sqlite.d.ts +152 -29
  339. package/dist/store/nexus-sqlite.d.ts.map +1 -1
  340. package/dist/store/nexus-sqlite.js +496 -177
  341. package/dist/store/nexus-sqlite.js.map +1 -1
  342. package/dist/store/nexus-validation-schemas.d.ts +32 -32
  343. package/dist/store/open-cleo-db.d.ts +37 -40
  344. package/dist/store/open-cleo-db.d.ts.map +1 -1
  345. package/dist/store/open-cleo-db.js +76 -153
  346. package/dist/store/open-cleo-db.js.map +1 -1
  347. package/dist/store/role-accessors-impl.d.ts +4 -4
  348. package/dist/store/role-accessors-impl.d.ts.map +1 -1
  349. package/dist/store/role-accessors-impl.js +18 -15
  350. package/dist/store/role-accessors-impl.js.map +1 -1
  351. package/dist/store/schema/{signaldock-schema.d.ts → agent-registry-schema.d.ts} +15 -5
  352. package/dist/store/schema/agent-registry-schema.d.ts.map +1 -0
  353. package/dist/store/schema/{signaldock-schema.js → agent-registry-schema.js} +15 -5
  354. package/dist/store/schema/agent-registry-schema.js.map +1 -0
  355. package/dist/store/schema/agent-schema.d.ts +1 -1
  356. package/dist/store/schema/agent-schema.js +4 -4
  357. package/dist/store/schema/agent-schema.js.map +1 -1
  358. package/dist/store/schema/attachments.d.ts +1 -1
  359. package/dist/store/schema/audit.d.ts +15 -5
  360. package/dist/store/schema/audit.d.ts.map +1 -1
  361. package/dist/store/schema/audit.js +12 -2
  362. package/dist/store/schema/audit.js.map +1 -1
  363. package/dist/store/schema/background-jobs.d.ts +1 -1
  364. package/dist/store/schema/cleo-global/{signaldock.d.ts → agent-registry.d.ts} +277 -271
  365. package/dist/store/schema/cleo-global/agent-registry.d.ts.map +1 -0
  366. package/dist/store/schema/cleo-global/{signaldock.js → agent-registry.js} +136 -125
  367. package/dist/store/schema/cleo-global/agent-registry.js.map +1 -0
  368. package/dist/store/schema/cleo-global/index.d.ts +29 -22
  369. package/dist/store/schema/cleo-global/index.d.ts.map +1 -1
  370. package/dist/store/schema/cleo-global/index.js +29 -22
  371. package/dist/store/schema/cleo-global/index.js.map +1 -1
  372. package/dist/store/schema/cleo-global/nexus.d.ts +36 -1034
  373. package/dist/store/schema/cleo-global/nexus.d.ts.map +1 -1
  374. package/dist/store/schema/cleo-global/nexus.js +32 -337
  375. package/dist/store/schema/cleo-global/nexus.js.map +1 -1
  376. package/dist/store/schema/cleo-global/skills.d.ts +16 -0
  377. package/dist/store/schema/cleo-global/skills.d.ts.map +1 -1
  378. package/dist/store/schema/cleo-global/skills.js +11 -0
  379. package/dist/store/schema/cleo-global/skills.js.map +1 -1
  380. package/dist/store/schema/{cleo-project → cleo-global}/telemetry.d.ts +33 -17
  381. package/dist/store/schema/cleo-global/telemetry.d.ts.map +1 -0
  382. package/dist/store/schema/{cleo-project → cleo-global}/telemetry.js +30 -18
  383. package/dist/store/schema/cleo-global/telemetry.js.map +1 -0
  384. package/dist/store/schema/cleo-project/audit.d.ts +8 -8
  385. package/dist/store/schema/cleo-project/audit.d.ts.map +1 -1
  386. package/dist/store/schema/cleo-project/audit.js +2 -6
  387. package/dist/store/schema/cleo-project/audit.js.map +1 -1
  388. package/dist/store/schema/cleo-project/docs.d.ts +1 -1
  389. package/dist/store/schema/cleo-project/index.d.ts +29 -12
  390. package/dist/store/schema/cleo-project/index.d.ts.map +1 -1
  391. package/dist/store/schema/cleo-project/index.js +29 -12
  392. package/dist/store/schema/cleo-project/index.js.map +1 -1
  393. package/dist/store/schema/cleo-project/lifecycle.d.ts +2 -2
  394. package/dist/store/schema/cleo-project/nexus-graph.d.ts +1067 -0
  395. package/dist/store/schema/cleo-project/nexus-graph.d.ts.map +1 -0
  396. package/dist/store/schema/cleo-project/nexus-graph.js +407 -0
  397. package/dist/store/schema/cleo-project/nexus-graph.js.map +1 -0
  398. package/dist/store/schema/cleo-project/provenance-orphans.d.ts +385 -0
  399. package/dist/store/schema/cleo-project/provenance-orphans.d.ts.map +1 -0
  400. package/dist/store/schema/cleo-project/provenance-orphans.js +142 -0
  401. package/dist/store/schema/cleo-project/provenance-orphans.js.map +1 -0
  402. package/dist/store/schema/cleo-project/provenance-rest.d.ts +1 -1
  403. package/dist/store/schema/cleo-project/runtime.d.ts +1 -1
  404. package/dist/store/schema/cleo-project/tasks-core-batch2.d.ts +1 -1
  405. package/dist/store/schema/cleo-project/tasks-core.d.ts +3 -3
  406. package/dist/store/schema/cleo-shared/brain.d.ts +711 -494
  407. package/dist/store/schema/cleo-shared/brain.d.ts.map +1 -1
  408. package/dist/store/schema/cleo-shared/brain.js +215 -134
  409. package/dist/store/schema/cleo-shared/brain.js.map +1 -1
  410. package/dist/store/schema/conduit-schema.d.ts +63 -51
  411. package/dist/store/schema/conduit-schema.d.ts.map +1 -1
  412. package/dist/store/schema/conduit-schema.js +23 -11
  413. package/dist/store/schema/conduit-schema.js.map +1 -1
  414. package/dist/store/schema/goal.d.ts +3 -2
  415. package/dist/store/schema/goal.d.ts.map +1 -1
  416. package/dist/store/schema/goal.js +3 -2
  417. package/dist/store/schema/goal.js.map +1 -1
  418. package/dist/store/schema/index.d.ts +1 -0
  419. package/dist/store/schema/index.d.ts.map +1 -1
  420. package/dist/store/schema/index.js +1 -0
  421. package/dist/store/schema/index.js.map +1 -1
  422. package/dist/store/schema/lifecycle.d.ts +2 -2
  423. package/dist/store/schema/memory-schema.d.ts +2 -2
  424. package/dist/store/schema/nexus-schema.d.ts +174 -115
  425. package/dist/store/schema/nexus-schema.d.ts.map +1 -1
  426. package/dist/store/schema/nexus-schema.js +175 -55
  427. package/dist/store/schema/nexus-schema.js.map +1 -1
  428. package/dist/store/schema/provenance/releases.d.ts +1 -1
  429. package/dist/store/schema/schema-utils.d.ts +78 -0
  430. package/dist/store/schema/schema-utils.d.ts.map +1 -0
  431. package/dist/store/schema/schema-utils.js +49 -0
  432. package/dist/store/schema/schema-utils.js.map +1 -0
  433. package/dist/store/schema/skills-schema.d.ts +81 -44
  434. package/dist/store/schema/skills-schema.d.ts.map +1 -1
  435. package/dist/store/schema/skills-schema.js +49 -16
  436. package/dist/store/schema/skills-schema.js.map +1 -1
  437. package/dist/store/schema/tasks.d.ts +3 -3
  438. package/dist/store/skills-db.d.ts +90 -50
  439. package/dist/store/skills-db.d.ts.map +1 -1
  440. package/dist/store/skills-db.js +132 -146
  441. package/dist/store/skills-db.js.map +1 -1
  442. package/dist/store/sqlite-backup.d.ts +2 -2
  443. package/dist/store/sqlite-backup.d.ts.map +1 -1
  444. package/dist/store/sqlite-backup.js +11 -10
  445. package/dist/store/sqlite-backup.js.map +1 -1
  446. package/dist/store/sqlite-data-accessor.d.ts.map +1 -1
  447. package/dist/store/sqlite-data-accessor.js +25 -18
  448. package/dist/store/sqlite-data-accessor.js.map +1 -1
  449. package/dist/store/sqlite.d.ts +72 -12
  450. package/dist/store/sqlite.d.ts.map +1 -1
  451. package/dist/store/sqlite.js +153 -89
  452. package/dist/store/sqlite.js.map +1 -1
  453. package/dist/store/tasks-schema.d.ts +4 -0
  454. package/dist/store/tasks-schema.d.ts.map +1 -1
  455. package/dist/store/tasks-schema.js +60 -0
  456. package/dist/store/tasks-schema.js.map +1 -1
  457. package/dist/store/tasks-sqlite.d.ts +2 -2
  458. package/dist/store/tasks-sqlite.d.ts.map +1 -1
  459. package/dist/store/tasks-sqlite.js +10 -5
  460. package/dist/store/tasks-sqlite.js.map +1 -1
  461. package/dist/store/umbrella-data-accessor.d.ts +17 -6
  462. package/dist/store/umbrella-data-accessor.d.ts.map +1 -1
  463. package/dist/store/umbrella-data-accessor.js +8 -8
  464. package/dist/store/umbrella-data-accessor.js.map +1 -1
  465. package/dist/store/validation-schemas.d.ts +241 -208
  466. package/dist/store/validation-schemas.d.ts.map +1 -1
  467. package/dist/system/health.d.ts.map +1 -1
  468. package/dist/system/health.js +11 -6
  469. package/dist/system/health.js.map +1 -1
  470. package/dist/system/project-health.d.ts.map +1 -1
  471. package/dist/system/project-health.js +58 -12
  472. package/dist/system/project-health.js.map +1 -1
  473. package/dist/tasks/add.d.ts +8 -0
  474. package/dist/tasks/add.d.ts.map +1 -1
  475. package/dist/tasks/add.js +101 -0
  476. package/dist/tasks/add.js.map +1 -1
  477. package/dist/tasks/cancelled-child-waiver-audit.d.ts +47 -0
  478. package/dist/tasks/cancelled-child-waiver-audit.d.ts.map +1 -0
  479. package/dist/tasks/cancelled-child-waiver-audit.js +34 -0
  480. package/dist/tasks/cancelled-child-waiver-audit.js.map +1 -0
  481. package/dist/tasks/complete.d.ts +22 -2
  482. package/dist/tasks/complete.d.ts.map +1 -1
  483. package/dist/tasks/complete.js +71 -6
  484. package/dist/tasks/complete.js.map +1 -1
  485. package/dist/tasks/compute-task-view.js +1 -1
  486. package/dist/tasks/session-scope.d.ts +5 -0
  487. package/dist/tasks/session-scope.d.ts.map +1 -1
  488. package/dist/tasks/session-scope.js +4 -0
  489. package/dist/tasks/session-scope.js.map +1 -1
  490. package/dist/tools/guard.d.ts +71 -1
  491. package/dist/tools/guard.d.ts.map +1 -1
  492. package/dist/tools/guard.js +73 -1
  493. package/dist/tools/guard.js.map +1 -1
  494. package/dist/tools/index.d.ts +21 -0
  495. package/dist/tools/index.d.ts.map +1 -1
  496. package/dist/tools/index.js +25 -0
  497. package/dist/tools/index.js.map +1 -1
  498. package/dist/upgrade.d.ts.map +1 -1
  499. package/dist/upgrade.js +22 -13
  500. package/dist/upgrade.js.map +1 -1
  501. package/dist/workgraph/containment.js +18 -18
  502. package/dist/workgraph/relations.js +2 -2
  503. package/dist/worktree/list.d.ts +1 -1
  504. package/dist/worktree/list.d.ts.map +1 -1
  505. package/dist/worktree/list.js +19 -21
  506. package/dist/worktree/list.js.map +1 -1
  507. package/dist/worktree-export.d.ts +18 -0
  508. package/dist/worktree-export.d.ts.map +1 -0
  509. package/dist/worktree-export.js +18 -0
  510. package/dist/worktree-export.js.map +1 -0
  511. package/migrations/drizzle-agent-registry/20260412000000_initial-global-agent-registry/migration.sql +29 -0
  512. package/migrations/drizzle-brain/20260601000001_t11522-brain-task-observations/migration.sql +28 -0
  513. package/migrations/drizzle-brain/20260601000002_t11522-inline-only-brain-tables/migration.sql +75 -0
  514. package/migrations/drizzle-cleo-global/20260531000001_t11363-consolidation-cleo-global/migration.sql +49 -144
  515. package/migrations/drizzle-cleo-global/20260531000001_t11363-consolidation-cleo-global/snapshot.json +8 -8
  516. package/migrations/drizzle-cleo-global/20260531000002_t11546-brain-usage-log/migration.sql +16 -0
  517. package/migrations/drizzle-cleo-global/20260601000001_t11544-skills-usage-project-id/migration.sql +12 -0
  518. package/migrations/drizzle-cleo-global/20260602000001_t11622-agent-registry-rename/migration.sql +80 -0
  519. package/migrations/drizzle-cleo-project/20260531000001_t11363-consolidation-cleo-project/migration.sql +26 -167
  520. package/migrations/drizzle-cleo-project/20260531000001_t11363-consolidation-cleo-project/snapshot.json +8 -8
  521. package/migrations/drizzle-cleo-project/20260531000002_t11546-brain-usage-log/migration.sql +21 -0
  522. package/migrations/drizzle-cleo-project/20260601000001_t11549-agent-credentials-brain-release-links/migration.sql +49 -0
  523. package/migrations/drizzle-cleo-project/20260601000002_t11538-project-nexus-graph/migration.sql +140 -0
  524. package/migrations/drizzle-cleo-project/20260602000002_t11649-token-usage-transport-mcp/migration.sql +146 -0
  525. package/migrations/drizzle-conduit/20260601000003_t11523-conduit-inline-schema/migration.sql +82 -0
  526. package/migrations/drizzle-nexus/20260421200001_t1165-baseline-reset/migration.sql +26 -8
  527. package/migrations/drizzle-nexus/20260601000001_t11545-nexus-relation-weights-partition/migration.sql +97 -0
  528. package/package.json +43 -11
  529. package/scripts/install-supervisor-binary.mjs +50 -201
  530. package/scripts/napi-binary-picker.mjs +267 -0
  531. package/dist/agents/agent-registry.d.ts.map +0 -1
  532. package/dist/agents/agent-registry.js.map +0 -1
  533. package/dist/store/data-safety.d.ts +0 -92
  534. package/dist/store/data-safety.d.ts.map +0 -1
  535. package/dist/store/data-safety.js +0 -274
  536. package/dist/store/data-safety.js.map +0 -1
  537. package/dist/store/schema/cleo-global/signaldock.d.ts.map +0 -1
  538. package/dist/store/schema/cleo-global/signaldock.js.map +0 -1
  539. package/dist/store/schema/cleo-project/telemetry.d.ts.map +0 -1
  540. package/dist/store/schema/cleo-project/telemetry.js.map +0 -1
  541. package/dist/store/schema/signaldock-schema.d.ts.map +0 -1
  542. package/dist/store/schema/signaldock-schema.js.map +0 -1
  543. package/dist/store/signaldock-sqlite.d.ts +0 -173
  544. package/dist/store/signaldock-sqlite.d.ts.map +0 -1
  545. package/dist/store/signaldock-sqlite.js +0 -445
  546. package/dist/store/signaldock-sqlite.js.map +0 -1
  547. package/migrations/drizzle-nexus/20260318205558_initial/migration.sql +0 -46
  548. package/migrations/drizzle-nexus/20260412000001_t529-nexus-graph-tables/migration.sql +0 -49
  549. package/migrations/drizzle-nexus/20260415000001_t622-project-registry-paths/migration.sql +0 -12
  550. package/migrations/drizzle-nexus/20260419000001_t998-nexus-plasticity/migration.sql +0 -13
  551. package/migrations/drizzle-nexus/20260423052640_t1077-add-user-profile-table/migration.sql +0 -16
  552. package/migrations/drizzle-nexus/20260423052640_t1077-add-user-profile-table/snapshot.json +0 -1531
  553. package/migrations/drizzle-nexus/20260424140538_t1148-add-sigils-table/migration.sql +0 -13
  554. package/migrations/drizzle-nexus/20260424140538_t1148-add-sigils-table/snapshot.json +0 -1652
  555. package/migrations/drizzle-nexus/20260504000001_t1839-fts5-nexus-symbols/migration.sql +0 -68
  556. package/migrations/drizzle-nexus/20260507135519_t9163-nexus-is-external/migration.sql +0 -20
  557. package/migrations/drizzle-nexus/20260507135519_t9163-nexus-is-external/snapshot.json +0 -1652
  558. package/migrations/drizzle-nexus/20260526222449_t11025-project-id-aliases/migration.sql +0 -16
  559. package/migrations/drizzle-signaldock/20260412000000_initial-global-signaldock/migration.sql +0 -209
  560. package/migrations/drizzle-signaldock/20260412000000_initial-global-signaldock/snapshot.json +0 -2060
@@ -0,0 +1,1220 @@
1
+ /**
2
+ * Exodus migration engine.
3
+ *
4
+ * `runExodusMigrate()` performs the actual data migration from legacy DBs
5
+ * to the consolidated dual-scope `cleo.db`. Key invariants:
6
+ *
7
+ * - Source DBs are opened **read-only** via `openCleoDbSnapshot` (AC4).
8
+ * - Source files are backed up to the staging dir before any writes (AC5).
9
+ * - Import is wrapped in `BEGIN … COMMIT` per source; partial failure leaves
10
+ * the target DB untouched (AC6).
11
+ * - Idempotency keys are propagated where the source row has them; generated
12
+ * where it does not (AC7).
13
+ * - The staging journal is written atomically before each table copy so a
14
+ * crash can be resumed (AC5).
15
+ *
16
+ * ## Type coercion — epoch INTEGER → ISO-8601 TEXT (ROOT CAUSE 1 fix — T11546)
17
+ *
18
+ * Many legacy tables store timestamps as INTEGER epoch values (seconds or
19
+ * milliseconds). The consolidated schema declares these columns as `text` with
20
+ * a CHECK constraint: `CHECK ("col" IS NULL OR "col" GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*')`.
21
+ *
22
+ * When a source INTEGER value is inserted into a target TEXT+GLOB column, SQLite
23
+ * coerces the integer to its decimal string representation (e.g. `"1717200000"`),
24
+ * which fails the GLOB check. `INSERT OR IGNORE` then SILENTLY DROPS the entire
25
+ * row. Result: all conduit_messages, brain_observations, etc. → 0 rows copied
26
+ * while migrate reports `success: true, rowsCopied: 0`.
27
+ *
28
+ * Fix (two parts):
29
+ * (a) Per-column value transform: `detectEpochToIsoColumns()` reads the target
30
+ * table DDL from `sqlite_master` to identify columns with an ISO GLOB
31
+ * CHECK constraint. For those columns, if the source type affinity is
32
+ * INTEGER, the SELECT expression applies `strftime('%Y-%m-%dT%H:%M:%fZ',
33
+ * col, 'unixepoch')` (seconds) or the `/1000.0` ms variant depending on
34
+ * the per-source/table heuristic.
35
+ * (b) No-swallow assertion: after the bulk INSERT OR IGNORE, the actual
36
+ * `changes` count is compared against the source row count. Any shortfall
37
+ * is surfaced as a hard table-level error (not a silent success).
38
+ * PK/UNIQUE conflicts on resume are tolerated (they are expected and safe
39
+ * to ignore); unexpected shortfalls are flagged with a detailed error.
40
+ *
41
+ * ## ATTACH-once-per-source design (P0 fix — T11531)
42
+ *
43
+ * Each legacy source DB is ATTACHed to the target handle ONCE using a unique
44
+ * per-source alias, all tables from that source are copied under the single
45
+ * attachment, and then the alias is DETACHed. The ATTACH and DETACH are
46
+ * performed **outside** the BEGIN/COMMIT transaction block because SQLite
47
+ * forbids DETACH inside an active multi-statement transaction. The INSERT
48
+ * statements themselves are issued inside the transaction for atomicity (AC6).
49
+ *
50
+ * Prior implementation called ATTACH/DETACH per-table inside an open
51
+ * transaction — DETACH silently failed (SQLite restriction), the alias stayed
52
+ * attached, and every subsequent table for the same source threw
53
+ * "database _exodus_src_ is already in use", causing ~80 % data loss.
54
+ *
55
+ * ## FK-defer bulk copy (ROOT CAUSE 1 fix — T11533)
56
+ *
57
+ * Legacy tasks.db has self-referential FKs (`tasks.parent_id → tasks.id`),
58
+ * cross-table FKs, and ~18 tables with FK relationships. When FK enforcement
59
+ * is ON and tables are copied in arbitrary order (children before parents),
60
+ * each child INSERT fails with FOREIGN KEY constraint error (SQLite errcode 787)
61
+ * and the table is silently skipped — causing ~114K rows of data loss.
62
+ *
63
+ * Fix: set `PRAGMA foreign_keys = OFF` on the target connection before any
64
+ * bulk INSERT, then after all tables are committed run `PRAGMA foreign_key_check`
65
+ * to validate referential integrity. Genuine orphan rows surface as verify
66
+ * failures; child-before-parent ordering artifacts are NOT dropped.
67
+ * `PRAGMA foreign_keys = ON` is restored after the check.
68
+ *
69
+ * ## Name-mapping (ROOT CAUSE 1 fix — T11532)
70
+ *
71
+ * Legacy source DBs use UNPREFIXED table names (`tasks`, `messages`, `skills`,
72
+ * …) while the consolidated `cleo.db` uses DOMAIN-PREFIXED names
73
+ * (`tasks_tasks`, `conduit_messages`, `skills_skills`, …). The
74
+ * `resolveConsolidatedTableName()` function from `table-name-map.ts` performs
75
+ * the deterministic legacy→consolidated mapping before every copy. Tables with
76
+ * no consolidated home emit an explicit WARN journal entry rather than being
77
+ * silently discarded.
78
+ *
79
+ * ## Column-drift tolerance (ROOT CAUSE 2 fix — T11532, hardened T11533)
80
+ *
81
+ * When the source and target schemas differ (consolidated schema added/changed
82
+ * columns vs legacy), the copy uses the INTERSECTION of source and target
83
+ * column names. New target-only columns take their schema defaults; old
84
+ * source-only columns are dropped. This is implemented by introspecting both
85
+ * schemas via `PRAGMA table_info` and building an explicit column list.
86
+ *
87
+ * **NOT NULL / no-default hazard (T11533)**: target-only NOT NULL columns
88
+ * WITHOUT a schema default caused `INSERT OR IGNORE` to silently drop rows
89
+ * whose source value was NULL for that column (constraint violation → IGNORE).
90
+ * The fix: for each intersection column that is NOT NULL in the target AND
91
+ * has no `dflt_value`, the SELECT clause wraps the source reference in
92
+ * `COALESCE(src_col, type_default)` so no row is silently dropped.
93
+ * A type_default of `''` is used for TEXT affinity and `0` for numeric.
94
+ *
95
+ * ## Advisory file lock (AC4)
96
+ *
97
+ * The source DB files are opened read-only via `openCleoDbSnapshot` which
98
+ * calls `new DatabaseSync(path, { readOnly: true })`. Node's SQLite binding
99
+ * opens with `SQLITE_OPEN_READONLY`, which prevents any writes from this
100
+ * process. We additionally write a `.lock` sentinel file next to each source
101
+ * DB for the duration of the migration so that other CLEO processes can detect
102
+ * an in-progress exodus and refuse to write.
103
+ *
104
+ * @task T11248 (E5 · SG-DB-SUBSTRATE-V2)
105
+ * @task T11531 (P0 attach-leak fix)
106
+ * @task T11532 (P0 name-mapping + column-drift + verify-rowid fix)
107
+ * @task T11533 (P0 FK-defer + NOT NULL coalesce + signaldock-global map + nexus hash fix)
108
+ * @task T11547 (P0 enum normalization — 7,421 rows recovered)
109
+ * @task T11548 (P0 final enum coverage — 285 remaining rows zero-loss)
110
+ * @task T11549 (P0 zero-loss final mile — brain_decisions enums + seconds-epoch coercion +
111
+ * agent_credentials/brain_release_links tables)
112
+ * @saga T11242
113
+ */
114
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
115
+ import { join } from 'node:path';
116
+ import { getLogger } from '../../logger.js';
117
+ import { getCleoVersion } from '../../scaffold/ensure-config.js';
118
+ import { openDualScopeDb } from '../dual-scope-db.js';
119
+ import { openCleoDbSnapshot } from '../open-cleo-db.js';
120
+ import { resolveConsolidatedTableName, resolveTableTargetScope } from './table-name-map.js';
121
+ import { EXODUS_TARGET_SCHEMA_VERSION } from './types.js';
122
+ const log = getLogger('exodus-migrate');
123
+ // ---------------------------------------------------------------------------
124
+ // Advisory lock sentinel filename
125
+ // ---------------------------------------------------------------------------
126
+ const LOCK_SENTINEL_SUFFIX = '.exodus-lock';
127
+ // ---------------------------------------------------------------------------
128
+ // Journal helpers
129
+ // ---------------------------------------------------------------------------
130
+ const JOURNAL_FILENAME = 'exodus-journal.json';
131
+ /**
132
+ * Get the SQLite version string from an open DatabaseSync handle.
133
+ */
134
+ function getSqliteVersion(db) {
135
+ try {
136
+ const row = db.prepare('SELECT sqlite_version() AS v').get();
137
+ return row?.v ?? 'unknown';
138
+ }
139
+ catch {
140
+ return 'unknown';
141
+ }
142
+ }
143
+ /**
144
+ * Read the tables list from a legacy SQLite DB (excluding SQLite internals).
145
+ */
146
+ function listTables(db) {
147
+ const rows = db
148
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle_%' ORDER BY name")
149
+ .all();
150
+ return rows.map((r) => r.name);
151
+ }
152
+ /**
153
+ * Write the journal file atomically (write-then-rename pattern).
154
+ */
155
+ function writeJournal(stagingDir, journal) {
156
+ const journalPath = join(stagingDir, JOURNAL_FILENAME);
157
+ const tmpPath = `${journalPath}.tmp`;
158
+ writeFileSync(tmpPath, JSON.stringify(journal, null, 2) + '\n', 'utf8');
159
+ // On POSIX, rename is atomic within the same fs.
160
+ renameSync(tmpPath, journalPath);
161
+ }
162
+ /**
163
+ * Read an existing journal from the staging dir, or return `null`.
164
+ */
165
+ function readJournal(stagingDir) {
166
+ const journalPath = join(stagingDir, JOURNAL_FILENAME);
167
+ if (!existsSync(journalPath))
168
+ return null;
169
+ try {
170
+ return JSON.parse(readFileSync(journalPath, 'utf8'));
171
+ }
172
+ catch {
173
+ return null;
174
+ }
175
+ }
176
+ /**
177
+ * Invalidate the migrate journal after an aborted/rolled-back migration so a
178
+ * post-abort retry RE-COPIES every table from scratch (T11572).
179
+ *
180
+ * ## Why this is required for retry correctness
181
+ *
182
+ * `runExodusMigrate` is resumable (AC5): after each table copy it writes a
183
+ * journal entry with `status: 'done'`, and a subsequent run SKIPS any table
184
+ * already marked `done`. That resume optimisation is correct only while the
185
+ * consolidated rows it copied still EXIST. When the parity gate aborts, the
186
+ * exodus-on-open hook truncates the consolidated tables back to EMPTY
187
+ * (`rollbackConsolidatedToEmpty`) — but the on-disk journal still says every
188
+ * table is `done`. The next open therefore re-triggers (consolidated empty),
189
+ * runs `runExodusMigrate` again, finds every table `done` in the journal, copies
190
+ * NOTHING, re-verifies the still-empty target, and re-aborts — a permanent loop
191
+ * that pays the full migrate+verify cost on every open.
192
+ *
193
+ * Deleting the journal file forces `runExodusMigrate` to `initJournal()` afresh,
194
+ * so the retry actually re-copies. Idempotent and best-effort: a missing journal
195
+ * (or a missing staging dir) is a no-op. The staging backups are intentionally
196
+ * LEFT in place (they are the legacy source-of-truth snapshots — harmless to
197
+ * keep, valuable for forensics); only the progress journal is cleared.
198
+ *
199
+ * @param stagingDir - The staging directory whose `exodus-journal.json` to clear.
200
+ * @returns `true` if a journal file was removed, `false` if there was nothing to
201
+ * remove.
202
+ *
203
+ * @task T11572 (abort/rollback must invalidate journal so retry re-copies)
204
+ * @epic T11249 (E6)
205
+ * @saga T11242
206
+ */
207
+ export function clearExodusJournal(stagingDir) {
208
+ const journalPath = join(stagingDir, JOURNAL_FILENAME);
209
+ try {
210
+ if (!existsSync(journalPath))
211
+ return false;
212
+ unlinkSync(journalPath);
213
+ log.info({ stagingDir }, 'exodus: cleared migrate journal after abort/rollback — a retry will RE-COPY all tables');
214
+ return true;
215
+ }
216
+ catch (err) {
217
+ log.warn({ err, stagingDir }, 'exodus: failed to clear migrate journal after rollback (a retry may skip already-"done" tables)');
218
+ return false;
219
+ }
220
+ }
221
+ /**
222
+ * Initialise a fresh journal object.
223
+ */
224
+ function initJournal(sqliteVersion) {
225
+ const now = new Date().toISOString();
226
+ return {
227
+ version: 1,
228
+ cleoVersion: getCleoVersion(),
229
+ targetSchemaVersion: EXODUS_TARGET_SCHEMA_VERSION,
230
+ nodeVersion: process.version,
231
+ sqliteVersion,
232
+ startedAt: now,
233
+ updatedAt: now,
234
+ tables: [],
235
+ };
236
+ }
237
+ // ---------------------------------------------------------------------------
238
+ // Advisory lock sentinel helpers (AC4)
239
+ // ---------------------------------------------------------------------------
240
+ function lockPath(dbPath) {
241
+ return `${dbPath}${LOCK_SENTINEL_SUFFIX}`;
242
+ }
243
+ function acquireAdvisoryLock(dbPath) {
244
+ const lp = lockPath(dbPath);
245
+ writeFileSync(lp, JSON.stringify({ pid: process.pid, ts: new Date().toISOString() }), 'utf8');
246
+ }
247
+ function releaseAdvisoryLock(dbPath) {
248
+ try {
249
+ unlinkSync(lockPath(dbPath));
250
+ }
251
+ catch {
252
+ // Ignore — lock may already be gone
253
+ }
254
+ }
255
+ // ---------------------------------------------------------------------------
256
+ // Unique ATTACH alias per source DB (T11531)
257
+ // ---------------------------------------------------------------------------
258
+ /**
259
+ * Convert a source DB name to a safe, unique SQLite ATTACH alias.
260
+ *
261
+ * SQLite identifiers may contain only word characters; we prefix with
262
+ * `_src_` so they never collide with target table names.
263
+ *
264
+ * @param name - Logical name from `LegacyDbDescriptor.name` (e.g. `"brain (project)"`).
265
+ * @param index - Positional index used when names collide after sanitisation.
266
+ * @returns A unique, identifier-safe alias string.
267
+ */
268
+ function makeAttachAlias(name, index) {
269
+ const safe = name
270
+ .replace(/[^a-z0-9]/gi, '_')
271
+ .replace(/_+/g, '_')
272
+ .slice(0, 20);
273
+ // Include the index to guarantee uniqueness even if two names normalise identically.
274
+ return `_src_${safe}_${index}`;
275
+ }
276
+ /**
277
+ * Determine a safe SQL literal default for a NOT NULL column with no schema
278
+ * default, given its SQLite type affinity.
279
+ *
280
+ * Used to coalesce NULL source values for target-only NOT NULL columns so that
281
+ * rows are not silently dropped by `INSERT OR IGNORE` when a source value is
282
+ * NULL (T11533 ROOT CAUSE 2 fix).
283
+ *
284
+ * @param colType - Raw `type` string from `PRAGMA table_info` (e.g. `"INTEGER"`,
285
+ * `"TEXT"`, `"REAL"`, `"BLOB"`, or compound forms like `"text NOT NULL"`).
286
+ * @returns A SQL literal string suitable for embedding in a `COALESCE()` call.
287
+ */
288
+ function typeDefaultLiteral(colType) {
289
+ const upper = colType.toUpperCase();
290
+ if (upper.includes('INT'))
291
+ return '0';
292
+ if (upper.includes('REAL') || upper.includes('FLOAT') || upper.includes('DOUBLE'))
293
+ return '0.0';
294
+ if (upper.includes('BLOB'))
295
+ return "x''";
296
+ // TEXT and any other affinity (SQLite permissive) → empty string
297
+ return "''";
298
+ }
299
+ const ENUM_NORMALIZATIONS = new Map([
300
+ // --- task_commits.link_source -------------------------------------------
301
+ // 'commit-message' → 'commit-subject' (pre-T9506 legacy value)
302
+ [
303
+ 'tasks_task_commits.link_source',
304
+ (src) => `CASE ${src} WHEN 'commit-message' THEN 'commit-subject' ELSE ${src} END`,
305
+ ],
306
+ // --- architecture_decisions.status (case + date-suffix normalization) ----
307
+ // 'Accepted', 'ACCEPTED', 'approved', 'Accepted (2026-04-18)', … → 'accepted'
308
+ // 'Proposed', 'PROPOSED' → 'proposed'
309
+ // 'Superseded', 'SUPERSEDED' → 'superseded'
310
+ [
311
+ 'tasks_architecture_decisions.status',
312
+ (src) => `CASE` +
313
+ ` WHEN lower(${src}) = 'accepted' OR lower(${src}) LIKE 'accepted %' OR lower(${src}) = 'approved' THEN 'accepted'` +
314
+ ` WHEN lower(${src}) = 'proposed' THEN 'proposed'` +
315
+ ` WHEN lower(${src}) = 'superseded' THEN 'superseded'` +
316
+ ` WHEN lower(${src}) = 'deprecated' THEN 'deprecated'` +
317
+ ` ELSE ${src}` +
318
+ ` END`,
319
+ ],
320
+ // --- brain_* enum normalizations REMOVED (T11647) -----------------------
321
+ // The brain memory family now lands in the consolidated cleo.db in its LEGACY
322
+ // RUNTIME shape — INTEGER epoch timestamps and, critically, NO SQL CHECK
323
+ // constraints (the `text({ enum })` unions are enforced only at the
324
+ // application layer, exactly as the runtime `drizzle-brain` tables are). With
325
+ // no brain CHECK constraint to satisfy, exodus MUST copy every brain enum
326
+ // value VERBATIM — coercing them (e.g. source_type 'observer-compressed'/
327
+ // 'sleep-consolidation' → 'agent', type 'observation'/'proposal'/'pattern' →
328
+ // nearest) would now be unnecessary data CORRUPTION, not a constraint fix.
329
+ // The previous brain entries (brain_observations.{source_type,type},
330
+ // brain_decisions.{confirmation_state,decision_category,confidence,outcome,
331
+ // decided_by}) are therefore deleted. The non-brain entries below still apply
332
+ // because those consolidated tables retain their CHECK constraints.
333
+ // --- tasks_token_usage.transport (T11548 → REMOVED T11649) ---------------
334
+ // NO normalization. 'mcp' is a first-class transport origin (MCP-gateway
335
+ // requests) and is preserved verbatim. The consolidated CHECK enum was WIDENED
336
+ // to include 'mcp' (canonical TOKEN_USAGE_TRANSPORTS SSoT + forward migration
337
+ // 20260602000002_t11649-token-usage-transport-mcp), so the value lands without
338
+ // coercion. The earlier 'mcp' → 'agent' mapping was a silent semantic alteration
339
+ // of ~194 rows (count-preserving, NOT integrity-preserving) — see T11649.
340
+ // (brain_decisions.{decision_category,confidence} normalizations removed —
341
+ // T11647: brain target = runtime shape with no CHECK; copy values verbatim.)
342
+ // --- tasks_commits.conventional_type (T11548 + T11578) -------------------
343
+ // The consolidated CHECK enum is feat/fix/chore/docs/refactor/test/build/ci/
344
+ // perf/revert/breaking. Real git history carries non-conventional subjects:
345
+ // - 'style' → 'chore' (pre-T11548 mapping; no 'style' in enum).
346
+ // - 'merge'/'release' → 'chore' (T11578): merge + release commits are
347
+ // maintenance-class; the precise semantic is preserved by the dedicated
348
+ // `is_merge_commit` / `is_release_commit` boolean columns, so collapsing
349
+ // `conventional_type` to the maintenance catch-all 'chore' is lossless at
350
+ // the row grain. Without this the 'merge'/'release' rows violate the CHECK,
351
+ // `INSERT OR IGNORE` drops the WHOLE commits table, and the exodus-on-open
352
+ // data-continuity gate aborts the cutover (T11578 CI regression).
353
+ // - any OTHER out-of-enum value → 'chore' (defensive: future non-conventional
354
+ // subjects must never re-break the zero-deficit gate; the boolean flags and
355
+ // raw subject text remain the precise provenance).
356
+ [
357
+ 'tasks_commits.conventional_type',
358
+ (src) => `CASE` +
359
+ ` WHEN ${src} IS NULL THEN NULL` +
360
+ ` WHEN ${src} IN ('feat', 'fix', 'chore', 'docs', 'refactor', 'test', 'build', 'ci', 'perf', 'revert', 'breaking') THEN ${src}` +
361
+ ` ELSE 'chore'` +
362
+ ` END`,
363
+ ],
364
+ // --- tasks_task_relations.relation_type (T11548) -------------------------
365
+ // 'grouped-by' → 'groups' (enum: related/blocks/duplicates/absorbs/fixes/extends/
366
+ // supersedes/groups). 4 rows.
367
+ [
368
+ 'tasks_task_relations.relation_type',
369
+ (src) => `CASE ${src} WHEN 'grouped-by' THEN 'groups' ELSE ${src} END`,
370
+ ],
371
+ // --- tasks_lifecycle_stages.stage_name (T11548) --------------------------
372
+ // Legacy camelCase / past-tense values → canonical snake_case stage names.
373
+ // 'implemented' → 'implementation', 'qaPassed' → 'validation',
374
+ // 'testsPassed' → 'testing'. 3 rows.
375
+ [
376
+ 'tasks_lifecycle_stages.stage_name',
377
+ (src) => `CASE ${src}` +
378
+ ` WHEN 'implemented' THEN 'implementation'` +
379
+ ` WHEN 'qaPassed' THEN 'validation'` +
380
+ ` WHEN 'testsPassed' THEN 'testing'` +
381
+ ` ELSE ${src}` +
382
+ ` END`,
383
+ ],
384
+ // --- tasks_architecture_decisions.gate_status (T11548) ------------------
385
+ // 'passed (T5313 consensus)' → 'passed', 'approved' → 'passed'
386
+ // (enum: pending/passed/failed/waived). 2 rows.
387
+ [
388
+ 'tasks_architecture_decisions.gate_status',
389
+ (src) => `CASE` +
390
+ ` WHEN ${src} LIKE 'passed%' THEN 'passed'` +
391
+ ` WHEN ${src} = 'approved' THEN 'passed'` +
392
+ ` ELSE ${src}` +
393
+ ` END`,
394
+ ],
395
+ // --- tasks_evidence_ac_bindings.binding_type (T11548) -------------------
396
+ // Values with a 'validator:...' prefix → 'direct'
397
+ // (enum: direct/satisfies/coverage). 3 rows.
398
+ // Strip the namespace prefix introduced before the enum was tightened.
399
+ [
400
+ 'tasks_evidence_ac_bindings.binding_type',
401
+ (src) => `CASE` + ` WHEN ${src} LIKE 'validator:%' THEN 'direct'` + ` ELSE ${src}` + ` END`,
402
+ ],
403
+ // (brain_decisions.{outcome,decided_by} normalizations removed — T11647:
404
+ // brain target = runtime shape with no CHECK; legacy values like 'accepted',
405
+ // 'rejected', 'prime' now survive VERBATIM instead of being coerced.)
406
+ ]);
407
+ /**
408
+ * Return a SQL CASE expression that normalises legacy enum values for `col` in
409
+ * `targetTableName` to the canonical values accepted by the consolidated CHECK,
410
+ * or return `null` when no normalization rule exists for this (table, column).
411
+ *
412
+ * @param targetTableName - Physical consolidated target table name.
413
+ * @param col - Column name.
414
+ * @param srcRef - SQL expression referencing the source column.
415
+ * @returns A SQL CASE expression string, or `null` if no rule applies.
416
+ */
417
+ function enumNormExpr(targetTableName, col, srcRef) {
418
+ const key = `${targetTableName}.${col}`;
419
+ const fn = ENUM_NORMALIZATIONS.get(key);
420
+ return fn ? fn(srcRef) : null;
421
+ }
422
+ // ---------------------------------------------------------------------------
423
+ // Epoch-to-ISO coercion layer (ROOT CAUSE 1 fix — T11546)
424
+ // ---------------------------------------------------------------------------
425
+ /**
426
+ * Regex to detect ISO GLOB CHECK constraints in DDL SQL.
427
+ * Matches: `CHECK ("colname" IS NULL OR "colname" GLOB '[0-9]...')`
428
+ * Uses `\[0-9` to match the literal `[0-9` at the start of the GLOB pattern.
429
+ */
430
+ const ISO_CHECK_REGEX = /CHECK\s*\(\s*"([^"]+)"\s+IS\s+NULL\s+OR\s+"[^"]+"\s+GLOB\s+'\[0-9/gi;
431
+ /**
432
+ * Per-source-DB epoch unit lookup — used as a primary hint for the SQL magnitude
433
+ * heuristic (T11549 coercion fix). When the magnitude heuristic is ambiguous
434
+ * (value in the overlap zone), the source-level hint wins.
435
+ *
436
+ * Verified from schema source comments (§8.1 resolution):
437
+ * - conduit.db: `Math.floor(Date.now() / 1000)` → SECONDS
438
+ * - brain.db: `Date.now()` / `unixepoch * 1000` → MILLISECONDS
439
+ * - signaldock.db: `strftime('%s','now')` → SECONDS
440
+ * - tasks.db: `Date.now()` → MILLISECONDS (most writers)
441
+ * - nexus.db: mixed — most columns are MILLISECONDS but `user_profile`
442
+ * `first_observed_at`/`last_reinforced_at` are SECONDS
443
+ * (written by PSYCHE dialectic writer via `Math.floor(Date.now() / 1000)`).
444
+ * The magnitude heuristic handles this automatically.
445
+ * - skills.db: `Date.now()` → MILLISECONDS
446
+ */
447
+ const SOURCE_EPOCH_UNITS = new Map([
448
+ ['conduit', 'seconds'],
449
+ ['brain', 'milliseconds'],
450
+ ['brain (project)', 'milliseconds'],
451
+ ['brain (global)', 'milliseconds'],
452
+ ['signaldock', 'seconds'],
453
+ ['tasks', 'milliseconds'],
454
+ ['nexus', 'milliseconds'],
455
+ ['skills', 'milliseconds'],
456
+ ]);
457
+ /**
458
+ * Return the epoch unit used by a given source DB's INTEGER timestamp columns.
459
+ * Defaults to `'seconds'` for unknown sources (safe default — ISO-8601 output
460
+ * will be off by 1000x only for ms sources, which are already enumerated above).
461
+ *
462
+ * NOTE: This is the per-source hint. The actual SQL expression generated by
463
+ * `buildEpochToIsoExpr` uses a magnitude-based heuristic at the row level so
464
+ * individual columns that diverge from the source default are handled correctly
465
+ * (T11549 bug fix — nexus `user_profile` stores seconds despite nexus being
466
+ * labeled milliseconds at the source level).
467
+ */
468
+ function epochUnitForSource(sourceName) {
469
+ const key = sourceName.toLowerCase();
470
+ // Check for prefix matches (e.g. "brain (project)" starts with "brain")
471
+ for (const [pattern, unit] of SOURCE_EPOCH_UNITS) {
472
+ if (key === pattern || key.startsWith(pattern))
473
+ return unit;
474
+ }
475
+ return 'seconds';
476
+ }
477
+ /**
478
+ * Magnitude threshold distinguishing epoch SECONDS from epoch MILLISECONDS.
479
+ *
480
+ * A Unix epoch value for years 2020–2100 is roughly 1.6e9 – 4.1e9 seconds,
481
+ * or 1.6e12 – 4.1e12 milliseconds. The safe boundary is 1e11 (100 billion):
482
+ * any value BELOW 1e11 is in seconds (even year 2100 seconds ≈ 4.1e9 < 1e11);
483
+ * any value AT OR ABOVE 1e11 is in milliseconds (year 2020 ms ≈ 1.6e12 > 1e11).
484
+ *
485
+ * This constant is embedded directly in the generated SQL CASE expression so
486
+ * it is evaluated per-row — each row's epoch is classified independently.
487
+ */
488
+ const EPOCH_SECONDS_THRESHOLD = 100_000_000_000; // 1e11
489
+ /**
490
+ * Build a SQL expression that converts an INTEGER epoch column to ISO-8601 TEXT,
491
+ * automatically detecting whether the stored value is in seconds or milliseconds
492
+ * using a magnitude heuristic (T11549 correctness fix).
493
+ *
494
+ * ## Heuristic
495
+ *
496
+ * A per-row CASE checks whether the column value is below {@link EPOCH_SECONDS_THRESHOLD}
497
+ * (100 billion). If so, the value is treated as seconds and passed directly to
498
+ * `strftime(..., 'unixepoch')`. If at or above the threshold, it is divided by
499
+ * 1000.0 first (milliseconds → seconds).
500
+ *
501
+ * This replaces the previous per-source heuristic which failed when individual
502
+ * columns within a source DB used a different epoch unit than the majority of that
503
+ * source's columns. The specific bug: `nexus.user_profile.{first_observed_at,
504
+ * last_reinforced_at}` stores SECONDS (value ≈ 1.78e9) but the nexus source was
505
+ * labeled `milliseconds`, causing these values to be divided by 1000 and converted
506
+ * to a 1970 date.
507
+ *
508
+ * ## NULL handling
509
+ *
510
+ * A NULL source value is preserved as NULL so it passes the `IS NULL` branch of
511
+ * the ISO GLOB CHECK constraint on the target column.
512
+ *
513
+ * @param srcRef - SQL expression referencing the source column value.
514
+ * @returns A SQL CASE expression producing an ISO-8601 TEXT timestamp.
515
+ */
516
+ function buildEpochToIsoExpr(srcRef) {
517
+ return (`CASE` +
518
+ ` WHEN ${srcRef} IS NULL THEN NULL` +
519
+ ` WHEN ${srcRef} < ${EPOCH_SECONDS_THRESHOLD}` +
520
+ ` THEN strftime('%Y-%m-%dT%H:%M:%fZ', ${srcRef}, 'unixepoch')` +
521
+ ` ELSE strftime('%Y-%m-%dT%H:%M:%fZ', ${srcRef}/1000.0, 'unixepoch')` +
522
+ ` END`);
523
+ }
524
+ /**
525
+ * Parse the DDL for a given table from `sqlite_master` and return the set of
526
+ * column names that have an ISO GLOB CHECK constraint.
527
+ *
528
+ * Reads the raw DDL text and uses a regex to extract column names appearing in
529
+ * `CHECK ("colname" IS NULL OR "colname" GLOB '[0-9]...')` patterns. This is
530
+ * robust to Drizzle's generated CHECK format (all CHECK constraints generated
531
+ * by T11363 follow this exact pattern).
532
+ *
533
+ * @param db - Target DB with the consolidated schema.
534
+ * @param tableName - Physical table name (consolidated, e.g. `conduit_messages`).
535
+ * @param targetSchema - Schema name the target table lives in (`'main'`, or an
536
+ * ATTACH alias for cross-scope routing — ADR-090 nexus graph residency, T11539).
537
+ * @returns Set of column names that require ISO GLOB validation.
538
+ */
539
+ function detectIsoGlobColumns(db, tableName, targetSchema = 'main') {
540
+ const escapedTable = tableName.replace(/'/g, "''");
541
+ const row = db
542
+ .prepare(`SELECT sql FROM "${targetSchema}".sqlite_master WHERE type='table' AND name='${escapedTable}'`)
543
+ .get();
544
+ if (!row?.sql)
545
+ return new Set();
546
+ const isoColumns = new Set();
547
+ // Pattern: CHECK ("colname" IS NULL OR "colname" GLOB '[0-9]...')
548
+ // The column name appears TWICE — we capture the first occurrence.
549
+ // Use matchAll to avoid the biome no-assign-in-expressions rule.
550
+ ISO_CHECK_REGEX.lastIndex = 0; // reset before reuse (global regex stateful)
551
+ for (const match of row.sql.matchAll(ISO_CHECK_REGEX)) {
552
+ isoColumns.add(match[1]);
553
+ }
554
+ return isoColumns;
555
+ }
556
+ /**
557
+ * Build a SQL SELECT expression for a shared column, applying (in priority order):
558
+ *
559
+ * 1. **Epoch→ISO-8601 coercion** (T11546): when the target has an ISO GLOB CHECK
560
+ * and the source column is INTEGER-typed.
561
+ * 2. **Enum-value normalization** (T11547): when `ENUM_NORMALIZATIONS` has an
562
+ * entry for `(targetTableName, col)`, producing a SQL CASE expression that
563
+ * maps legacy values to canonical enum members without losing semantics.
564
+ * 3. **NOT NULL coalesce** (T11533): for non-epoch, non-normalized columns whose
565
+ * target is NOT NULL with no schema default.
566
+ * 4. **Plain column reference** otherwise.
567
+ *
568
+ * The epoch→ISO conversion uses `buildEpochToIsoExpr` which detects the epoch
569
+ * scale (seconds vs milliseconds) per-row using a magnitude heuristic: values
570
+ * below {@link EPOCH_SECONDS_THRESHOLD} (1e11) are treated as seconds; values at
571
+ * or above are treated as milliseconds and divided by 1000.0 before conversion
572
+ * (T11549 correctness fix — the previous per-source unit was incorrect for
573
+ * individual columns that diverged from the source default, e.g.
574
+ * `nexus.user_profile.first_observed_at` stores SECONDS even though most nexus
575
+ * writers use milliseconds).
576
+ *
577
+ * A NULL source value is preserved as NULL (passes the `IS NULL` branch of the
578
+ * GLOB CHECK, and is OK for nullable columns).
579
+ *
580
+ * @param attachAlias - ATTACH alias for the source DB.
581
+ * @param legacyTable - Legacy table name in the source.
582
+ * @param targetTableName - Physical consolidated target table name (for enum lookup).
583
+ * @param col - Column name.
584
+ * @param srcType - Raw type string from source `PRAGMA table_info`.
585
+ * @param tgtInfo - Target column metadata from `PRAGMA table_info`.
586
+ * @param isoGlobCols - Set of columns requiring ISO GLOB in the target.
587
+ * @returns SQL expression string suitable for use in a SELECT clause.
588
+ */
589
+ function buildSelectExpr(attachAlias, legacyTable, targetTableName, col, srcType, tgtInfo, isoGlobCols) {
590
+ const srcRef = `"${attachAlias}"."${legacyTable}"."${col}"`;
591
+ const srcUpper = srcType.toUpperCase();
592
+ const isIntegerSource = srcUpper.includes('INT') || srcUpper === '' || srcUpper === 'NUMERIC';
593
+ const isNotNullWithoutDefault = tgtInfo.notnull === 1 && tgtInfo.dflt_value === null;
594
+ // Priority 1: Epoch→ISO coercion (T11546 + T11549) — applies when target has
595
+ // ISO GLOB CHECK and source column is INTEGER (epoch) typed. Uses per-row
596
+ // magnitude detection to distinguish seconds from milliseconds (T11549 fix).
597
+ if (isoGlobCols.has(col) && isIntegerSource) {
598
+ // Magnitude-based heuristic: value < 1e11 → seconds, ≥ 1e11 → milliseconds.
599
+ // Each row is classified independently — no reliance on a per-source label.
600
+ const isoExpr = buildEpochToIsoExpr(srcRef);
601
+ // If the target is NOT NULL without a default, COALESCE to '' to avoid a separate
602
+ // constraint violation (though a NULL epoch is anomalous data).
603
+ if (isNotNullWithoutDefault) {
604
+ return `COALESCE(${isoExpr}, '') AS "${col}"`;
605
+ }
606
+ return `${isoExpr} AS "${col}"`;
607
+ }
608
+ // Priority 2: Enum-value normalization (T11547) — maps legacy enum values to
609
+ // canonical members so CHECK constraints accept them.
610
+ const normExpr = enumNormExpr(targetTableName, col, srcRef);
611
+ if (normExpr !== null) {
612
+ // Wrap in COALESCE if the target is NOT NULL without a default, so NULL
613
+ // source values get a safe fallback instead of triggering a constraint drop.
614
+ if (isNotNullWithoutDefault) {
615
+ const defLiteral = typeDefaultLiteral(tgtInfo.type);
616
+ return `COALESCE(${normExpr}, ${defLiteral}) AS "${col}"`;
617
+ }
618
+ return `${normExpr} AS "${col}"`;
619
+ }
620
+ // Priority 3: Standard NOT NULL coalesce for non-epoch, non-normalized columns
621
+ // (T11533 fix preserved).
622
+ if (isNotNullWithoutDefault) {
623
+ const defLiteral = typeDefaultLiteral(tgtInfo.type);
624
+ return `COALESCE(${srcRef}, ${defLiteral}) AS "${col}"`;
625
+ }
626
+ return srcRef;
627
+ }
628
+ /**
629
+ * Copy all rows from a legacy source table (in the already-attached alias) into
630
+ * the corresponding consolidated target table.
631
+ *
632
+ * ## What changed in T11532 vs the T11531 version:
633
+ *
634
+ * 1. **Name mapping (ROOT CAUSE 1 — T11532)**: `legacyTableName` is resolved to
635
+ * its consolidated name via `resolveConsolidatedTableName()`. Without this,
636
+ * `tasks` (legacy) was looked up as `main."tasks"` which doesn't exist in
637
+ * the consolidated schema (the real target is `tasks_tasks`).
638
+ *
639
+ * 2. **Column-drift tolerance (ROOT CAUSE 2 — T11532 + T11533)**: the INSERT
640
+ * uses the INTERSECTION of source and target column lists rather than source
641
+ * columns verbatim. When the consolidated schema added new columns, old code
642
+ * failed. Target-only NOT NULL columns without defaults now get COALESCE()
643
+ * wrapping in the SELECT so NULL source values don't cause silent row drops.
644
+ *
645
+ * 3. **Explicit skip (ROOT CAUSE 5)**: tables intentionally excluded from the
646
+ * consolidated schema (virtual tables, orphan telemetry, etc.) now return
647
+ * a logged skip result rather than being silently treated as "target not
648
+ * found".
649
+ *
650
+ * 4. **Epoch→ISO coercion (ROOT CAUSE 1 — T11546)**: columns with an ISO GLOB
651
+ * CHECK in the target that are INTEGER-typed in the source are converted via
652
+ * `strftime('%Y-%m-%dT%H:%M:%fZ', col[/1000.0], 'unixepoch')`. Without this,
653
+ * `INSERT OR IGNORE` silently drops ALL rows for those tables (CHECK fails
654
+ * for every row because an integer like `1717200000` doesn't match the GLOB).
655
+ *
656
+ * 5. **No-swallow assertion (ROOT CAUSE 1b — T11546)**: after the bulk INSERT,
657
+ * `changes` is compared against the source row count. A shortfall is a hard
658
+ * per-table error. PK/UNIQUE conflicts on idempotent resume are expected and
659
+ * tolerated (checked via count of existing rows); CHECK constraint drops are
660
+ * not tolerated.
661
+ *
662
+ * **Pre-condition**: the caller has already executed
663
+ * `ATTACH DATABASE '<path>' AS "<attachAlias>"` on `targetNativeDb`, and
664
+ * `PRAGMA foreign_keys = OFF` has been set so FK ordering doesn't matter.
665
+ *
666
+ * ## Cross-scope routing (ADR-090 · T11539)
667
+ *
668
+ * `targetSchema` defaults to `'main'` (the connection's own consolidated DB). For
669
+ * the four nexus code-graph tables, which are extracted from the GLOBAL `nexus.db`
670
+ * source but must land in the PROJECT consolidated `cleo.db`, the caller attaches
671
+ * the project DB under an alias on the SAME connection and passes that alias as
672
+ * `targetSchema`. All target-side introspection (`sqlite_master`, `PRAGMA
673
+ * table_info`) and the `INSERT OR IGNORE` are then schema-qualified to that
674
+ * attached DB instead of `main`.
675
+ *
676
+ * @param targetNativeDb - Writable target handle (mid-transaction, FK OFF).
677
+ * @param srcNativeDb - Read-only source snapshot (for metadata queries).
678
+ * @param attachAlias - Alias under which the source is attached.
679
+ * @param legacyTableName - Physical table name in the legacy source DB.
680
+ * @param sourceName - `LegacyDbDescriptor.name` (for name resolution).
681
+ * @param targetSchema - Schema the consolidated target table lives in
682
+ * (`'main'` by default, or an ATTACH alias for cross-scope routing).
683
+ */
684
+ function copyTableFromAttached(targetNativeDb, srcNativeDb, attachAlias, legacyTableName, sourceName, targetSchema = 'main') {
685
+ // --- Step 1: Resolve the consolidated target table name (ROOT CAUSE 1) ---
686
+ const resolution = resolveConsolidatedTableName(sourceName, legacyTableName);
687
+ if (resolution.kind === 'skip') {
688
+ log.warn({ legacyTableName, sourceName, reason: resolution.reason }, `Exodus: explicitly skipping table — ${resolution.reason}`);
689
+ return { rowsCopied: 0, skipped: true, reason: resolution.reason };
690
+ }
691
+ const targetTableName = resolution.targetName;
692
+ // --- Step 2: Get source column list (full pragma for type info) ---
693
+ const srcPragma = srcNativeDb.prepare(`PRAGMA table_info("${legacyTableName}")`).all();
694
+ const srcColumns = new Set(srcPragma.map((r) => r.name));
695
+ if (srcColumns.size === 0)
696
+ return { rowsCopied: 0, skipped: false };
697
+ // --- Step 3: Check source row count (skip INSERT if empty to avoid noise) ---
698
+ const countRow = srcNativeDb.prepare(`SELECT COUNT(*) AS c FROM "${legacyTableName}"`).get();
699
+ const sourceCount = countRow?.c ?? 0;
700
+ if (sourceCount === 0)
701
+ return { rowsCopied: 0, skipped: false };
702
+ // --- Step 4: Check the consolidated target table exists ---
703
+ // Schema-qualified so cross-scope routing (ADR-090 T11539) introspects the
704
+ // attached project DB, not `main`, for the four nexus graph tables.
705
+ const escapedTarget = targetTableName.replace(/'/g, "''");
706
+ const existsRow = targetNativeDb
707
+ .prepare(`SELECT name FROM "${targetSchema}".sqlite_master WHERE type='table' AND name='${escapedTarget}'`)
708
+ .get();
709
+ if (!existsRow) {
710
+ // Target table absent — log and treat as explicit skip
711
+ const reason = `consolidated target '${targetTableName}' not found (mapped from legacy '${legacyTableName}')`;
712
+ log.warn({ legacyTableName, targetTableName, sourceName, attachAlias, targetSchema }, `Exodus: ${reason}`);
713
+ return { rowsCopied: 0, skipped: true, reason };
714
+ }
715
+ // --- Step 5: Compute column intersection (ROOT CAUSE 2 — T11532 + T11533) ---
716
+ const tgtPragma = targetNativeDb
717
+ .prepare(`PRAGMA "${targetSchema}".table_info("${targetTableName}")`)
718
+ .all();
719
+ // Build a lookup for target columns (type + notNull + dflt_value)
720
+ const tgtColMap = new Map(tgtPragma.map((r) => [r.name, r]));
721
+ // Only copy columns that exist in BOTH source and target.
722
+ const sharedColumns = srcPragma.map((r) => r.name).filter((col) => tgtColMap.has(col));
723
+ if (sharedColumns.length === 0) {
724
+ const reason = `no overlapping columns between source '${legacyTableName}' and target '${targetTableName}'`;
725
+ log.warn({ legacyTableName, targetTableName, sourceName }, `Exodus: ${reason}`);
726
+ return { rowsCopied: 0, skipped: true, reason };
727
+ }
728
+ const srcOnlyColumns = srcPragma.map((r) => r.name).filter((c) => !tgtColMap.has(c));
729
+ const tgtOnlyColumns = tgtPragma.map((r) => r.name).filter((c) => !srcColumns.has(c));
730
+ if (srcOnlyColumns.length > 0 || tgtOnlyColumns.length > 0) {
731
+ log.info({ legacyTableName, targetTableName, sourceName, srcOnlyColumns, tgtOnlyColumns }, 'Exodus: column drift detected — copying intersection, dropping src-only cols, using defaults for tgt-only cols');
732
+ }
733
+ // --- Step 5b: Detect ISO GLOB columns in the target (T11546 epoch coercion) ---
734
+ //
735
+ // Read the target table DDL to find columns with ISO-8601 GLOB CHECK constraints.
736
+ // For those columns where the source is INTEGER-typed, we inject a strftime()
737
+ // expression in the SELECT so the inserted value passes the GLOB check.
738
+ // T11549: the scale (seconds vs ms) is now detected per-row by magnitude heuristic
739
+ // inside buildSelectExpr via buildEpochToIsoExpr — the per-source hint is kept for
740
+ // logging only.
741
+ const isoGlobCols = detectIsoGlobColumns(targetNativeDb, targetTableName, targetSchema);
742
+ const epochUnitHint = epochUnitForSource(sourceName);
743
+ // Build a map of source column types for quick lookup in buildSelectExpr.
744
+ const srcTypeMap = new Map(srcPragma.map((r) => [r.name, r.type]));
745
+ if (isoGlobCols.size > 0) {
746
+ // Log which columns will be coerced so the migration journal is traceable.
747
+ const coercedCols = sharedColumns.filter((col) => {
748
+ const srcType = srcTypeMap.get(col) ?? '';
749
+ const upper = srcType.toUpperCase();
750
+ return isoGlobCols.has(col) && (upper.includes('INT') || upper === '' || upper === 'NUMERIC');
751
+ });
752
+ if (coercedCols.length > 0) {
753
+ log.info({
754
+ legacyTableName,
755
+ targetTableName,
756
+ sourceName,
757
+ coercedCols,
758
+ epochUnitHint,
759
+ }, `Exodus: applying epoch→ISO coercion (magnitude heuristic) for ${coercedCols.length} column(s) (T11546+T11549)`);
760
+ }
761
+ }
762
+ // --- Step 5c: Detect enum-normalized columns in this target table (T11547) ---
763
+ //
764
+ // Log which columns have a normalization rule so the migration journal is
765
+ // traceable and operators can verify the mapping was applied.
766
+ const normalizedCols = sharedColumns.filter((col) => ENUM_NORMALIZATIONS.has(`${targetTableName}.${col}`));
767
+ if (normalizedCols.length > 0) {
768
+ log.info({ legacyTableName, targetTableName, sourceName, normalizedCols }, `Exodus: applying enum-value normalization for ${normalizedCols.length} column(s) (T11547)`);
769
+ }
770
+ // --- Step 6: Build the SELECT expression list ---
771
+ //
772
+ // For each shared column, `buildSelectExpr` handles (priority order):
773
+ // 1. Epoch→ISO coercion when target has ISO GLOB CHECK and source is INTEGER (T11546)
774
+ // 2. Enum-value normalization for legacy values not in the consolidated CHECK (T11547)
775
+ // 3. COALESCE for NOT NULL target columns without schema defaults (T11533)
776
+ // 4. Plain column reference otherwise
777
+ const selectExprs = sharedColumns.map((col) => {
778
+ const srcType = srcTypeMap.get(col) ?? '';
779
+ const tgtInfo = tgtColMap.get(col);
780
+ return buildSelectExpr(attachAlias, legacyTableName, targetTableName, col, srcType, tgtInfo, isoGlobCols);
781
+ });
782
+ // --- Step 6b: Handle target-only NOT NULL columns without schema defaults ---
783
+ //
784
+ // If a target-only column is NOT NULL with no dflt_value, omitting it from
785
+ // the INSERT causes a "NOT NULL constraint failed" error, which INSERT OR IGNORE
786
+ // silently converts to a dropped row. We must include these columns in the
787
+ // INSERT with a literal type-default value so every row survives. (T11533 fix)
788
+ const tgtOnlyNotNullCols = tgtOnlyColumns.filter((col) => {
789
+ const info = tgtColMap.get(col);
790
+ return info !== undefined && info.notnull === 1 && info.dflt_value === null;
791
+ });
792
+ const allInsertCols = [...sharedColumns, ...tgtOnlyNotNullCols];
793
+ const allSelectExprs = [
794
+ ...selectExprs,
795
+ ...tgtOnlyNotNullCols.map((col) => {
796
+ const info = tgtColMap.get(col);
797
+ return `${typeDefaultLiteral(info.type)} AS "${col}"`;
798
+ }),
799
+ ];
800
+ const colList = allInsertCols.map((c) => `"${c}"`).join(', ');
801
+ const selectList = allSelectExprs.join(', ');
802
+ // INSERT OR IGNORE so idempotency keys prevent duplicates on resume.
803
+ // The source alias uses legacyTableName; the target uses consolidatedName.
804
+ // OR IGNORE fires on PK/UNIQUE conflicts (safe for idempotent resume) AND
805
+ // on CHECK constraint violations (dangerous — must detect and report).
806
+ // `targetSchema` is `main` by default; for cross-scope nexus graph tables
807
+ // (ADR-090 T11539) it is the ATTACH alias of the project consolidated cleo.db.
808
+ const stmt = targetNativeDb.prepare(`INSERT OR IGNORE INTO "${targetSchema}"."${targetTableName}" (${colList}) ` +
809
+ `SELECT ${selectList} FROM "${attachAlias}"."${legacyTableName}"`);
810
+ const result = stmt.run();
811
+ const rowsCopied = result.changes ?? 0;
812
+ // --- Step 7: No-swallow assertion (ROOT CAUSE 1b — T11546) ---
813
+ //
814
+ // If rowsCopied < sourceCount, rows were silently dropped. This can happen for
815
+ // two reasons:
816
+ // a) PK/UNIQUE conflict on idempotent resume — EXPECTED and SAFE (the data
817
+ // is already in the target from a previous run).
818
+ // b) CHECK / NOT NULL / type constraint violation — DATA LOSS, must error.
819
+ //
820
+ // We distinguish these by counting existing target rows BEFORE the INSERT
821
+ // and comparing: if (existingBefore + sourceCount) > rowsCopied + existingBefore,
822
+ // some rows were dropped by constraints rather than deduplicated. However,
823
+ // since we are mid-transaction and do not know existingBefore (prior sources
824
+ // may have written to the same table), we take a simpler approach: if
825
+ // rowsCopied == 0 AND sourceCount > 0, this is almost certainly a constraint
826
+ // failure (a full-table dedup on resume would be extremely unusual). If
827
+ // rowsCopied < sourceCount but > 0, it may be a partial dedup. We log a
828
+ // warning for partial losses and a hard error for full (0-row) losses.
829
+ //
830
+ // The verifier (`runExodusVerify`) catches any remaining discrepancy post-hoc.
831
+ if (rowsCopied < sourceCount) {
832
+ const dropped = sourceCount - rowsCopied;
833
+ if (rowsCopied === 0) {
834
+ // Full table drop — almost certainly a CHECK or type constraint failure.
835
+ const reason = `INSERT OR IGNORE dropped ALL ${sourceCount} rows from '${legacyTableName}'→'${targetTableName}' (rowsCopied=0, sourceCount=${sourceCount}). Likely a CHECK/type constraint violation — check epoch coercion or enum values.`;
836
+ log.error({ legacyTableName, targetTableName, sourceName, sourceCount, rowsCopied }, `Exodus: ${reason}`);
837
+ return { rowsCopied: 0, skipped: false, reason };
838
+ }
839
+ // Partial drop — could be UNIQUE dedup on resume or a real constraint drop.
840
+ // Log as warning and let the verifier catch genuine losses.
841
+ log.warn({ legacyTableName, targetTableName, sourceName, sourceCount, rowsCopied, dropped }, `Exodus: INSERT OR IGNORE dropped ${dropped}/${sourceCount} rows from '${legacyTableName}'→'${targetTableName}' — may be idempotent-resume dedup or a constraint violation; verify will confirm`);
842
+ }
843
+ return { rowsCopied, skipped: false };
844
+ }
845
+ // ---------------------------------------------------------------------------
846
+ // Main migration runner
847
+ // ---------------------------------------------------------------------------
848
+ /**
849
+ * Validate that the journal's schema version matches the current target version.
850
+ *
851
+ * When `forceCrossVersion === true`, mismatches are logged but not fatal.
852
+ */
853
+ function checkSchemaVersion(journal, forceCrossVersion) {
854
+ if (journal.targetSchemaVersion !== EXODUS_TARGET_SCHEMA_VERSION) {
855
+ const msg = `Schema version mismatch: journal=${journal.targetSchemaVersion}, expected=${EXODUS_TARGET_SCHEMA_VERSION}`;
856
+ if (forceCrossVersion) {
857
+ log.warn(msg + ' (--force-cross-version: continuing anyway)');
858
+ return true;
859
+ }
860
+ log.error(msg + ' — pass --force-cross-version to override');
861
+ return false;
862
+ }
863
+ return true;
864
+ }
865
+ /**
866
+ * Run the exodus migration.
867
+ *
868
+ * @param plan - Pre-flight plan from `buildExodusPlan()`.
869
+ * @param forceCrossVersion - Skip the schema-version guard (AC9).
870
+ * @param onProgress - Optional progress callback called after each table.
871
+ *
872
+ * @returns {@link ExodusMigrateResult}
873
+ *
874
+ * @task T11248 (AC4, AC5, AC6, AC7, AC9)
875
+ * @task T11531 (P0 attach-leak fix)
876
+ */
877
+ export async function runExodusMigrate(plan, forceCrossVersion = false, onProgress) {
878
+ const { sources, stagingDir, diskPreflight, projectDbPath } = plan;
879
+ // AC8: disk pre-flight
880
+ if (!diskPreflight) {
881
+ return {
882
+ ok: false,
883
+ tables: [],
884
+ stagingDir,
885
+ backupPaths: [],
886
+ error: `Insufficient disk space: need ≥3× source size (${plan.totalSourceBytes} bytes source, ${plan.availableBytes} bytes available). Free up space or use a different storage location.`,
887
+ };
888
+ }
889
+ // Ensure staging directory exists (AC5)
890
+ mkdirSync(stagingDir, { recursive: true });
891
+ // Determine SQLite version from the first available source DB
892
+ let sqliteVersion = 'unknown';
893
+ for (const src of sources) {
894
+ if (existsSync(src.path)) {
895
+ const snap = openCleoDbSnapshot(src.path, { readOnly: true });
896
+ sqliteVersion = getSqliteVersion(snap.db);
897
+ snap.close();
898
+ break;
899
+ }
900
+ }
901
+ // Load or initialise the journal (AC5 — resume from staging)
902
+ let journal = readJournal(stagingDir);
903
+ if (journal === null) {
904
+ journal = initJournal(sqliteVersion);
905
+ }
906
+ else {
907
+ // Existing journal — check schema version (AC9)
908
+ if (!checkSchemaVersion(journal, forceCrossVersion)) {
909
+ return {
910
+ ok: false,
911
+ tables: [],
912
+ stagingDir,
913
+ backupPaths: [],
914
+ error: 'Schema version mismatch. Pass --force-cross-version to override.',
915
+ };
916
+ }
917
+ onProgress?.('Resuming from existing staging journal…');
918
+ }
919
+ const backupPaths = [];
920
+ const allTableResults = [];
921
+ const lockedPaths = [];
922
+ try {
923
+ // 1. Back up existing source DBs into staging dir and acquire advisory locks
924
+ for (const src of sources) {
925
+ if (!existsSync(src.path))
926
+ continue;
927
+ const backupDest = join(stagingDir, `${src.name.replace(/[^a-z0-9-]/g, '_')}-backup.db`);
928
+ if (!existsSync(backupDest)) {
929
+ onProgress?.(`Backing up ${src.name} → staging dir…`);
930
+ copyFileSync(src.path, backupDest);
931
+ backupPaths.push(backupDest);
932
+ }
933
+ // AC4: advisory lock sentinel
934
+ acquireAdvisoryLock(src.path);
935
+ lockedPaths.push(src.path);
936
+ }
937
+ // 2. Open (or create) the consolidated target DBs via the chokepoint.
938
+ // This runs Drizzle migrations to create the target schema.
939
+ onProgress?.('Opening consolidated project-scope cleo.db (running migrations)…');
940
+ // openDualScopeDb takes cwd, not a db path — pass undefined to use process.cwd()
941
+ const projectHandle = await openDualScopeDb('project');
942
+ onProgress?.('Opening consolidated global-scope cleo.db (running migrations)…');
943
+ const globalHandle = await openDualScopeDb('global');
944
+ // Extract the raw DatabaseSync from the Drizzle wrapper ($client pattern).
945
+ function extractNativeDb(handle) {
946
+ const drizzleHandle = handle.db;
947
+ const client = drizzleHandle['$client'];
948
+ if (client && typeof client['prepare'] === 'function') {
949
+ return client;
950
+ }
951
+ // Fallback: the handle itself may be a DatabaseSync (unlikely but safe)
952
+ if (typeof drizzleHandle['prepare'] === 'function') {
953
+ return drizzleHandle;
954
+ }
955
+ throw new Error('Could not extract native DatabaseSync from dual-scope DB handle');
956
+ }
957
+ const projectNative = extractNativeDb(projectHandle);
958
+ const globalNative = extractNativeDb(globalHandle);
959
+ // 3. Per-scope sources migration (AC6)
960
+ const projectSources = sources.filter((s) => s.targetScope === 'project' && existsSync(s.path));
961
+ const globalSources = sources.filter((s) => s.targetScope === 'global' && existsSync(s.path));
962
+ await migrateScope('project', projectSources, projectNative, journal, stagingDir, allTableResults, onProgress);
963
+ // Cross-scope routing (ADR-090 · T11539): the four nexus graph tables come
964
+ // from the GLOBAL `nexus.db` source but land in the PROJECT consolidated
965
+ // cleo.db. Pass the project DB path so the global pass can attach it and
966
+ // route those tables there. The project pass already committed + the project
967
+ // handle is idle, so the cross-attach write is the sole writer (WAL-safe).
968
+ await migrateScope('global', globalSources, globalNative, journal, stagingDir, allTableResults, onProgress, projectDbPath);
969
+ // Final journal update
970
+ journal.updatedAt = new Date().toISOString();
971
+ writeJournal(stagingDir, journal);
972
+ projectHandle.close();
973
+ globalHandle.close();
974
+ return { ok: true, tables: allTableResults, stagingDir, backupPaths };
975
+ }
976
+ catch (err) {
977
+ const error = err instanceof Error ? err.message : String(err);
978
+ log.error({ err }, 'Exodus migration failed');
979
+ return { ok: false, tables: allTableResults, stagingDir, backupPaths, error };
980
+ }
981
+ finally {
982
+ // Release advisory locks
983
+ for (const p of lockedPaths) {
984
+ releaseAdvisoryLock(p);
985
+ }
986
+ }
987
+ }
988
+ /**
989
+ * Migrate all tables from the given sources into the target native DB.
990
+ *
991
+ * ## ATTACH-once-per-source protocol (T11531)
992
+ *
993
+ * SQLite forbids `DETACH` inside an active multi-statement transaction.
994
+ * To avoid the "database alias is already in use" error that caused ~80 %
995
+ * data loss, we use the following sequence **per source DB**:
996
+ *
997
+ * 1. ATTACH the source path under a unique alias (outside any transaction).
998
+ * 2. Open a read-only snapshot of the source for metadata queries.
999
+ * 3. BEGIN the write transaction on the target.
1000
+ * 4. INSERT OR IGNORE … SELECT for each table using the attached alias.
1001
+ * 5. COMMIT (or ROLLBACK on error).
1002
+ * 6. DETACH the source alias in `finally` (outside the committed transaction).
1003
+ * 7. Close the read-only snapshot.
1004
+ *
1005
+ * Each source gets its own unique alias (`_src_<name>_<index>`) so multiple
1006
+ * sources can be processed sequentially without alias conflicts.
1007
+ *
1008
+ * ## FK-defer protocol (T11533 ROOT CAUSE 1)
1009
+ *
1010
+ * Before the first INSERT in any scope, foreign-key enforcement is switched OFF
1011
+ * on the target connection (`PRAGMA foreign_keys = OFF`) so copy order does not
1012
+ * matter (avoids "FOREIGN KEY constraint failed" for child-before-parent copies).
1013
+ * After all sources in the scope are committed, `PRAGMA foreign_key_check` is
1014
+ * executed to surface genuinely orphaned rows, and then FK enforcement is
1015
+ * restored (`PRAGMA foreign_keys = ON`).
1016
+ *
1017
+ * Sequence across all sources in a scope:
1018
+ * PRAGMA foreign_keys = OFF
1019
+ * for each source:
1020
+ * ATTACH … AS alias (outside tx)
1021
+ * BEGIN
1022
+ * INSERT … SELECT for each table
1023
+ * COMMIT
1024
+ * DETACH alias (outside tx)
1025
+ * PRAGMA foreign_key_check → log orphans as warnings
1026
+ * PRAGMA foreign_keys = ON
1027
+ */
1028
+ async function migrateScope(scope, sources, targetNativeDb, journal, stagingDir, allTableResults, onProgress, crossScopeTargetPath) {
1029
+ if (sources.length === 0)
1030
+ return;
1031
+ onProgress?.(`Migrating ${scope}-scope sources…`);
1032
+ // FK-defer: disable FK enforcement for the entire scope's bulk copy so that
1033
+ // copy order (child-before-parent) does not cause constraint failures.
1034
+ // Restored + checked after all sources in this scope are committed (T11533).
1035
+ targetNativeDb.exec('PRAGMA foreign_keys = OFF');
1036
+ log.info({ scope }, 'Exodus: foreign_keys=OFF for bulk copy (T11533 FK-defer)');
1037
+ try {
1038
+ for (let i = 0; i < sources.length; i++) {
1039
+ const src = sources[i];
1040
+ const attachAlias = makeAttachAlias(src.name, i);
1041
+ const escapedPath = src.path.replace(/'/g, "''");
1042
+ // Step 1: ATTACH the source outside any transaction (SQLite restriction).
1043
+ // The finally block below guarantees DETACH runs even on error.
1044
+ targetNativeDb.exec(`ATTACH DATABASE '${escapedPath}' AS "${attachAlias}"`);
1045
+ onProgress?.(` [${src.name}] Attached as "${attachAlias}"`);
1046
+ // Cross-scope routing (ADR-090 · T11539): the four nexus graph tables are
1047
+ // extracted from the GLOBAL `nexus.db` source but land in PROJECT scope.
1048
+ // When such tables exist for this source, ATTACH the cross-scope target
1049
+ // consolidated `cleo.db` onto THIS connection so their INSERT … SELECT can
1050
+ // schema-qualify the destination without a second connection/transaction.
1051
+ let crossAlias = null;
1052
+ if (crossScopeTargetPath) {
1053
+ crossAlias = `_xscope_${attachAlias}`;
1054
+ const escapedCrossPath = crossScopeTargetPath.replace(/'/g, "''");
1055
+ targetNativeDb.exec(`ATTACH DATABASE '${escapedCrossPath}' AS "${crossAlias}"`);
1056
+ onProgress?.(` [${src.name}] Cross-scope target attached as "${crossAlias}"`);
1057
+ }
1058
+ // Step 2: Open a read-only snapshot for metadata (column info, counts).
1059
+ const snap = openCleoDbSnapshot(src.path, { readOnly: true });
1060
+ try {
1061
+ const tables = listTables(snap.db);
1062
+ // Step 3: BEGIN the transaction for this source's copy batch (AC6).
1063
+ // Per-source transactions mean a failing source does not roll back
1064
+ // previously-copied sources.
1065
+ targetNativeDb.exec('BEGIN');
1066
+ let txOpen = true;
1067
+ try {
1068
+ for (const tableName of tables) {
1069
+ // Check journal for resume (AC5)
1070
+ const existing = journal.tables.find((e) => e.sourceDb === src.name && e.tableName === tableName);
1071
+ if (existing?.status === 'done') {
1072
+ onProgress?.(` ↳ ${src.name}.${tableName} — already done (resuming)`);
1073
+ allTableResults.push({
1074
+ sourceDb: src.name,
1075
+ tableName,
1076
+ rowsCopied: existing.rowsCopied,
1077
+ skipped: false,
1078
+ });
1079
+ continue;
1080
+ }
1081
+ onProgress?.(` ↳ Copying ${src.name}.${tableName}…`);
1082
+ let rowsCopied = 0;
1083
+ let status = 'done';
1084
+ let errorMsg;
1085
+ let skipped = false;
1086
+ try {
1087
+ // Step 4: INSERT using the already-attached alias — no per-table ATTACH/DETACH.
1088
+ // FK enforcement is OFF (set at scope start), so copy order does not matter.
1089
+ // Pass src.name so copyTableFromAttached can resolve the consolidated target name.
1090
+ //
1091
+ // Cross-scope routing (ADR-090 · T11539): if this table's effective
1092
+ // scope differs from the loop scope (the four nexus graph tables),
1093
+ // direct the INSERT at the cross-scope target DB attached above.
1094
+ const effectiveScope = resolveTableTargetScope(src.name, tableName, scope);
1095
+ const targetSchema = effectiveScope !== scope && crossAlias ? crossAlias : 'main';
1096
+ if (effectiveScope !== scope && !crossAlias) {
1097
+ throw new Error(`Table '${tableName}' from '${src.name}' routes to ${effectiveScope} scope ` +
1098
+ `but no cross-scope target was attached for the ${scope} pass (ADR-090 T11539)`);
1099
+ }
1100
+ const copyResult = copyTableFromAttached(targetNativeDb, snap.db, attachAlias, tableName, src.name, targetSchema);
1101
+ rowsCopied = copyResult.rowsCopied;
1102
+ if (copyResult.skipped) {
1103
+ status = 'skipped';
1104
+ errorMsg = copyResult.reason;
1105
+ skipped = true;
1106
+ }
1107
+ else if (copyResult.reason) {
1108
+ // No-swallow error: all rows dropped by a constraint (T11546).
1109
+ // The table is NOT skipped (copy was attempted) but the result
1110
+ // must be surfaced as an error, not a silent 0-row success.
1111
+ status = 'skipped'; // Mark skipped so journal doesn't say "done" on 0 rows
1112
+ errorMsg = copyResult.reason;
1113
+ // skipped stays false — the distinction is the reason field (data loss vs intentional skip)
1114
+ }
1115
+ }
1116
+ catch (err) {
1117
+ const msg = err instanceof Error ? err.message : String(err);
1118
+ log.warn({ tableName, sourceDb: src.name, err }, 'Table copy failed — skipping');
1119
+ status = 'skipped';
1120
+ errorMsg = msg;
1121
+ skipped = true;
1122
+ }
1123
+ // Update journal entry
1124
+ const entry = {
1125
+ sourceDb: src.name,
1126
+ tableName,
1127
+ status,
1128
+ rowsCopied,
1129
+ updatedAt: new Date().toISOString(),
1130
+ ...(errorMsg ? { error: errorMsg } : {}),
1131
+ };
1132
+ const idx = journal.tables.findIndex((e) => e.sourceDb === src.name && e.tableName === tableName);
1133
+ if (idx >= 0) {
1134
+ journal.tables[idx] = entry;
1135
+ }
1136
+ else {
1137
+ journal.tables.push(entry);
1138
+ }
1139
+ journal.updatedAt = new Date().toISOString();
1140
+ // Atomic journal write after each table (AC5 — crash-resumable)
1141
+ writeJournal(stagingDir, journal);
1142
+ allTableResults.push({
1143
+ sourceDb: src.name,
1144
+ tableName,
1145
+ rowsCopied,
1146
+ skipped,
1147
+ reason: errorMsg,
1148
+ });
1149
+ }
1150
+ // Step 5: COMMIT all copies for this source.
1151
+ targetNativeDb.exec('COMMIT');
1152
+ txOpen = false;
1153
+ }
1154
+ catch (err) {
1155
+ if (txOpen) {
1156
+ try {
1157
+ targetNativeDb.exec('ROLLBACK');
1158
+ }
1159
+ catch {
1160
+ // ignore rollback errors
1161
+ }
1162
+ }
1163
+ throw err;
1164
+ }
1165
+ }
1166
+ finally {
1167
+ // Step 7: Close the read-only metadata snapshot.
1168
+ snap.close();
1169
+ // Step 6: DETACH the source alias — executed outside the (now committed
1170
+ // or rolled-back) transaction so SQLite allows it.
1171
+ try {
1172
+ targetNativeDb.exec(`DETACH DATABASE "${attachAlias}"`);
1173
+ onProgress?.(` [${src.name}] Detached "${attachAlias}"`);
1174
+ }
1175
+ catch (detachErr) {
1176
+ // Log but do not re-throw — a failed DETACH is non-fatal for the
1177
+ // migrated data; the alias will be released when the target DB closes.
1178
+ log.warn({ attachAlias, sourceDb: src.name, err: detachErr }, 'DETACH failed — alias will be released on DB close');
1179
+ }
1180
+ // Detach the cross-scope target alias (ADR-090 · T11539), if attached.
1181
+ if (crossAlias) {
1182
+ try {
1183
+ targetNativeDb.exec(`DETACH DATABASE "${crossAlias}"`);
1184
+ onProgress?.(` [${src.name}] Cross-scope target detached "${crossAlias}"`);
1185
+ }
1186
+ catch (detachErr) {
1187
+ log.warn({ crossAlias, sourceDb: src.name, err: detachErr }, 'Cross-scope DETACH failed — alias will be released on DB close');
1188
+ }
1189
+ }
1190
+ }
1191
+ } // end for loop over sources
1192
+ }
1193
+ finally {
1194
+ // FK-check: validate referential integrity AFTER all bulk copies are committed.
1195
+ // Genuine orphan rows surface here as warnings; child-before-parent ordering
1196
+ // artifacts that would have caused "FOREIGN KEY constraint failed" during copy
1197
+ // are now harmless (rows were inserted in FK-OFF mode).
1198
+ try {
1199
+ const orphans = targetNativeDb.prepare('PRAGMA foreign_key_check').all();
1200
+ if (orphans.length > 0) {
1201
+ log.warn({ scope, orphanCount: orphans.length, sample: orphans.slice(0, 5) }, `Exodus: PRAGMA foreign_key_check found ${orphans.length} orphan row(s) after bulk copy — these are genuine data orphans (not ordering artifacts)`);
1202
+ }
1203
+ else {
1204
+ log.info({ scope }, 'Exodus: PRAGMA foreign_key_check PASSED — no orphan rows');
1205
+ }
1206
+ }
1207
+ catch (checkErr) {
1208
+ log.warn({ scope, err: checkErr }, 'Exodus: PRAGMA foreign_key_check failed (non-fatal) — target schema may not have FK constraints enabled');
1209
+ }
1210
+ // Restore FK enforcement on the target connection.
1211
+ try {
1212
+ targetNativeDb.exec('PRAGMA foreign_keys = ON');
1213
+ log.info({ scope }, 'Exodus: foreign_keys=ON restored after bulk copy');
1214
+ }
1215
+ catch (fkErr) {
1216
+ log.warn({ scope, err: fkErr }, 'Exodus: could not restore foreign_keys=ON (non-fatal)');
1217
+ }
1218
+ }
1219
+ }
1220
+ //# sourceMappingURL=migrate.js.map