@cleocode/core 2026.7.2 → 2026.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (297) hide show
  1. package/dist/bootstrap.d.ts.map +1 -1
  2. package/dist/bootstrap.js +57 -27
  3. package/dist/bootstrap.js.map +1 -1
  4. package/dist/conduit/local-transport.d.ts +3 -6
  5. package/dist/conduit/local-transport.d.ts.map +1 -1
  6. package/dist/conduit/local-transport.js +6 -7
  7. package/dist/conduit/local-transport.js.map +1 -1
  8. package/dist/db/index.d.ts +28 -1
  9. package/dist/db/index.d.ts.map +1 -1
  10. package/dist/db/index.js +28 -1
  11. package/dist/db/index.js.map +1 -1
  12. package/dist/docs/display-alias.js +1 -1
  13. package/dist/docs/display-alias.js.map +1 -1
  14. package/dist/docs/export-document.js +16292 -22315
  15. package/dist/docs/export-document.js.map +3 -3
  16. package/dist/docs/numbering.js +1 -1
  17. package/dist/docs/numbering.js.map +1 -1
  18. package/dist/docs/supersede.js +1 -1
  19. package/dist/docs/supersede.js.map +1 -1
  20. package/dist/goal/store.d.ts.map +1 -1
  21. package/dist/goal/store.js.map +1 -1
  22. package/dist/hooks/payload-schemas.d.ts +9 -9
  23. package/dist/injection.d.ts.map +1 -1
  24. package/dist/injection.js +44 -3
  25. package/dist/injection.js.map +1 -1
  26. package/dist/lifecycle/index.js +1 -1
  27. package/dist/lifecycle/index.js.map +1 -1
  28. package/dist/llm/pi/pi-session-storage.d.ts.map +1 -1
  29. package/dist/llm/pi/pi-session-storage.js +6 -1
  30. package/dist/llm/pi/pi-session-storage.js.map +1 -1
  31. package/dist/llm/plugin-facade.js +5726 -16641
  32. package/dist/llm/plugin-facade.js.map +3 -3
  33. package/dist/memory/brain-backfill.js +3 -3
  34. package/dist/memory/brain-backfill.js.map +1 -1
  35. package/dist/memory/brain-consolidator.js +2 -2
  36. package/dist/memory/brain-consolidator.js.map +1 -1
  37. package/dist/memory/brain-doctor.js +1 -1
  38. package/dist/memory/brain-doctor.js.map +1 -1
  39. package/dist/memory/brain-health-dashboard.js +1 -1
  40. package/dist/memory/brain-health-dashboard.js.map +1 -1
  41. package/dist/memory/brain-lifecycle.js +10 -10
  42. package/dist/memory/brain-lifecycle.js.map +1 -1
  43. package/dist/memory/brain-links.d.ts +26 -2
  44. package/dist/memory/brain-links.d.ts.map +1 -1
  45. package/dist/memory/brain-links.js +65 -19
  46. package/dist/memory/brain-links.js.map +1 -1
  47. package/dist/memory/brain-maintenance.js +1 -1
  48. package/dist/memory/brain-maintenance.js.map +1 -1
  49. package/dist/memory/brain-page-nodes.js +4 -4
  50. package/dist/memory/brain-page-nodes.js.map +1 -1
  51. package/dist/memory/brain-reasoning.js +1 -1
  52. package/dist/memory/brain-reasoning.js.map +1 -1
  53. package/dist/memory/brain-search.js +1 -1
  54. package/dist/memory/brain-search.js.map +1 -1
  55. package/dist/memory/brain-similarity.js +1 -1
  56. package/dist/memory/brain-similarity.js.map +1 -1
  57. package/dist/memory/brain-stdp.js +6 -6
  58. package/dist/memory/brain-stdp.js.map +1 -1
  59. package/dist/memory/brain-sweep-executor.js +1 -1
  60. package/dist/memory/brain-sweep-executor.js.map +1 -1
  61. package/dist/memory/brain-writer-handlers.js +2 -2
  62. package/dist/memory/brain-writer-handlers.js.map +1 -1
  63. package/dist/memory/brain-writer-thread.d.ts.map +1 -1
  64. package/dist/memory/brain-writer-thread.js +31 -40
  65. package/dist/memory/brain-writer-thread.js.map +1 -1
  66. package/dist/memory/claude-mem-migration.d.ts.map +1 -1
  67. package/dist/memory/claude-mem-migration.js +9 -3
  68. package/dist/memory/claude-mem-migration.js.map +1 -1
  69. package/dist/memory/dialectic-evaluator.d.ts +11 -6
  70. package/dist/memory/dialectic-evaluator.d.ts.map +1 -1
  71. package/dist/memory/dialectic-evaluator.js +8 -2
  72. package/dist/memory/dialectic-evaluator.js.map +1 -1
  73. package/dist/memory/engine-compat.js +4 -4
  74. package/dist/memory/engine-compat.js.map +1 -1
  75. package/dist/memory/extraction-gate.js +3 -3
  76. package/dist/memory/extraction-gate.js.map +1 -1
  77. package/dist/memory/graph-memory-bridge.d.ts.map +1 -1
  78. package/dist/memory/graph-memory-bridge.js +12 -10
  79. package/dist/memory/graph-memory-bridge.js.map +1 -1
  80. package/dist/memory/graph-queries.js +4 -4
  81. package/dist/memory/graph-queries.js.map +1 -1
  82. package/dist/memory/learnings.d.ts +8 -8
  83. package/dist/memory/llm-extraction.js +1 -1
  84. package/dist/memory/llm-extraction.js.map +1 -1
  85. package/dist/memory/memory-bridge.js +1 -1
  86. package/dist/memory/memory-bridge.js.map +1 -1
  87. package/dist/memory/nexus-plasticity.js +1 -1
  88. package/dist/memory/nexus-plasticity.js.map +1 -1
  89. package/dist/memory/observer-reflector.js +1 -1
  90. package/dist/memory/observer-reflector.js.map +1 -1
  91. package/dist/memory/patterns.d.ts +14 -14
  92. package/dist/memory/pipeline-manifest-sqlite.d.ts.map +1 -1
  93. package/dist/memory/pipeline-manifest-sqlite.js +13 -3
  94. package/dist/memory/pipeline-manifest-sqlite.js.map +1 -1
  95. package/dist/memory/quality-feedback.js +4 -4
  96. package/dist/memory/quality-feedback.js.map +1 -1
  97. package/dist/memory/retrieval/build-retrieval-bundle.d.ts +1 -1
  98. package/dist/memory/retrieval/build-retrieval-bundle.d.ts.map +1 -1
  99. package/dist/memory/retrieval/build-retrieval-bundle.js.map +1 -1
  100. package/dist/memory/retrieval/increment-citation-counts.js +1 -1
  101. package/dist/memory/retrieval/increment-citation-counts.js.map +1 -1
  102. package/dist/memory/retrieval/log-retrieval.js +1 -1
  103. package/dist/memory/retrieval/log-retrieval.js.map +1 -1
  104. package/dist/memory/retrieval/observe.d.ts.map +1 -1
  105. package/dist/memory/retrieval/observe.js +32 -17
  106. package/dist/memory/retrieval/observe.js.map +1 -1
  107. package/dist/memory/retrieval/search.js +1 -1
  108. package/dist/memory/retrieval/search.js.map +1 -1
  109. package/dist/memory/retrieval/timeline.js +1 -1
  110. package/dist/memory/retrieval/timeline.js.map +1 -1
  111. package/dist/memory/session-memory.d.ts.map +1 -1
  112. package/dist/memory/session-memory.js +35 -1
  113. package/dist/memory/session-memory.js.map +1 -1
  114. package/dist/memory/sleep-consolidation.js +4 -4
  115. package/dist/memory/sleep-consolidation.js.map +1 -1
  116. package/dist/memory/temporal-supersession.js +5 -5
  117. package/dist/memory/temporal-supersession.js.map +1 -1
  118. package/dist/memory/transcript-extractor.js +1 -1
  119. package/dist/memory/transcript-extractor.js.map +1 -1
  120. package/dist/memory/transcript-ingestor.js +1 -1
  121. package/dist/memory/transcript-ingestor.js.map +1 -1
  122. package/dist/memory/transcript-scanner.js +1 -1
  123. package/dist/memory/transcript-scanner.js.map +1 -1
  124. package/dist/nexus/living-brain.js +7 -7
  125. package/dist/nexus/living-brain.js.map +1 -1
  126. package/dist/nexus/migrations/M-split-graph.d.ts.map +1 -1
  127. package/dist/nexus/migrations/M-split-graph.js +4 -1
  128. package/dist/nexus/migrations/M-split-graph.js.map +1 -1
  129. package/dist/nexus/sigil.d.ts +1 -2
  130. package/dist/nexus/sigil.d.ts.map +1 -1
  131. package/dist/nexus/tasks-bridge.js +4 -4
  132. package/dist/nexus/tasks-bridge.js.map +1 -1
  133. package/dist/nexus/user-profile.d.ts +1 -2
  134. package/dist/nexus/user-profile.d.ts.map +1 -1
  135. package/dist/orchestration/spawn-prompt.d.ts.map +1 -1
  136. package/dist/orchestration/spawn-prompt.js +4 -2
  137. package/dist/orchestration/spawn-prompt.js.map +1 -1
  138. package/dist/release/reconcile.d.ts.map +1 -1
  139. package/dist/release/reconcile.js +5 -1
  140. package/dist/release/reconcile.js.map +1 -1
  141. package/dist/sagas/migrate-containment.d.ts.map +1 -1
  142. package/dist/sagas/migrate-containment.js +4 -2
  143. package/dist/sagas/migrate-containment.js.map +1 -1
  144. package/dist/scaffold/ensure-dirs.js +1 -1
  145. package/dist/scaffold/ensure-dirs.js.map +1 -1
  146. package/dist/scaffold/ensure-skills.d.ts.map +1 -1
  147. package/dist/scaffold/ensure-skills.js +67 -0
  148. package/dist/scaffold/ensure-skills.js.map +1 -1
  149. package/dist/selfimprove/dhq-adapter.d.ts.map +1 -1
  150. package/dist/selfimprove/dhq-adapter.js +6 -2
  151. package/dist/selfimprove/dhq-adapter.js.map +1 -1
  152. package/dist/selfimprove/fix-gen.js +5833 -16627
  153. package/dist/selfimprove/fix-gen.js.map +3 -3
  154. package/dist/sentient/dream-cycle.js +1 -1
  155. package/dist/sentient/dream-cycle.js.map +1 -1
  156. package/dist/sentient/hygiene-scan.js +1 -1
  157. package/dist/sentient/hygiene-scan.js.map +1 -1
  158. package/dist/sentient/propose-tick.js +3 -3
  159. package/dist/sentient/propose-tick.js.map +1 -1
  160. package/dist/sentient/stage-drift-tick.js +1 -1
  161. package/dist/sentient/stage-drift-tick.js.map +1 -1
  162. package/dist/sequence/index.js +1 -1
  163. package/dist/sequence/index.js.map +1 -1
  164. package/dist/store/agent-registry-store.d.ts.map +1 -1
  165. package/dist/store/agent-registry-store.js +72 -30
  166. package/dist/store/agent-registry-store.js.map +1 -1
  167. package/dist/store/attachment-store.js +3 -3
  168. package/dist/store/attachment-store.js.map +1 -1
  169. package/dist/store/background-jobs.d.ts +1 -2
  170. package/dist/store/background-jobs.d.ts.map +1 -1
  171. package/dist/store/background-jobs.js.map +1 -1
  172. package/dist/store/conduit-sqlite.d.ts +21 -1
  173. package/dist/store/conduit-sqlite.d.ts.map +1 -1
  174. package/dist/store/conduit-sqlite.js +43 -58
  175. package/dist/store/conduit-sqlite.js.map +1 -1
  176. package/dist/store/cross-db-cleanup.d.ts +38 -0
  177. package/dist/store/cross-db-cleanup.d.ts.map +1 -1
  178. package/dist/store/cross-db-cleanup.js +125 -0
  179. package/dist/store/cross-db-cleanup.js.map +1 -1
  180. package/dist/store/db-helpers.d.ts +1 -2
  181. package/dist/store/db-helpers.d.ts.map +1 -1
  182. package/dist/store/dual-scope-db.d.ts +343 -4
  183. package/dist/store/dual-scope-db.d.ts.map +1 -1
  184. package/dist/store/dual-scope-db.js +665 -262
  185. package/dist/store/dual-scope-db.js.map +1 -1
  186. package/dist/store/memory-accessor.d.ts +1 -1
  187. package/dist/store/memory-accessor.d.ts.map +1 -1
  188. package/dist/store/memory-accessor.js.map +1 -1
  189. package/dist/store/memory-sqlite.d.ts +58 -4
  190. package/dist/store/memory-sqlite.d.ts.map +1 -1
  191. package/dist/store/memory-sqlite.js +70 -78
  192. package/dist/store/memory-sqlite.js.map +1 -1
  193. package/dist/store/migration-manager.d.ts +7 -4
  194. package/dist/store/migration-manager.d.ts.map +1 -1
  195. package/dist/store/migration-manager.js +10 -10
  196. package/dist/store/migration-manager.js.map +1 -1
  197. package/dist/store/migration-sqlite.js +1 -1
  198. package/dist/store/migration-sqlite.js.map +1 -1
  199. package/dist/store/nexus-sqlite.d.ts +43 -1
  200. package/dist/store/nexus-sqlite.d.ts.map +1 -1
  201. package/dist/store/nexus-sqlite.js +80 -116
  202. package/dist/store/nexus-sqlite.js.map +1 -1
  203. package/dist/store/ports/domain-binding.d.ts +233 -0
  204. package/dist/store/ports/domain-binding.d.ts.map +1 -0
  205. package/dist/store/ports/domain-binding.js +500 -0
  206. package/dist/store/ports/domain-binding.js.map +1 -0
  207. package/dist/store/ports/fk-integrity.d.ts +155 -0
  208. package/dist/store/ports/fk-integrity.d.ts.map +1 -0
  209. package/dist/store/ports/fk-integrity.js +182 -0
  210. package/dist/store/ports/fk-integrity.js.map +1 -0
  211. package/dist/store/role-accessors-impl.js +2 -2
  212. package/dist/store/role-accessors-impl.js.map +1 -1
  213. package/dist/store/schema/attachments.d.ts +1 -1
  214. package/dist/store/schema/audit.d.ts +2 -2
  215. package/dist/store/schema/background-jobs.d.ts +1 -1
  216. package/dist/store/schema/cleo-global/agent-registry.d.ts +1 -1
  217. package/dist/store/schema/cleo-global/models-catalog.d.ts +1 -1
  218. package/dist/store/schema/cleo-global/skills.d.ts +2 -2
  219. package/dist/store/schema/cleo-project/audit.d.ts +2 -2
  220. package/dist/store/schema/cleo-project/docs.d.ts +1 -1
  221. package/dist/store/schema/cleo-project/lifecycle.d.ts +4 -4
  222. package/dist/store/schema/cleo-project/nexus-graph.d.ts +2 -2
  223. package/dist/store/schema/cleo-project/provenance-rest.d.ts +3 -3
  224. package/dist/store/schema/cleo-project/runtime.d.ts +2 -2
  225. package/dist/store/schema/cleo-project/tasks-core-batch2.d.ts +1 -1
  226. package/dist/store/schema/cleo-project/tasks-core.d.ts +3 -3
  227. package/dist/store/schema/cleo-shared/brain.d.ts +5 -5
  228. package/dist/store/schema/jsonb.js +1 -1
  229. package/dist/store/schema/jsonb.js.map +1 -1
  230. package/dist/store/schema/lifecycle.d.ts +4 -4
  231. package/dist/store/schema/memory-schema.d.ts +3 -3
  232. package/dist/store/schema/nexus-schema.d.ts +1 -1
  233. package/dist/store/schema/provenance/releases.d.ts +3 -3
  234. package/dist/store/schema/skills-schema.d.ts +2 -2
  235. package/dist/store/schema/tasks.d.ts +2 -2
  236. package/dist/store/skills-db.d.ts +20 -1
  237. package/dist/store/skills-db.d.ts.map +1 -1
  238. package/dist/store/skills-db.js +80 -72
  239. package/dist/store/skills-db.js.map +1 -1
  240. package/dist/store/sqlite-backup.js +2 -2
  241. package/dist/store/sqlite-backup.js.map +1 -1
  242. package/dist/store/sqlite-data-accessor.js +1 -1
  243. package/dist/store/sqlite-data-accessor.js.map +1 -1
  244. package/dist/store/sqlite.d.ts +73 -11
  245. package/dist/store/sqlite.d.ts.map +1 -1
  246. package/dist/store/sqlite.js +173 -138
  247. package/dist/store/sqlite.js.map +1 -1
  248. package/dist/store/tasks-sqlite.js +2 -2
  249. package/dist/store/tasks-sqlite.js.map +1 -1
  250. package/dist/store/validation-schemas.d.ts +35 -35
  251. package/dist/store/writer-lease.d.ts +85 -53
  252. package/dist/store/writer-lease.d.ts.map +1 -1
  253. package/dist/store/writer-lease.js +194 -112
  254. package/dist/store/writer-lease.js.map +1 -1
  255. package/dist/system/health.d.ts +0 -4
  256. package/dist/system/health.d.ts.map +1 -1
  257. package/dist/system/health.js +58 -2
  258. package/dist/system/health.js.map +1 -1
  259. package/dist/tasks/add.d.ts.map +1 -1
  260. package/dist/tasks/add.js +9 -2
  261. package/dist/tasks/add.js.map +1 -1
  262. package/dist/tasks/backfill-child-projections.d.ts.map +1 -1
  263. package/dist/tasks/backfill-child-projections.js +3 -1
  264. package/dist/tasks/backfill-child-projections.js.map +1 -1
  265. package/dist/tasks/evidence.d.ts +17 -0
  266. package/dist/tasks/evidence.d.ts.map +1 -1
  267. package/dist/tasks/evidence.js +145 -35
  268. package/dist/tasks/evidence.js.map +1 -1
  269. package/dist/tasks/infer-add-params.d.ts.map +1 -1
  270. package/dist/tasks/infer-add-params.js +6 -1
  271. package/dist/tasks/infer-add-params.js.map +1 -1
  272. package/dist/tasks/tool-cache.d.ts +29 -0
  273. package/dist/tasks/tool-cache.d.ts.map +1 -1
  274. package/dist/tasks/tool-cache.js +166 -6
  275. package/dist/tasks/tool-cache.js.map +1 -1
  276. package/dist/tasks/tool-resolver.d.ts +5 -1
  277. package/dist/tasks/tool-resolver.d.ts.map +1 -1
  278. package/dist/tasks/tool-resolver.js +28 -20
  279. package/dist/tasks/tool-resolver.js.map +1 -1
  280. package/dist/telemetry/index.d.ts.map +1 -1
  281. package/dist/telemetry/index.js +4 -1
  282. package/dist/telemetry/index.js.map +1 -1
  283. package/dist/telemetry/sqlite.d.ts +1 -2
  284. package/dist/telemetry/sqlite.d.ts.map +1 -1
  285. package/dist/telemetry/sqlite.js +1 -2
  286. package/dist/telemetry/sqlite.js.map +1 -1
  287. package/dist/upgrade.js +2 -2
  288. package/dist/upgrade.js.map +1 -1
  289. package/dist/validation/doctor/checks.d.ts +23 -1
  290. package/dist/validation/doctor/checks.d.ts.map +1 -1
  291. package/dist/validation/doctor/checks.js +79 -19
  292. package/dist/validation/doctor/checks.js.map +1 -1
  293. package/dist/worktree/list.js +1 -1
  294. package/dist/worktree/list.js.map +1 -1
  295. package/package.json +24 -14
  296. package/schemas/project-context.schema.json +69 -0
  297. package/templates/workflows/release-prepare.yml.tmpl +10 -0
@@ -51,14 +51,14 @@
51
51
  */
52
52
  import { existsSync, mkdirSync } from 'node:fs';
53
53
  import { createRequire } from 'node:module';
54
- import { dirname, join } from 'node:path';
54
+ import { dirname, join, resolve } from 'node:path';
55
55
  import { getLogger } from '../logger.js';
56
56
  import { getCleoHome, resolveCleoDir } from '../paths.js';
57
57
  import { getRecordedExodusAbort } from './exodus/abort-events.js';
58
58
  import { migrateWithRetry, reconcileJournal } from './migration-manager.js';
59
59
  import { resolveConsolidatedJournalSiblings, resolveCorePackageMigrationsFolder, } from './resolve-migrations-folder.js';
60
60
  import { applyPerfPragmas } from './sqlite-pragmas.js';
61
- import { activeScope, activeScopeDbPath, withColdOpenLease, withWriterLease, } from './writer-lease.js';
61
+ import { makeWriterLeaseIdentity, registerDbIdentity, resolveDbIdentity, withColdOpenLease, withWriterLease, } from './writer-lease.js';
62
62
  /**
63
63
  * Thrown by {@link assertWriteDurable} when a MUTATING caller is about to write
64
64
  * through a {@link DualScopeDbHandle} whose first-open exodus auto-migration
@@ -204,24 +204,6 @@ function getDatabaseSyncCtor() {
204
204
  }
205
205
  return _DatabaseSyncCtor;
206
206
  }
207
- // ── Schema loading ───────────────────────────────────────────────────────────
208
- // Dynamic imports for schema barrels. Loaded once per scope and cached.
209
- // We use dynamic import() to avoid loading both schemas at module-init time —
210
- // only the requested scope's schema is loaded.
211
- let _projectSchema = null;
212
- let _globalSchema = null;
213
- async function loadProjectSchema() {
214
- if (_projectSchema === null) {
215
- _projectSchema = await import('./schema/cleo-project/index.js');
216
- }
217
- return _projectSchema;
218
- }
219
- async function loadGlobalSchema() {
220
- if (_globalSchema === null) {
221
- _globalSchema = await import('./schema/cleo-global/index.js');
222
- }
223
- return _globalSchema;
224
- }
225
207
  // ── Existence table for migration reconciliation ──────────────────────────────
226
208
  /**
227
209
  * The "existence table" used by {@link reconcileJournal} to detect whether
@@ -312,36 +294,58 @@ async function openDedicatedDualScopeDb(scope, dbPath, log) {
312
294
  }
313
295
  const DatabaseSyncCtor = getDatabaseSyncCtor();
314
296
  const nativeDb = new DatabaseSyncCtor(dbPath, { allowExtension: true });
315
- // T11829: bound per-connection memory for one-shot/CLI opens (full SSoT for daemon).
316
- applyPerfPragmas(nativeDb, memoryBoundedPragmaOverrides());
317
- const schema = scope === 'project' ? await loadProjectSchema() : await loadGlobalSchema();
318
- const drizzle = getDrizzle();
319
- // biome-ignore lint/suspicious/noExplicitAny: schema type is scope-specific; typed via DualScopeDbHandle<TScope>
320
- const db = drizzle({ client: nativeDb, schema });
321
- const migrationsFolder = resolveCorePackageMigrationsFolder(migrationsSetName(scope));
322
- // T11829: pass the OTHER lineages that share this scope's consolidated cleo.db
323
- // journal so their rows are never deleted as cross-lineage orphans. Both scopes
324
- // are consolidated single-file substrates with multiple coexisting lineages
325
- // (project: tasks/cleo-project/nexus/conduit/brain; global:
326
- // cleo-global/agent-registry/…). Over-inclusion across scopes is safe — a
327
- // sibling hash absent from this file's journal contributes nothing.
328
- reconcileJournal(nativeDb, migrationsFolder, existenceTable(scope), `dual-scope-db[${scope}]`, resolveConsolidatedJournalSiblings(migrationsSetName(scope)));
329
- migrateWithRetry(db, migrationsFolder, nativeDb, existenceTable(scope), `dual-scope-db[${scope}]`);
330
- log.debug({ scope, dbPath }, 'DEDICATED dual-scope cleo.db ready (T11782 FIX D)');
331
- return {
332
- db,
333
- scope,
334
- dbPath,
335
- close() {
336
- // Dedicated handles are never cached — close only the native connection.
337
- try {
338
- nativeDb.close();
339
- }
340
- catch {
341
- // Idempotent — ignore double-close errors.
342
- }
343
- },
344
- };
297
+ // Every operation after construction is wrapped so any exception —
298
+ // pragmas, Drizzle wrapping, migration-folder resolution, lease, or
299
+ // migration — closes the native handle before rethrow.
300
+ try {
301
+ // T11829: bound per-connection memory for one-shot/CLI opens (full SSoT for daemon).
302
+ applyPerfPragmas(nativeDb, memoryBoundedPragmaOverrides());
303
+ const drizzle = getDrizzle();
304
+ // biome-ignore lint/suspicious/noExplicitAny: dual-scope handle is untyped at construction; typed via DualScopeDbHandle<TScope>
305
+ const db = drizzle({ client: nativeDb });
306
+ const migrationsFolder = resolveCorePackageMigrationsFolder(migrationsSetName(scope));
307
+ // ── Cold-open lease (F4 · T12036) ──────────────────────────────────────
308
+ // Dedicated opens also serialize their cold-open migrations through the same
309
+ // writer-lease so they never race on the `__drizzle_migrations` journal
310
+ // even when two dedicated opens of a fresh file are concurrent.
311
+ const handle = await withColdOpenLease(scope, nativeDb, async () => {
312
+ reconcileJournal(nativeDb, migrationsFolder, existenceTable(scope), `dual-scope-db[${scope}]`, resolveConsolidatedJournalSiblings(migrationsSetName(scope)));
313
+ migrateWithRetry(db, migrationsFolder, nativeDb, existenceTable(scope), `dual-scope-db[${scope}]`);
314
+ log.debug({ scope, dbPath }, 'DEDICATED dual-scope cleo.db ready (T11782 FIX D)');
315
+ const identity = makeWriterLeaseIdentity(scope, dbPath);
316
+ registerDbIdentity(db, identity);
317
+ return {
318
+ db,
319
+ scope,
320
+ dbPath,
321
+ identity,
322
+ get isOpen() {
323
+ return nativeDb.isOpen;
324
+ },
325
+ close() {
326
+ // Dedicated handles are never cached — close only the native connection.
327
+ try {
328
+ nativeDb.close();
329
+ }
330
+ catch {
331
+ // Idempotent — ignore double-close errors.
332
+ }
333
+ },
334
+ };
335
+ });
336
+ return handle;
337
+ }
338
+ catch (err) {
339
+ // Close the native handle on any failure after construction to avoid
340
+ // leaking a file descriptor. The primary error is rethrown unchanged.
341
+ try {
342
+ nativeDb.close();
343
+ }
344
+ catch {
345
+ // Already closed or never opened — safe to ignore.
346
+ }
347
+ throw err;
348
+ }
345
349
  }
346
350
  export async function openDualScopeDbAtPath(scope, dbPath,
347
351
  /**
@@ -353,7 +357,10 @@ export async function openDualScopeDbAtPath(scope, dbPath,
353
357
  */
354
358
  exodusCwd, options) {
355
359
  const dedicated = options?.dedicated === true;
356
- const key = cacheKey(scope, dbPath);
360
+ // Normalize the path before keying so equivalent spellings share one
361
+ // cache entry — the chokepoint key mirrors the runtime's path.resolve.
362
+ const normalizedPath = resolve(dbPath);
363
+ const key = cacheKey(scope, normalizedPath);
357
364
  // A DEDICATED open (T11782 · FIX D) bypasses the singleton cache entirely: it
358
365
  // opens a SECOND SQLite connection to the same file (WAL allows concurrent
359
366
  // connections) so the exodus migrate engine can copy + (on abort) truncate on
@@ -370,206 +377,29 @@ exodusCwd, options) {
370
377
  if (existing.initPromise) {
371
378
  return existing.initPromise;
372
379
  }
373
- return existing.handle;
380
+ if (existing.handle && existing.nativeDb?.isOpen) {
381
+ return existing.handle;
382
+ }
383
+ _cache.delete(key);
374
384
  }
375
385
  }
376
386
  const log = getLogger('dual-scope-db');
377
387
  if (dedicated) {
378
- return openDedicatedDualScopeDb(scope, dbPath, log);
388
+ return openDedicatedDualScopeDb(scope, normalizedPath, log);
379
389
  }
380
390
  // Create a placeholder entry so concurrent callers wait for the same init.
381
- const initPromise = (async () => {
382
- log.debug({ scope, dbPath }, 'opening dual-scope cleo.db');
383
- // Ensure the directory exists before opening.
384
- const dir = dirname(dbPath);
385
- if (!existsSync(dir)) {
386
- mkdirSync(dir, { recursive: true });
387
- }
388
- // Open the native SQLite handle.
389
- //
390
- // `allowExtension: true` permits — but does NOT load — SQLite loadable
391
- // extensions. The brain domain (E6-L2 · T11522) loads the `sqlite-vec`
392
- // extension on this shared handle for vector similarity search; node:sqlite
393
- // requires the flag at construction time (it cannot be toggled afterwards
394
- // via `enableLoadExtension`). Enabling the flag is harmless for every other
395
- // domain — no extension is loaded automatically, and the cache stays
396
- // single-keyed regardless of which domain opens the handle first.
397
- const DatabaseSyncCtor = getDatabaseSyncCtor();
398
- const nativeDb = new DatabaseSyncCtor(dbPath, { allowExtension: true });
399
- // Apply canonical pragma set (specs/sqlite-pragmas.json SSoT), bounding
400
- // per-connection memory for one-shot/CLI opens (full SSoT for daemon) — T11829.
401
- applyPerfPragmas(nativeDb, memoryBoundedPragmaOverrides());
402
- // Load the schema barrel for this scope.
403
- const schema = scope === 'project' ? await loadProjectSchema() : await loadGlobalSchema();
404
- // Create the Drizzle ORM wrapper.
405
- // Drizzle v1 RC3 node-sqlite API: pass { client, schema } as a single config object.
406
- const drizzle = getDrizzle();
407
- // biome-ignore lint/suspicious/noExplicitAny: schema type is scope-specific; typed via DualScopeDbHandle<TScope>
408
- const db = drizzle({ client: nativeDb, schema });
409
- // Resolve the migrations folder for this scope.
410
- const migrationsFolder = resolveCorePackageMigrationsFolder(migrationsSetName(scope));
411
- // ── Seam 0 — cold-open critical section (THE T5158 HEAL · T11627 ST-3) ────
412
- // Lease the migrate/reconcile cold-open write-txn against the just-opened
413
- // native handle so EXACTLY ONE process per scope runs it while concurrent
414
- // cold-open peers BEGIN IMMEDIATE-queue and then observe a ready DB. This heals
415
- // the T5158 `E_NOT_INITIALIZED` / `E_INTERNAL` corruption (concurrent cold-open
416
- // migrate write-txns racing the consolidated cleo.db's single shared
417
- // `__drizzle_migrations` journal) WITH the supervisor daemon disabled (`local`
418
- // mode default). `off` mode is a pass-through → byte-identical to pre-lease
419
- // behaviour (busy_timeout=30000 still serializes the write-txn).
420
- // `withColdOpenLease` also records the scope in the Seam-1 active-scope registry
421
- // so chokepoint write primitives lease correctly.
422
- //
423
- // The lease wraps ONLY reconcileJournal + migrateWithRetry — the precise write-
424
- // txn that races in T5158. The exodus-on-open hook runs AFTER the lease releases
425
- // (below): it owns its OWN single-flight lock + dedicated migrate connections,
426
- // and `runExodusMigrate` CLOSES + re-opens the scope handles, which would
427
- // close the very handle the lease row lives on mid-section. Releasing first is
428
- // both correct (exodus is already serialized) and necessary (no close-under-lease).
429
- const handle = await withColdOpenLease(scope, nativeDb, async () => {
430
- // Reconcile the migration journal (handles WAL/journal divergence across
431
- // SQLite version upgrades — same pattern as sqlite.ts / memory-sqlite.ts).
432
- // T11829: pass the OTHER lineages that share this scope's consolidated cleo.db
433
- // journal so their rows are not deleted as cross-lineage orphans (the confirmed
434
- // OOM root cause: each lineage previously deleted the others' rows so the shared
435
- // journal never converged).
436
- reconcileJournal(nativeDb, migrationsFolder, existenceTable(scope), `dual-scope-db[${scope}]`, resolveConsolidatedJournalSiblings(migrationsSetName(scope)));
437
- // Run any pending migrations.
438
- migrateWithRetry(db, migrationsFolder, nativeDb, existenceTable(scope), `dual-scope-db[${scope}]`);
439
- log.debug({ scope, dbPath }, 'dual-scope cleo.db ready');
440
- const built = {
441
- db,
442
- scope,
443
- dbPath,
444
- close() {
445
- _cache.delete(key);
446
- try {
447
- nativeDb.close();
448
- }
449
- catch {
450
- // Idempotent — ignore double-close errors.
451
- }
452
- },
453
- };
454
- // Update the cache entry to mark init complete.
455
- const entry = _cache.get(key);
456
- if (entry) {
457
- entry.initPromise = null;
458
- entry.handle = built;
459
- }
460
- return built;
461
- },
462
- // Record this open's resolved dbPath in the Seam-1 registry so the chokepoint
463
- // write primitives lease their row in THIS file — not the cwd-default — when
464
- // more than one project's cleo.db is open in this process (T11627 Finding 1).
465
- { dbPath });
466
- // ── Exodus-on-open (E6 · T11553) — runs AFTER the cold-open lease releases ──
467
- // Data-continuity safety net: on a canonical open where the consolidated
468
- // cleo.db is empty but the legacy fleet (tasks.db/brain.db/…) has rows for
469
- // THIS scope, lazily auto-migrate ONCE — gated by a parity verify, serialised
470
- // by a single-flight lock, rolled back to empty on parity failure (legacy
471
- // kept). Re-entrancy/concurrency are handled inside the hook. Lazy `import()`
472
- // breaks the cycle (exodus/migrate.ts imports openDualScopeDb from here).
473
- //
474
- // NOTE: `runExodusMigrate` CLOSES + evicts the dual-scope handles it opened
475
- // when it finishes, so after a `migrated`/`aborted` outcome the `handle`
476
- // built above (and its `nativeDb`) is CLOSED. We therefore re-open this scope
477
- // fresh (cache-miss, NOT armed — no `exodusCwd`) and return the new live
478
- // handle. That re-open flows through the cold-open lease again on a FRESH handle
479
- // (cheap claim/release — the DB is now migrated), with no contention against the
480
- // already-released outer lease.
481
- //
482
- // Armed ONLY when `exodusCwd` was threaded through the canonical
483
- // `openDualScopeDb` AND `dbPath` is the canonical path for that scope+cwd —
484
- // never for explicit-path opens (test fixtures / legacy-path domains).
485
- if (exodusCwd !== undefined && dbPath === resolveDualScopeDbPath(scope, exodusCwd)) {
486
- // ── T12001 (Epic T11992) — db-heavy admission for the exodus auto-migrate ──
487
- // The on-open exodus (reconcile + parity verify + migrate) is a heavy DB op;
488
- // letting it co-schedule with builds/tests/agents is a historical OOM vector.
489
- // Admit it through the governor's `db-heavy` class (machine-wide serialized;
490
- // deferred under memory backoff). On a denial we SKIP the migration THIS open
491
- // (kill-switch precedent CLEO_DISABLE_EXODUS_ON_OPEN) — NEVER block or defer
492
- // the interactive command (interactive-cli is never gated) — and it re-runs
493
- // idempotently on a calmer open. Fail-open: ANY governor error proceeds
494
- // un-gated, byte-identical to pre-T12001 behaviour.
495
- let releaseDbHeavy = null;
496
- let dbHeavyDeferred = false;
497
- try {
498
- const { governor } = await import('../resources/governor.js');
499
- const admit = await governor.tryAcquire('db-heavy');
500
- if (admit.deferred) {
501
- dbHeavyDeferred = true;
502
- }
503
- else {
504
- releaseDbHeavy = admit.release;
505
- }
506
- }
507
- catch {
508
- // Governor unavailable — fail open (proceed un-gated).
509
- }
510
- if (dbHeavyDeferred) {
511
- log.debug({ scope, dbPath }, 'exodus-on-open skipped this open — db-heavy deferred under memory pressure ' +
512
- '(re-runs idempotently on a calmer open)');
513
- return handle;
514
- }
515
- try {
516
- const { maybeRunExodusOnOpen } = await import('./exodus/on-open.js');
517
- const result = await maybeRunExodusOnOpen(scope, dbPath, nativeDb, exodusCwd);
518
- if (result.outcome === 'migrated' || result.outcome === 'aborted') {
519
- // The migrate engine closed our handle — re-open fresh (un-armed) so the
520
- // caller receives a valid, live handle bound to the now-(de)populated DB.
521
- const reopened = scope === 'project'
522
- ? await openDualScopeDbAtPath('project', dbPath)
523
- : await openDualScopeDbAtPath('global', dbPath);
524
- if (result.outcome === 'aborted') {
525
- // T11828 (DHQ-059): the data-continuity gate aborted — the consolidated
526
- // DB is empty + consistent, legacy kept as source. Surface this to a
527
- // MUTATING caller (read-only callers ignore it) by (a) stamping a
528
- // structured marker on the returned handle and (b) broadcasting a typed
529
- // event. The non-zero error itself is raised on the write path via
530
- // `assertWriteDurable(handle)` — NOT here, so read opens never throw.
531
- const abort = {
532
- scope,
533
- dbPath,
534
- reason: result.reason,
535
- at: Date.now(),
536
- };
537
- log.warn({ scope, reason: result.reason }, 'exodus-on-open aborted; consolidated cleo.db left empty, legacy kept as source — ' +
538
- 'mutating callers must check handle.exodusAbort / call assertWriteDurable (T11828)');
539
- const { emitExodusAbort } = await import('./exodus/abort-events.js');
540
- emitExodusAbort(abort);
541
- return { ...reopened, exodusAbort: abort };
542
- }
543
- // A subsequent SUCCESSFUL migration resolves any prior abort recorded
544
- // for this scope, so writes are no longer rejected (T11828).
545
- const { clearExodusAborts } = await import('./exodus/abort-events.js');
546
- clearExodusAborts(scope);
547
- return reopened;
548
- }
549
- }
550
- catch (err) {
551
- // Best-effort safety net: a hook failure must not make the DB
552
- // unopenable. Warn and re-open fresh; `cleo exodus migrate` remains the
553
- // manual path. (The handle may have been closed mid-migrate.)
554
- log.warn({ err, scope }, 'exodus-on-open hook failed (non-fatal); re-opening consolidated handle');
555
- return scope === 'project'
556
- ? openDualScopeDbAtPath('project', dbPath)
557
- : openDualScopeDbAtPath('global', dbPath);
558
- }
559
- finally {
560
- // Release the db-heavy slot on EVERY exit path (early returns above run
561
- // `finally` first). Idempotent; release errors are swallowed.
562
- if (releaseDbHeavy)
563
- await releaseDbHeavy().catch(() => { });
564
- }
565
- }
566
- return handle;
567
- })();
568
- // Store a placeholder with the in-flight promise so concurrent callers wait.
391
+ // MUST run BEFORE the async IIFE is defined: the IIFE starts executing
392
+ // synchronously, and the cold-open callback inside withColdOpenLease runs
393
+ // synchronously before the outer IIFE yields — if the placeholder entry
394
+ // doesn't exist yet, the callback cannot find and update it (T12035).
395
+ let initResolve = null;
396
+ let initReject = null;
397
+ const initPromise = new Promise((resolve, reject) => {
398
+ initResolve = resolve;
399
+ initReject = reject;
400
+ });
569
401
  _cache.set(key, {
570
- // biome-ignore lint/suspicious/noExplicitAny: placeholder until initPromise resolves
571
402
  handle: null,
572
- // biome-ignore lint/suspicious/noExplicitAny: nativeDb not available yet
573
403
  nativeDb: null,
574
404
  initPromise,
575
405
  });
@@ -588,6 +418,218 @@ exodusCwd, options) {
588
418
  _cache.delete(key);
589
419
  }
590
420
  });
421
+ // Start the actual init work. The IIFE is a fire-and-forget callback that
422
+ // resolves/rejects the externally-created initPromise. Separating promise
423
+ // creation from the async IIFE ensures the cache placeholder exists before
424
+ // any synchronous work runs inside withColdOpenLease's fn callback (T12035).
425
+ (async () => {
426
+ try {
427
+ log.debug({ scope, dbPath: normalizedPath }, 'opening dual-scope cleo.db');
428
+ // Ensure the directory exists before opening.
429
+ const dir = dirname(normalizedPath);
430
+ if (!existsSync(dir)) {
431
+ mkdirSync(dir, { recursive: true });
432
+ }
433
+ // Open the native SQLite handle.
434
+ //
435
+ // `allowExtension: true` permits — but does NOT load — SQLite loadable
436
+ // extensions. The brain domain (E6-L2 · T11522) loads the `sqlite-vec`
437
+ // extension on this shared handle for vector similarity search; node:sqlite
438
+ // requires the flag at construction time (it cannot be toggled afterwards
439
+ // via `enableLoadExtension`). Enabling the flag is harmless for every other
440
+ // domain — no extension is loaded automatically, and the cache stays
441
+ // single-keyed regardless of which domain opens the handle first.
442
+ const DatabaseSyncCtor = getDatabaseSyncCtor();
443
+ const nativeDb = new DatabaseSyncCtor(normalizedPath, { allowExtension: true });
444
+ // Apply canonical pragma set (specs/sqlite-pragmas.json SSoT), bounding
445
+ // per-connection memory for one-shot/CLI opens (full SSoT for daemon) — T11829.
446
+ applyPerfPragmas(nativeDb, memoryBoundedPragmaOverrides());
447
+ // Create the Drizzle ORM wrapper.
448
+ const drizzle = getDrizzle();
449
+ // biome-ignore lint/suspicious/noExplicitAny: dual-scope handle is untyped at construction; typed via DualScopeDbHandle<TScope>
450
+ const db = drizzle({ client: nativeDb });
451
+ // Resolve the migrations folder for this scope.
452
+ const migrationsFolder = resolveCorePackageMigrationsFolder(migrationsSetName(scope));
453
+ // ── Seam 0 — cold-open critical section (THE T5158 HEAL · T11627 ST-3) ────
454
+ // Lease the migrate/reconcile cold-open write-txn against the just-opened
455
+ // native handle so EXACTLY ONE process per scope runs it while concurrent
456
+ // cold-open peers BEGIN IMMEDIATE-queue and then observe a ready DB. This heals
457
+ // the T5158 `E_NOT_INITIALIZED` / `E_INTERNAL` corruption (concurrent cold-open
458
+ // migrate write-txns racing the consolidated cleo.db's single shared
459
+ // `__drizzle_migrations` journal) WITH the supervisor daemon disabled (`local`
460
+ // mode default). `off` mode is a pass-through → byte-identical to pre-lease
461
+ // behaviour (busy_timeout=30000 still serializes the write-txn).
462
+ // The identity is bound to the drizzle DB handle via
463
+ // registerDbIdentity during construction — the chokepoint write
464
+ // primitives resolve it from the exact handle binding (T12042), no
465
+ // longer from a process-global active-scope registry.
466
+ //
467
+ // The lease wraps ONLY reconcileJournal + migrateWithRetry — the precise write-
468
+ // txn that races in T5158. The exodus-on-open hook runs AFTER the lease releases
469
+ // (below): it owns its OWN single-flight lock + dedicated migrate connections,
470
+ // and `runExodusMigrate` CLOSES + re-opens the scope handles, which would
471
+ // close the very handle the lease row lives on mid-section. Releasing first is
472
+ // both correct (exodus is already serialized) and necessary (no close-under-lease).
473
+ const handle = await withColdOpenLease(scope, nativeDb, async () => {
474
+ // Reconcile the migration journal (handles WAL/journal divergence across
475
+ // SQLite version upgrades — same pattern as sqlite.ts / memory-sqlite.ts).
476
+ // T11829: pass the OTHER lineages that share this scope's consolidated cleo.db
477
+ // journal so their rows are not deleted as cross-lineage orphans (the confirmed
478
+ // OOM root cause: each lineage previously deleted the others' rows so the shared
479
+ // journal never converged).
480
+ reconcileJournal(nativeDb, migrationsFolder, existenceTable(scope), `dual-scope-db[${scope}]`, resolveConsolidatedJournalSiblings(migrationsSetName(scope)));
481
+ // Run any pending migrations.
482
+ migrateWithRetry(db, migrationsFolder, nativeDb, existenceTable(scope), `dual-scope-db[${scope}]`);
483
+ log.debug({ scope, dbPath: normalizedPath }, 'dual-scope cleo.db ready');
484
+ const identity = makeWriterLeaseIdentity(scope, normalizedPath);
485
+ registerDbIdentity(db, identity);
486
+ const built = {
487
+ db,
488
+ scope,
489
+ dbPath: normalizedPath,
490
+ identity,
491
+ get isOpen() {
492
+ return nativeDb.isOpen;
493
+ },
494
+ close() {
495
+ // Identity-guarded deletion: only evict from the singleton cache
496
+ // when the current entry still references THIS exact handle. A stale
497
+ // store close after a replacement was opened leaves the replacement
498
+ // intact. The native connection is always closed regardless.
499
+ const entry = _cache.get(key);
500
+ if (entry?.handle === built) {
501
+ _cache.delete(key);
502
+ }
503
+ try {
504
+ nativeDb.close();
505
+ }
506
+ catch {
507
+ // Idempotent — ignore double-close errors.
508
+ }
509
+ },
510
+ };
511
+ // Update the cache entry to mark init complete.
512
+ const entry = _cache.get(key);
513
+ if (entry) {
514
+ entry.initPromise = null;
515
+ entry.handle = built;
516
+ entry.nativeDb = nativeDb;
517
+ }
518
+ return built;
519
+ });
520
+ // ── Exodus-on-open (E6 · T11553) — runs AFTER the cold-open lease releases ──
521
+ // Data-continuity safety net: on a canonical open where the consolidated
522
+ // cleo.db is empty but the legacy fleet (tasks.db/brain.db/…) has rows for
523
+ // THIS scope, lazily auto-migrate ONCE — gated by a parity verify, serialised
524
+ // by a single-flight lock, rolled back to empty on parity failure (legacy
525
+ // kept). Re-entrancy/concurrency are handled inside the hook. Lazy `import()`
526
+ // breaks the cycle (exodus/migrate.ts imports openDualScopeDb from here).
527
+ //
528
+ // NOTE: `runExodusMigrate` opens DEDICATED connections (not cached).
529
+ // When the migration finishes those dedicated handles close only their own
530
+ // `DatabaseSync` — they never touch the singleton `_cache`. After a
531
+ // `migrated`/`aborted` outcome the `handle` built above (and its
532
+ // `nativeDb`) is CLOSED. We therefore re-open this scope fresh
533
+ // (cache-miss, NOT armed — no `exodusCwd`) and return the new live
534
+ // handle. That re-open flows through the cold-open lease again on a FRESH handle
535
+ // (cheap claim/release — the DB is now migrated), with no contention against the
536
+ // already-released outer lease.
537
+ //
538
+ // Armed ONLY when `exodusCwd` was threaded through the canonical
539
+ // `openDualScopeDb` AND `dbPath` is the canonical path for that scope+cwd —
540
+ // never for explicit-path opens (test fixtures / legacy-path domains).
541
+ let finalHandle = handle;
542
+ if (exodusCwd !== undefined && normalizedPath === resolveDualScopeDbPath(scope, exodusCwd)) {
543
+ // ── T12001 (Epic T11992) — db-heavy admission for the exodus auto-migrate ──
544
+ // The on-open exodus (reconcile + parity verify + migrate) is a heavy DB op;
545
+ // letting it co-schedule with builds/tests/agents is a historical OOM vector.
546
+ // Admit it through the governor's `db-heavy` class (machine-wide serialized;
547
+ // deferred under memory backoff). On a denial we SKIP the migration THIS open
548
+ // (kill-switch precedent CLEO_DISABLE_EXODUS_ON_OPEN) — NEVER block or defer
549
+ // the interactive command (interactive-cli is never gated) — and it re-runs
550
+ // idempotently on a calmer open. Fail-open: ANY governor error proceeds
551
+ // un-gated, byte-identical to pre-T12001 behaviour.
552
+ let releaseDbHeavy = null;
553
+ let dbHeavyDeferred = false;
554
+ try {
555
+ const { governor } = await import('../resources/governor.js');
556
+ const admit = await governor.tryAcquire('db-heavy');
557
+ if (admit.deferred) {
558
+ dbHeavyDeferred = true;
559
+ }
560
+ else {
561
+ releaseDbHeavy = admit.release;
562
+ }
563
+ }
564
+ catch {
565
+ // Governor unavailable — fail open (proceed un-gated).
566
+ }
567
+ if (dbHeavyDeferred) {
568
+ log.debug({ scope, dbPath: normalizedPath }, 'exodus-on-open skipped this open — db-heavy deferred under memory pressure ' +
569
+ '(re-runs idempotently on a calmer open)');
570
+ }
571
+ else {
572
+ try {
573
+ const { maybeRunExodusOnOpen } = await import('./exodus/on-open.js');
574
+ const result = await maybeRunExodusOnOpen(scope, normalizedPath, nativeDb, exodusCwd);
575
+ if (result.outcome === 'migrated' || result.outcome === 'aborted') {
576
+ // The migrate engine closed our handle — re-open fresh (un-armed) so the
577
+ // caller receives a valid, live handle bound to the now-(de)populated DB.
578
+ const reopened = scope === 'project'
579
+ ? await openDualScopeDbAtPath('project', normalizedPath)
580
+ : await openDualScopeDbAtPath('global', normalizedPath);
581
+ if (result.outcome === 'aborted') {
582
+ // T11828 (DHQ-059): the data-continuity gate aborted — the consolidated
583
+ // DB is empty + consistent, legacy kept as source. Surface this to a
584
+ // MUTATING caller (read-only callers ignore it) by (a) stamping a
585
+ // structured marker on the returned handle and (b) broadcasting a typed
586
+ // event. The non-zero error itself is raised on the write path via
587
+ // `assertWriteDurable(handle)` — NOT here, so read opens never throw.
588
+ const abort = {
589
+ scope,
590
+ dbPath: normalizedPath,
591
+ reason: result.reason,
592
+ at: Date.now(),
593
+ };
594
+ log.warn({ scope, reason: result.reason }, 'exodus-on-open aborted; consolidated cleo.db left empty, legacy kept as source — ' +
595
+ 'mutating callers must check handle.exodusAbort / call assertWriteDurable (T11828)');
596
+ const { emitExodusAbort } = await import('./exodus/abort-events.js');
597
+ emitExodusAbort(abort);
598
+ finalHandle = { ...reopened, exodusAbort: abort };
599
+ }
600
+ else {
601
+ // A subsequent SUCCESSFUL migration resolves any prior abort recorded
602
+ // for this scope, so writes are no longer rejected (T11828).
603
+ const { clearExodusAborts } = await import('./exodus/abort-events.js');
604
+ clearExodusAborts(scope);
605
+ finalHandle = reopened;
606
+ }
607
+ }
608
+ }
609
+ catch (err) {
610
+ // Best-effort safety net: a hook failure must not make the DB
611
+ // unopenable. Warn and re-open fresh; `cleo exodus migrate` remains the
612
+ // manual path. (The handle may have been closed mid-migrate.)
613
+ log.warn({ err, scope }, 'exodus-on-open hook failed (non-fatal); re-opening consolidated handle');
614
+ finalHandle =
615
+ scope === 'project'
616
+ ? await openDualScopeDbAtPath('project', normalizedPath)
617
+ : await openDualScopeDbAtPath('global', normalizedPath);
618
+ }
619
+ finally {
620
+ // Release the db-heavy slot on EVERY exit path (early returns above run
621
+ // `finally` first). Idempotent; release errors are swallowed.
622
+ if (releaseDbHeavy)
623
+ await releaseDbHeavy().catch(() => { });
624
+ }
625
+ }
626
+ }
627
+ initResolve(finalHandle);
628
+ }
629
+ catch (err) {
630
+ initReject(err);
631
+ }
632
+ })();
591
633
  return initPromise;
592
634
  }
593
635
  /**
@@ -628,13 +670,372 @@ export function _resetDualScopeDbCache(scope) {
628
670
  // defensively in case the handle was a mid-init placeholder.
629
671
  _cache.delete(key);
630
672
  }
631
- // Only reset the schema caches on a full (unscoped) reset — a scoped reset must
632
- // not force the OTHER scope to reload its schema barrel mid-flight.
633
- if (scope === undefined) {
634
- _projectSchema = null;
635
- _globalSchema = null;
673
+ }
674
+ /**
675
+ * Error thrown when a runtime entry is cancelled during initialization
676
+ * (e.g. {@link CleoRuntime.closeProject} called while an open is in flight).
677
+ */
678
+ class EntryCancelledError extends Error {
679
+ constructor(scope, dbPath) {
680
+ super(`CleoRuntime entry cancelled during initialization: ${scope}::${dbPath}`);
681
+ this.name = 'EntryCancelledError';
636
682
  }
637
683
  }
684
+ /**
685
+ * Thrown when the post-await liveness reacquisition exhausts its bounded
686
+ * retries — two consecutive opens returned a dead handle. The caller can
687
+ * distinguish a transient liveness loss (one retry succeeds) from a
688
+ * persistent failure (both handles dead).
689
+ *
690
+ * @task T12036 (E6-L12)
691
+ */
692
+ class LivenessExhaustedError extends Error {
693
+ constructor(scope, dbPath) {
694
+ super(`CleoRuntime liveness exhausted after bounded reacquisition: ${scope}::${dbPath}`);
695
+ this.name = 'LivenessExhaustedError';
696
+ }
697
+ }
698
+ /**
699
+ * Concrete implementation of the {@link CleoRuntime} store registry.
700
+ *
701
+ * Maintains a private `Map<string, RuntimeEntry>` keyed by the scope-qualified
702
+ * composite `${scope}::${normalizedDbPath}`. Each call to {@link openProject}
703
+ * or {@link openGlobal} flows through the existing dual-scope chokepoint
704
+ * ({@link openDualScopeDbAtPath}) so migrations, pragmas, and the singleton
705
+ * `_cache` are shared — the runtime adds explicit per-entry wrapping, identity
706
+ * tracking, and scoped disposal on top.
707
+ *
708
+ * ## Key invariants
709
+ *
710
+ * 1. **Scope-qualified keys** — `'project::/abs/path'` and
711
+ * `'global::/abs/path'` are distinct keys even when the path is the same
712
+ * string. A `GlobalStore` can never be cast to a `ProjectStore` via a
713
+ * key collision.
714
+ * 2. **Entry identity** — every entry carries a monotonically-increasing `id`.
715
+ * After `await openDualScopeDbAtPath`, the init publishes ONLY when the
716
+ * current entry's `id` still matches. If another call (close/re-open)
717
+ * replaced the entry, the acquired handle is closed and the init rejects.
718
+ * 3. **Stale-safe close** — each store records its entry `id`. `close()` only
719
+ * evicts the registry entry when its `id` still matches; an old stale close
720
+ * is a no-op.
721
+ * 4. **Cache-hit liveness** — before returning a cached store, the runtime
722
+ * checks {@link ProjectStore.isOpen} / {@link GlobalStore.isOpen}
723
+ * (which delegates to {@link DualScopeDbHandle.isOpen}, a typed getter
724
+ * reflecting the underlying `DatabaseSync.isOpen`). A handle closed
725
+ * externally (e.g. `_resetDualScopeDbCache`) is evicted and re-opened.
726
+ * 5. **Path normalization** — all paths are normalized via `path.resolve()`
727
+ * before keying, so `/path/./to/db` and `/path/to/db` share one entry.
728
+ * 6. **Dedicated mode** — passing `{ dedicated: true }` opens a second
729
+ * `DatabaseSync` connection (WAL-allowed) that bypasses the shared cache.
730
+ * Dedicated stores are NOT registered or tracked; the caller is responsible
731
+ * for closing them.
732
+ *
733
+ * @task T12036 (E6-L12)
734
+ */
735
+ class CleoRuntimeImpl {
736
+ _registry = new Map();
737
+ _nextId = 0;
738
+ /**
739
+ * Injectable opener function. Defaults to {@link openDualScopeDbAtPath}.
740
+ * Tests override this to return a pre-closed handle for exercising the
741
+ * post-await liveness branch. Not part of the public {@link CleoRuntime}
742
+ * interface.
743
+ */
744
+ openDualScopeFn = openDualScopeDbAtPath;
745
+ /** @inheritdoc */
746
+ get openPaths() {
747
+ return new Set(this._registry.keys());
748
+ }
749
+ /** @inheritdoc */
750
+ async openProject(dbPath, options) {
751
+ const normalized = resolve(dbPath);
752
+ if (options?.dedicated) {
753
+ const handle = await openDualScopeDbAtPath('project', normalized, options?.exodusCwd, {
754
+ dedicated: true,
755
+ });
756
+ return this.buildStore('project', handle, 0);
757
+ }
758
+ return this.openEntry('project', normalized, 0, options?.exodusCwd);
759
+ }
760
+ /** @inheritdoc */
761
+ async openGlobal(options) {
762
+ const dbPath = resolveDualScopeDbPath('global');
763
+ const normalized = resolve(dbPath);
764
+ if (options?.dedicated) {
765
+ const handle = await openDualScopeDbAtPath('global', normalized, options?.exodusCwd, {
766
+ dedicated: true,
767
+ });
768
+ return this.buildStore('global', handle, 0);
769
+ }
770
+ return this.openEntry('global', normalized, 0, options?.exodusCwd);
771
+ }
772
+ /** @inheritdoc */
773
+ async openGlobalAt(dbPath, options) {
774
+ const normalized = resolve(dbPath);
775
+ if (options?.dedicated) {
776
+ const handle = await openDualScopeDbAtPath('global', normalized, options?.exodusCwd, {
777
+ dedicated: true,
778
+ });
779
+ return this.buildStore('global', handle, 0);
780
+ }
781
+ return this.openEntry('global', normalized, 0, options?.exodusCwd);
782
+ }
783
+ /** @inheritdoc */
784
+ closeProject(dbPath) {
785
+ const normalized = resolve(dbPath);
786
+ this.closeEntry('project', normalized);
787
+ }
788
+ /** @inheritdoc */
789
+ closeAll() {
790
+ // Snapshot keys: closeEntry mutates the map during iteration.
791
+ for (const key of [...this._registry.keys()]) {
792
+ try {
793
+ this.closeEntryByKey(key);
794
+ }
795
+ catch {
796
+ // Continue closing remaining entries.
797
+ }
798
+ }
799
+ }
800
+ // ── Private helpers ──────────────────────────────────────────────────────
801
+ /**
802
+ * Open (or reuse) a scoped entry. All five key invariants are enforced
803
+ * on this single code path.
804
+ *
805
+ * @param depth - Reacquisition depth guard. Starts at 0; capped at 1.
806
+ * When the post-await liveness check finds a dead handle, the bounded
807
+ * retry calls this with `depth + 1`. A second dead handle at depth 1
808
+ * throws {@link LivenessExhaustedError} instead of recursing further.
809
+ */
810
+ async openEntry(scope, dbPath, depth = 0,
811
+ /**
812
+ * Forwarded to the chokepoint as `exodusCwd` so a runtime open arms the
813
+ * exodus-on-open legacy-fleet auto-migration exactly like
814
+ * {@link openDualScopeDb} does. NOT part of the registry key — the key is
815
+ * the canonical path this cwd already resolved to (T12037).
816
+ */
817
+ exodusCwd) {
818
+ const key = cacheKey(scope, dbPath);
819
+ // ── Cache-hit liveness (invariant 4) ─────────────────────────────────
820
+ const existing = this._registry.get(key);
821
+ if (existing) {
822
+ if (existing.initPromise) {
823
+ return existing.initPromise;
824
+ }
825
+ if (existing.store?.isOpen) {
826
+ return existing.store;
827
+ }
828
+ // Handle was closed externally — evict and re-open.
829
+ this._registry.delete(key);
830
+ }
831
+ // ── Placeholder with identity (invariant 2) ──────────────────────────
832
+ const entryId = ++this._nextId;
833
+ let initResolve;
834
+ let initReject;
835
+ const initPromise = new Promise((resolve, reject) => {
836
+ initResolve = resolve;
837
+ initReject = reject;
838
+ });
839
+ this._registry.set(key, { id: entryId, store: null, initPromise });
840
+ // Evict on failure so a transient error doesn't poison.
841
+ initPromise.catch(() => {
842
+ const placeholder = this._registry.get(key);
843
+ if (placeholder?.initPromise === initPromise) {
844
+ this._registry.delete(key);
845
+ }
846
+ });
847
+ try {
848
+ // Dispatch on scope literal so the overloaded opener resolves to the
849
+ // correct typed return. The union `DualScope` cannot satisfy either
850
+ // literal overload directly.
851
+ const dualHandle = scope === 'project'
852
+ ? await this.openDualScopeFn('project', dbPath, exodusCwd)
853
+ : await this.openDualScopeFn('global', dbPath, exodusCwd);
854
+ // ── Publish only if THIS init is still current (invariant 2) ───────
855
+ const current = this._registry.get(key);
856
+ if (!current || current.id !== entryId) {
857
+ // Cancelled during init. Do NOT close dualHandle — a replacement
858
+ // entry for the same scope/key may be awaiting the same in-flight
859
+ // chokepoint promise. The handle stays in the shared cache; the
860
+ // replacement inherits it live.
861
+ throw new EntryCancelledError(scope, dbPath);
862
+ }
863
+ // ── Post-await liveness (invariant 4b) ─────────────────────────
864
+ // The chokepoint resolved with a live handle, but an external close
865
+ // (e.g. _resetDualScopeDbCache or a test-injected dead-handle opener)
866
+ // between the await and this publish can kill it. If the handle is
867
+ // dead, close it (evict from cache), delete the placeholder, and
868
+ // perform ONE bounded reacquisition.
869
+ if (!dualHandle.isOpen) {
870
+ dualHandle.close();
871
+ this._registry.delete(key);
872
+ if (depth >= 1) {
873
+ throw new LivenessExhaustedError(scope, dbPath);
874
+ }
875
+ const retryStore = await this.openEntry(scope, dbPath, depth + 1, exodusCwd);
876
+ initResolve(retryStore);
877
+ return retryStore;
878
+ }
879
+ const store = this.buildStore(scope, dualHandle, entryId);
880
+ current.store = store;
881
+ current.initPromise = null;
882
+ initResolve(store);
883
+ return store;
884
+ }
885
+ catch (err) {
886
+ initReject(err);
887
+ throw err;
888
+ }
889
+ }
890
+ /**
891
+ * Build a typed {@link ProjectStore} or {@link GlobalStore} from a
892
+ * {@link DualScopeDbHandle}. The store's `close()` is stale-safe
893
+ * (invariant 3): it only evicts the registry entry when the entry's
894
+ * identity still matches.
895
+ */
896
+ buildStore(scope, dualHandle, entryId) {
897
+ const dbPath = dualHandle.dbPath;
898
+ const identity = dualHandle.identity;
899
+ const exodusAbort = dualHandle.exodusAbort;
900
+ const key = cacheKey(scope, dbPath);
901
+ if (scope === 'project') {
902
+ const store = {
903
+ scope: 'project',
904
+ dbPath,
905
+ identity,
906
+ db: dualHandle.db,
907
+ get isOpen() {
908
+ return dualHandle.isOpen;
909
+ },
910
+ exodusAbort,
911
+ close: () => {
912
+ dualHandle.close();
913
+ this.cleanupEntry(key, entryId);
914
+ },
915
+ };
916
+ return store;
917
+ }
918
+ const store = {
919
+ scope: 'global',
920
+ dbPath,
921
+ identity,
922
+ db: dualHandle.db,
923
+ get isOpen() {
924
+ return dualHandle.isOpen;
925
+ },
926
+ exodusAbort,
927
+ close: () => {
928
+ dualHandle.close();
929
+ this.cleanupEntry(key, entryId);
930
+ },
931
+ };
932
+ return store;
933
+ }
934
+ /**
935
+ * Conditionally evict a registry entry. Only deletes when the current
936
+ * entry's identity still matches `entryId` — an old stale close is a no-op
937
+ * (invariant 3).
938
+ */
939
+ cleanupEntry(key, entryId) {
940
+ const entry = this._registry.get(key);
941
+ if (entry && entry.id === entryId) {
942
+ this._registry.delete(key);
943
+ }
944
+ }
945
+ /**
946
+ * Close and evict a single scoped entry from the registry by composite
947
+ * key. Cancels in-flight opens by deleting the placeholder entry — the
948
+ * in-flight init detects the missing/id-mismatched entry and rejects
949
+ * after its `await` resolves (invariant 2).
950
+ */
951
+ closeEntry(scope, dbPath) {
952
+ this.closeEntryByKey(cacheKey(scope, dbPath));
953
+ }
954
+ /**
955
+ * Close and evict a single entry by composite registry key.
956
+ */
957
+ closeEntryByKey(key) {
958
+ const entry = this._registry.get(key);
959
+ if (!entry)
960
+ return;
961
+ // Delete FIRST so in-flight inits observe a missing entry
962
+ // (invariant 2 — cancellation). Even if the store close below throws,
963
+ // the entry is gone — the caller won't get it back and the dual-scope
964
+ // chokepoint cache still owns the SQLite close.
965
+ this._registry.delete(key);
966
+ if (entry.store) {
967
+ try {
968
+ entry.store.close();
969
+ }
970
+ catch {
971
+ // Close is best-effort; the entry is already evicted.
972
+ }
973
+ }
974
+ }
975
+ }
976
+ /**
977
+ * Override the dual-scope opener function used by the given
978
+ * {@link CleoRuntime} instance. Tests inject a custom opener to return a
979
+ * pre-closed handle, exercising the post-await liveness and bounded-
980
+ * reacquisition code paths.
981
+ *
982
+ * The injected function receives the same `(scope, dbPath)` signature as
983
+ * {@link openDualScopeDbAtPath} and must return a
984
+ * {@link DualScopeDbHandle}. Only non-dedicated opens (
985
+ * {@link CleoRuntime.openProject} and {@link CleoRuntime.openGlobal}
986
+ * without `{ dedicated: true }`) are affected. Dedicated opens always
987
+ * call {@link openDualScopeDbAtPath} directly — a custom opener can
988
+ * never turn a dedicated store into a cached/shared store.
989
+ *
990
+ * Pass `undefined` to restore the default (`openDualScopeDbAtPath`).
991
+ *
992
+ * @param runtime - The runtime instance to configure.
993
+ * @param fn - The opener to use, or `undefined` to reset.
994
+ *
995
+ * @internal Test seam; imported directly from this module, not the barrel.
996
+ */
997
+ export function setRuntimeOpenFn(runtime, fn) {
998
+ runtime.openDualScopeFn = fn ?? openDualScopeDbAtPath;
999
+ }
1000
+ /**
1001
+ * Create a new {@link CleoRuntime} store registry.
1002
+ *
1003
+ * The returned runtime is the explicit composition root for project and
1004
+ * global database entries. Use {@link CleoRuntime.openProject} to open a
1005
+ * project's `cleo.db` at a canonical path, and {@link CleoRuntime.openGlobal}
1006
+ * for the global scope. Closing a project via {@link CleoRuntime.closeProject}
1007
+ * or the store's own `close()` disposes only that entry.
1008
+ *
1009
+ * ## Cross-runtime behavior
1010
+ *
1011
+ * Two separate runtime instances SHARE the **process-global dual-scope
1012
+ * handle cache** (`_cache`). Closing a store in one runtime closes the
1013
+ * shared `DatabaseSync` — another runtime referencing the same path
1014
+ * will observe a closed connection. The **cache-hit liveness** check in
1015
+ * this runtime reacquires a fresh handle on the next `openProject`/
1016
+ * `openGlobal`. This is by design: the runtime owns **entry lifecycle**,
1017
+ * not the underlying connection. Use a dedicated connection for isolation.
1018
+ *
1019
+ * @returns A fresh {@link CleoRuntime} instance with an empty registry.
1020
+ *
1021
+ * @example
1022
+ * ```ts
1023
+ * import { createCleoRuntime, resolveDualScopeDbPath } from '@cleocode/core/db';
1024
+ *
1025
+ * const runtime = createCleoRuntime();
1026
+ * const projectA = await runtime.openProject(resolveDualScopeDbPath('project', '/path/to/projA'));
1027
+ * const projectB = await runtime.openProject(resolveDualScopeDbPath('project', '/path/to/projB'));
1028
+ * // projectA and projectB are distinct, independent handles.
1029
+ * projectA.close(); // Only closes projectA; projectB and any global handle are intact.
1030
+ * ```
1031
+ *
1032
+ * @task T12036 (E6-L12)
1033
+ * @epic T11249 (E6)
1034
+ * @saga T11242
1035
+ */
1036
+ export function createCleoRuntime() {
1037
+ return new CleoRuntimeImpl();
1038
+ }
638
1039
  /**
639
1040
  * Attempt to insert `row` into `table`. If a row with the same value for
640
1041
  * `keyColumn` already exists (UNIQUE conflict), the insert is silently skipped.
@@ -656,6 +1057,11 @@ export function _resetDualScopeDbCache(scope) {
656
1057
  * the consolidated-schema MUTATION primitives, so the guard is write-only and
657
1058
  * never affects read paths.
658
1059
  *
1060
+ * Derives the writer-lease identity from the DB handle itself via
1061
+ * {@link resolveDbIdentity} — the identity was registered at
1062
+ * {@link DualScopeDbHandle} construction, so a caller cannot pair file-A
1063
+ * identity with file-B DB (T12042).
1064
+ *
659
1065
  * @example
660
1066
  * ```ts
661
1067
  * import { tasksTasksTable } from '@cleocode/core/store/schema/cleo-project';
@@ -664,6 +1070,7 @@ export function _resetDualScopeDbCache(scope) {
664
1070
  *
665
1071
  * @task T11513 (E4-T2)
666
1072
  * @task T11828 (write-side exodus-abort guard)
1073
+ * @task T12042 (E6-L12b — exact DB-bound identity)
667
1074
  * @epic T11247 (E4)
668
1075
  * @saga T11242
669
1076
  */
@@ -673,17 +1080,11 @@ db, table, row,
673
1080
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
674
1081
  _keyColumn) {
675
1082
  assertNoRecordedExodusAbort();
676
- // Seam 1 (T11627 ST-3): gate the chokepoint write through the writer lease so
677
- // it serializes with the leased cold-open (Seam 0) and every other chokepoint
678
- // write in the process. The scope AND dbPath come from the process-local
679
- // active-scope registry recorded at cold-open ({@link activeScope} /
680
- // {@link activeScopeDbPath}) — no signature change. Pinning the dbPath keeps the
681
- // lease row in the SAME file this write targets when multiple projects are open
682
- // (T11627 Finding 1). `off` mode is a pass-through (busy_timeout serializes).
683
- return withWriterLease(activeScope(), 'tasks', async () => {
1083
+ const identity = resolveDbIdentity(db);
1084
+ return withWriterLease(identity.scope, 'tasks', async () => {
684
1085
  const result = await db.insert(table).values(row).onConflictDoNothing().returning();
685
1086
  return result.length;
686
- }, { dbPath: activeScopeDbPath() });
1087
+ }, { dbPath: identity.dbPath });
687
1088
  }
688
1089
  /**
689
1090
  * Upsert `row` into `table`, updating all non-key columns when a row with
@@ -706,6 +1107,9 @@ _keyColumn) {
706
1107
  * Refuses the write (throws {@link ExodusAbortWriteUnsafeError}) when a prior
707
1108
  * exodus-on-open aborted in this process (T11828 · DHQ-059) — write-only guard.
708
1109
  *
1110
+ * Derives the writer-lease identity from the DB handle itself via
1111
+ * {@link resolveDbIdentity} (T12042 — exact DB-bound identity, no fallback).
1112
+ *
709
1113
  * @example
710
1114
  * ```ts
711
1115
  * await upsertIdempotent(db, tasksTasksTable, updatedTask, 'idempotencyKey',
@@ -714,6 +1118,7 @@ _keyColumn) {
714
1118
  *
715
1119
  * @task T11513 (E4-T2)
716
1120
  * @task T11828 (write-side exodus-abort guard)
1121
+ * @task T12042 (E6-L12b — exact DB-bound identity)
717
1122
  * @epic T11247 (E4)
718
1123
  * @saga T11242
719
1124
  */
@@ -725,10 +1130,8 @@ _keyColumn,
725
1130
  // biome-ignore lint/suspicious/noExplicitAny: column reference type varies by table
726
1131
  conflictTarget, set) {
727
1132
  assertNoRecordedExodusAbort();
728
- // Seam 1 (T11627 ST-3): gate the chokepoint upsert through the writer lease —
729
- // same active-scope-registry path as insertIdempotent (scope + pinned dbPath),
730
- // no signature change.
731
- return withWriterLease(activeScope(), 'tasks', async () => {
1133
+ const identity = resolveDbIdentity(db);
1134
+ return withWriterLease(identity.scope, 'tasks', async () => {
732
1135
  const updateSet = set ?? row;
733
1136
  const result = await db
734
1137
  .insert(table)
@@ -740,6 +1143,6 @@ conflictTarget, set) {
740
1143
  })
741
1144
  .returning();
742
1145
  return result.length;
743
- }, { dbPath: activeScopeDbPath() });
1146
+ }, { dbPath: identity.dbPath });
744
1147
  }
745
1148
  //# sourceMappingURL=dual-scope-db.js.map