@cleocode/core 2026.7.2 → 2026.8.1

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 (167) hide show
  1. package/dist/conduit/local-transport.d.ts +3 -6
  2. package/dist/conduit/local-transport.d.ts.map +1 -1
  3. package/dist/conduit/local-transport.js +6 -7
  4. package/dist/conduit/local-transport.js.map +1 -1
  5. package/dist/db/index.d.ts +15 -1
  6. package/dist/db/index.d.ts.map +1 -1
  7. package/dist/db/index.js +15 -1
  8. package/dist/db/index.js.map +1 -1
  9. package/dist/docs/export-document.js +16032 -22124
  10. package/dist/docs/export-document.js.map +3 -3
  11. package/dist/goal/store.d.ts.map +1 -1
  12. package/dist/goal/store.js.map +1 -1
  13. package/dist/hooks/payload-schemas.d.ts +9 -9
  14. package/dist/llm/pi/pi-session-storage.d.ts.map +1 -1
  15. package/dist/llm/pi/pi-session-storage.js +6 -1
  16. package/dist/llm/pi/pi-session-storage.js.map +1 -1
  17. package/dist/llm/plugin-facade.js +5532 -16583
  18. package/dist/llm/plugin-facade.js.map +3 -3
  19. package/dist/memory/brain-links.d.ts +26 -2
  20. package/dist/memory/brain-links.d.ts.map +1 -1
  21. package/dist/memory/brain-links.js +65 -19
  22. package/dist/memory/brain-links.js.map +1 -1
  23. package/dist/memory/brain-writer-thread.d.ts.map +1 -1
  24. package/dist/memory/brain-writer-thread.js +31 -40
  25. package/dist/memory/brain-writer-thread.js.map +1 -1
  26. package/dist/memory/claude-mem-migration.d.ts.map +1 -1
  27. package/dist/memory/claude-mem-migration.js +7 -1
  28. package/dist/memory/claude-mem-migration.js.map +1 -1
  29. package/dist/memory/dialectic-evaluator.d.ts +11 -6
  30. package/dist/memory/dialectic-evaluator.d.ts.map +1 -1
  31. package/dist/memory/dialectic-evaluator.js +8 -2
  32. package/dist/memory/dialectic-evaluator.js.map +1 -1
  33. package/dist/memory/graph-memory-bridge.d.ts.map +1 -1
  34. package/dist/memory/graph-memory-bridge.js +3 -1
  35. package/dist/memory/graph-memory-bridge.js.map +1 -1
  36. package/dist/memory/learnings.d.ts +8 -8
  37. package/dist/memory/patterns.d.ts +14 -14
  38. package/dist/memory/retrieval/build-retrieval-bundle.d.ts +1 -1
  39. package/dist/memory/retrieval/build-retrieval-bundle.d.ts.map +1 -1
  40. package/dist/memory/retrieval/build-retrieval-bundle.js.map +1 -1
  41. package/dist/memory/retrieval/observe.d.ts.map +1 -1
  42. package/dist/memory/retrieval/observe.js +31 -16
  43. package/dist/memory/retrieval/observe.js.map +1 -1
  44. package/dist/memory/session-memory.d.ts.map +1 -1
  45. package/dist/memory/session-memory.js +35 -1
  46. package/dist/memory/session-memory.js.map +1 -1
  47. package/dist/nexus/migrations/M-split-graph.d.ts.map +1 -1
  48. package/dist/nexus/migrations/M-split-graph.js +4 -1
  49. package/dist/nexus/migrations/M-split-graph.js.map +1 -1
  50. package/dist/nexus/sigil.d.ts +1 -2
  51. package/dist/nexus/sigil.d.ts.map +1 -1
  52. package/dist/nexus/user-profile.d.ts +1 -2
  53. package/dist/nexus/user-profile.d.ts.map +1 -1
  54. package/dist/orchestration/spawn-prompt.d.ts.map +1 -1
  55. package/dist/orchestration/spawn-prompt.js +4 -2
  56. package/dist/orchestration/spawn-prompt.js.map +1 -1
  57. package/dist/scaffold/ensure-skills.d.ts.map +1 -1
  58. package/dist/scaffold/ensure-skills.js +67 -0
  59. package/dist/scaffold/ensure-skills.js.map +1 -1
  60. package/dist/selfimprove/dhq-adapter.d.ts.map +1 -1
  61. package/dist/selfimprove/dhq-adapter.js +6 -2
  62. package/dist/selfimprove/dhq-adapter.js.map +1 -1
  63. package/dist/selfimprove/fix-gen.js +5635 -16565
  64. package/dist/selfimprove/fix-gen.js.map +3 -3
  65. package/dist/store/agent-registry-store.d.ts.map +1 -1
  66. package/dist/store/agent-registry-store.js +1 -2
  67. package/dist/store/agent-registry-store.js.map +1 -1
  68. package/dist/store/background-jobs.d.ts +1 -2
  69. package/dist/store/background-jobs.d.ts.map +1 -1
  70. package/dist/store/background-jobs.js.map +1 -1
  71. package/dist/store/conduit-sqlite.d.ts.map +1 -1
  72. package/dist/store/conduit-sqlite.js +1 -2
  73. package/dist/store/conduit-sqlite.js.map +1 -1
  74. package/dist/store/cross-db-cleanup.d.ts +38 -0
  75. package/dist/store/cross-db-cleanup.d.ts.map +1 -1
  76. package/dist/store/cross-db-cleanup.js +125 -0
  77. package/dist/store/cross-db-cleanup.js.map +1 -1
  78. package/dist/store/db-helpers.d.ts +1 -2
  79. package/dist/store/db-helpers.d.ts.map +1 -1
  80. package/dist/store/dual-scope-db.d.ts +304 -4
  81. package/dist/store/dual-scope-db.d.ts.map +1 -1
  82. package/dist/store/dual-scope-db.js +647 -262
  83. package/dist/store/dual-scope-db.js.map +1 -1
  84. package/dist/store/memory-accessor.d.ts +1 -1
  85. package/dist/store/memory-accessor.d.ts.map +1 -1
  86. package/dist/store/memory-accessor.js.map +1 -1
  87. package/dist/store/memory-sqlite.d.ts +1 -1
  88. package/dist/store/memory-sqlite.d.ts.map +1 -1
  89. package/dist/store/memory-sqlite.js +18 -7
  90. package/dist/store/memory-sqlite.js.map +1 -1
  91. package/dist/store/migration-manager.d.ts +7 -4
  92. package/dist/store/migration-manager.d.ts.map +1 -1
  93. package/dist/store/migration-manager.js +10 -10
  94. package/dist/store/migration-manager.js.map +1 -1
  95. package/dist/store/migration-sqlite.js +1 -1
  96. package/dist/store/migration-sqlite.js.map +1 -1
  97. package/dist/store/nexus-sqlite.d.ts +1 -1
  98. package/dist/store/nexus-sqlite.d.ts.map +1 -1
  99. package/dist/store/nexus-sqlite.js +1 -1
  100. package/dist/store/nexus-sqlite.js.map +1 -1
  101. package/dist/store/schema/attachments.d.ts +1 -1
  102. package/dist/store/schema/audit.d.ts +2 -2
  103. package/dist/store/schema/background-jobs.d.ts +1 -1
  104. package/dist/store/schema/cleo-global/agent-registry.d.ts +1 -1
  105. package/dist/store/schema/cleo-global/models-catalog.d.ts +1 -1
  106. package/dist/store/schema/cleo-global/skills.d.ts +2 -2
  107. package/dist/store/schema/cleo-project/audit.d.ts +2 -2
  108. package/dist/store/schema/cleo-project/docs.d.ts +1 -1
  109. package/dist/store/schema/cleo-project/lifecycle.d.ts +4 -4
  110. package/dist/store/schema/cleo-project/nexus-graph.d.ts +2 -2
  111. package/dist/store/schema/cleo-project/provenance-rest.d.ts +3 -3
  112. package/dist/store/schema/cleo-project/runtime.d.ts +2 -2
  113. package/dist/store/schema/cleo-project/tasks-core-batch2.d.ts +1 -1
  114. package/dist/store/schema/cleo-project/tasks-core.d.ts +3 -3
  115. package/dist/store/schema/cleo-shared/brain.d.ts +5 -5
  116. package/dist/store/schema/jsonb.js +1 -1
  117. package/dist/store/schema/jsonb.js.map +1 -1
  118. package/dist/store/schema/lifecycle.d.ts +4 -4
  119. package/dist/store/schema/memory-schema.d.ts +3 -3
  120. package/dist/store/schema/nexus-schema.d.ts +1 -1
  121. package/dist/store/schema/provenance/releases.d.ts +3 -3
  122. package/dist/store/schema/skills-schema.d.ts +2 -2
  123. package/dist/store/schema/tasks.d.ts +2 -2
  124. package/dist/store/skills-db.d.ts +1 -1
  125. package/dist/store/skills-db.d.ts.map +1 -1
  126. package/dist/store/skills-db.js +1 -1
  127. package/dist/store/skills-db.js.map +1 -1
  128. package/dist/store/sqlite.d.ts +1 -1
  129. package/dist/store/sqlite.d.ts.map +1 -1
  130. package/dist/store/sqlite.js +30 -8
  131. package/dist/store/sqlite.js.map +1 -1
  132. package/dist/store/validation-schemas.d.ts +35 -35
  133. package/dist/store/writer-lease.d.ts +85 -53
  134. package/dist/store/writer-lease.d.ts.map +1 -1
  135. package/dist/store/writer-lease.js +194 -112
  136. package/dist/store/writer-lease.js.map +1 -1
  137. package/dist/tasks/add.d.ts.map +1 -1
  138. package/dist/tasks/add.js +9 -2
  139. package/dist/tasks/add.js.map +1 -1
  140. package/dist/tasks/backfill-child-projections.d.ts.map +1 -1
  141. package/dist/tasks/backfill-child-projections.js +3 -1
  142. package/dist/tasks/backfill-child-projections.js.map +1 -1
  143. package/dist/tasks/evidence.d.ts +17 -0
  144. package/dist/tasks/evidence.d.ts.map +1 -1
  145. package/dist/tasks/evidence.js +145 -35
  146. package/dist/tasks/evidence.js.map +1 -1
  147. package/dist/tasks/infer-add-params.d.ts.map +1 -1
  148. package/dist/tasks/infer-add-params.js +6 -1
  149. package/dist/tasks/infer-add-params.js.map +1 -1
  150. package/dist/tasks/tool-cache.d.ts +29 -0
  151. package/dist/tasks/tool-cache.d.ts.map +1 -1
  152. package/dist/tasks/tool-cache.js +166 -6
  153. package/dist/tasks/tool-cache.js.map +1 -1
  154. package/dist/tasks/tool-resolver.d.ts +5 -1
  155. package/dist/tasks/tool-resolver.d.ts.map +1 -1
  156. package/dist/tasks/tool-resolver.js +28 -20
  157. package/dist/tasks/tool-resolver.js.map +1 -1
  158. package/dist/telemetry/index.d.ts.map +1 -1
  159. package/dist/telemetry/index.js +4 -1
  160. package/dist/telemetry/index.js.map +1 -1
  161. package/dist/telemetry/sqlite.d.ts +1 -2
  162. package/dist/telemetry/sqlite.d.ts.map +1 -1
  163. package/dist/telemetry/sqlite.js +1 -2
  164. package/dist/telemetry/sqlite.js.map +1 -1
  165. package/package.json +14 -14
  166. package/schemas/project-context.schema.json +69 -0
  167. 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,354 @@ 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';
682
+ }
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';
636
696
  }
637
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, undefined, {
754
+ dedicated: true,
755
+ });
756
+ return this.buildStore('project', handle, 0);
757
+ }
758
+ return this.openEntry('project', normalized);
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, undefined, {
766
+ dedicated: true,
767
+ });
768
+ return this.buildStore('global', handle, 0);
769
+ }
770
+ return this.openEntry('global', normalized);
771
+ }
772
+ /** @inheritdoc */
773
+ closeProject(dbPath) {
774
+ const normalized = resolve(dbPath);
775
+ this.closeEntry('project', normalized);
776
+ }
777
+ /** @inheritdoc */
778
+ closeAll() {
779
+ // Snapshot keys: closeEntry mutates the map during iteration.
780
+ for (const key of [...this._registry.keys()]) {
781
+ try {
782
+ this.closeEntryByKey(key);
783
+ }
784
+ catch {
785
+ // Continue closing remaining entries.
786
+ }
787
+ }
788
+ }
789
+ // ── Private helpers ──────────────────────────────────────────────────────
790
+ /**
791
+ * Open (or reuse) a scoped entry. All five key invariants are enforced
792
+ * on this single code path.
793
+ *
794
+ * @param depth - Reacquisition depth guard. Starts at 0; capped at 1.
795
+ * When the post-await liveness check finds a dead handle, the bounded
796
+ * retry calls this with `depth + 1`. A second dead handle at depth 1
797
+ * throws {@link LivenessExhaustedError} instead of recursing further.
798
+ */
799
+ async openEntry(scope, dbPath, depth = 0) {
800
+ const key = cacheKey(scope, dbPath);
801
+ // ── Cache-hit liveness (invariant 4) ─────────────────────────────────
802
+ const existing = this._registry.get(key);
803
+ if (existing) {
804
+ if (existing.initPromise) {
805
+ return existing.initPromise;
806
+ }
807
+ if (existing.store?.isOpen) {
808
+ return existing.store;
809
+ }
810
+ // Handle was closed externally — evict and re-open.
811
+ this._registry.delete(key);
812
+ }
813
+ // ── Placeholder with identity (invariant 2) ──────────────────────────
814
+ const entryId = ++this._nextId;
815
+ let initResolve;
816
+ let initReject;
817
+ const initPromise = new Promise((resolve, reject) => {
818
+ initResolve = resolve;
819
+ initReject = reject;
820
+ });
821
+ this._registry.set(key, { id: entryId, store: null, initPromise });
822
+ // Evict on failure so a transient error doesn't poison.
823
+ initPromise.catch(() => {
824
+ const placeholder = this._registry.get(key);
825
+ if (placeholder?.initPromise === initPromise) {
826
+ this._registry.delete(key);
827
+ }
828
+ });
829
+ try {
830
+ // Dispatch on scope literal so the overloaded opener resolves to the
831
+ // correct typed return. The union `DualScope` cannot satisfy either
832
+ // literal overload directly.
833
+ const dualHandle = scope === 'project'
834
+ ? await this.openDualScopeFn('project', dbPath)
835
+ : await this.openDualScopeFn('global', dbPath);
836
+ // ── Publish only if THIS init is still current (invariant 2) ───────
837
+ const current = this._registry.get(key);
838
+ if (!current || current.id !== entryId) {
839
+ // Cancelled during init. Do NOT close dualHandle — a replacement
840
+ // entry for the same scope/key may be awaiting the same in-flight
841
+ // chokepoint promise. The handle stays in the shared cache; the
842
+ // replacement inherits it live.
843
+ throw new EntryCancelledError(scope, dbPath);
844
+ }
845
+ // ── Post-await liveness (invariant 4b) ─────────────────────────
846
+ // The chokepoint resolved with a live handle, but an external close
847
+ // (e.g. _resetDualScopeDbCache or a test-injected dead-handle opener)
848
+ // between the await and this publish can kill it. If the handle is
849
+ // dead, close it (evict from cache), delete the placeholder, and
850
+ // perform ONE bounded reacquisition.
851
+ if (!dualHandle.isOpen) {
852
+ dualHandle.close();
853
+ this._registry.delete(key);
854
+ if (depth >= 1) {
855
+ throw new LivenessExhaustedError(scope, dbPath);
856
+ }
857
+ const retryStore = await this.openEntry(scope, dbPath, depth + 1);
858
+ initResolve(retryStore);
859
+ return retryStore;
860
+ }
861
+ const store = this.buildStore(scope, dualHandle, entryId);
862
+ current.store = store;
863
+ current.initPromise = null;
864
+ initResolve(store);
865
+ return store;
866
+ }
867
+ catch (err) {
868
+ initReject(err);
869
+ throw err;
870
+ }
871
+ }
872
+ /**
873
+ * Build a typed {@link ProjectStore} or {@link GlobalStore} from a
874
+ * {@link DualScopeDbHandle}. The store's `close()` is stale-safe
875
+ * (invariant 3): it only evicts the registry entry when the entry's
876
+ * identity still matches.
877
+ */
878
+ buildStore(scope, dualHandle, entryId) {
879
+ const dbPath = dualHandle.dbPath;
880
+ const identity = dualHandle.identity;
881
+ const exodusAbort = dualHandle.exodusAbort;
882
+ const key = cacheKey(scope, dbPath);
883
+ if (scope === 'project') {
884
+ const store = {
885
+ scope: 'project',
886
+ dbPath,
887
+ identity,
888
+ db: dualHandle.db,
889
+ get isOpen() {
890
+ return dualHandle.isOpen;
891
+ },
892
+ exodusAbort,
893
+ close: () => {
894
+ dualHandle.close();
895
+ this.cleanupEntry(key, entryId);
896
+ },
897
+ };
898
+ return store;
899
+ }
900
+ const store = {
901
+ scope: 'global',
902
+ dbPath,
903
+ identity,
904
+ db: dualHandle.db,
905
+ get isOpen() {
906
+ return dualHandle.isOpen;
907
+ },
908
+ exodusAbort,
909
+ close: () => {
910
+ dualHandle.close();
911
+ this.cleanupEntry(key, entryId);
912
+ },
913
+ };
914
+ return store;
915
+ }
916
+ /**
917
+ * Conditionally evict a registry entry. Only deletes when the current
918
+ * entry's identity still matches `entryId` — an old stale close is a no-op
919
+ * (invariant 3).
920
+ */
921
+ cleanupEntry(key, entryId) {
922
+ const entry = this._registry.get(key);
923
+ if (entry && entry.id === entryId) {
924
+ this._registry.delete(key);
925
+ }
926
+ }
927
+ /**
928
+ * Close and evict a single scoped entry from the registry by composite
929
+ * key. Cancels in-flight opens by deleting the placeholder entry — the
930
+ * in-flight init detects the missing/id-mismatched entry and rejects
931
+ * after its `await` resolves (invariant 2).
932
+ */
933
+ closeEntry(scope, dbPath) {
934
+ this.closeEntryByKey(cacheKey(scope, dbPath));
935
+ }
936
+ /**
937
+ * Close and evict a single entry by composite registry key.
938
+ */
939
+ closeEntryByKey(key) {
940
+ const entry = this._registry.get(key);
941
+ if (!entry)
942
+ return;
943
+ // Delete FIRST so in-flight inits observe a missing entry
944
+ // (invariant 2 — cancellation). Even if the store close below throws,
945
+ // the entry is gone — the caller won't get it back and the dual-scope
946
+ // chokepoint cache still owns the SQLite close.
947
+ this._registry.delete(key);
948
+ if (entry.store) {
949
+ try {
950
+ entry.store.close();
951
+ }
952
+ catch {
953
+ // Close is best-effort; the entry is already evicted.
954
+ }
955
+ }
956
+ }
957
+ }
958
+ /**
959
+ * Override the dual-scope opener function used by the given
960
+ * {@link CleoRuntime} instance. Tests inject a custom opener to return a
961
+ * pre-closed handle, exercising the post-await liveness and bounded-
962
+ * reacquisition code paths.
963
+ *
964
+ * The injected function receives the same `(scope, dbPath)` signature as
965
+ * {@link openDualScopeDbAtPath} and must return a
966
+ * {@link DualScopeDbHandle}. Only non-dedicated opens (
967
+ * {@link CleoRuntime.openProject} and {@link CleoRuntime.openGlobal}
968
+ * without `{ dedicated: true }`) are affected. Dedicated opens always
969
+ * call {@link openDualScopeDbAtPath} directly — a custom opener can
970
+ * never turn a dedicated store into a cached/shared store.
971
+ *
972
+ * Pass `undefined` to restore the default (`openDualScopeDbAtPath`).
973
+ *
974
+ * @param runtime - The runtime instance to configure.
975
+ * @param fn - The opener to use, or `undefined` to reset.
976
+ *
977
+ * @internal Test seam; imported directly from this module, not the barrel.
978
+ */
979
+ export function setRuntimeOpenFn(runtime, fn) {
980
+ runtime.openDualScopeFn = fn ?? openDualScopeDbAtPath;
981
+ }
982
+ /**
983
+ * Create a new {@link CleoRuntime} store registry.
984
+ *
985
+ * The returned runtime is the explicit composition root for project and
986
+ * global database entries. Use {@link CleoRuntime.openProject} to open a
987
+ * project's `cleo.db` at a canonical path, and {@link CleoRuntime.openGlobal}
988
+ * for the global scope. Closing a project via {@link CleoRuntime.closeProject}
989
+ * or the store's own `close()` disposes only that entry.
990
+ *
991
+ * ## Cross-runtime behavior
992
+ *
993
+ * Two separate runtime instances SHARE the **process-global dual-scope
994
+ * handle cache** (`_cache`). Closing a store in one runtime closes the
995
+ * shared `DatabaseSync` — another runtime referencing the same path
996
+ * will observe a closed connection. The **cache-hit liveness** check in
997
+ * this runtime reacquires a fresh handle on the next `openProject`/
998
+ * `openGlobal`. This is by design: the runtime owns **entry lifecycle**,
999
+ * not the underlying connection. Use a dedicated connection for isolation.
1000
+ *
1001
+ * @returns A fresh {@link CleoRuntime} instance with an empty registry.
1002
+ *
1003
+ * @example
1004
+ * ```ts
1005
+ * import { createCleoRuntime, resolveDualScopeDbPath } from '@cleocode/core/db';
1006
+ *
1007
+ * const runtime = createCleoRuntime();
1008
+ * const projectA = await runtime.openProject(resolveDualScopeDbPath('project', '/path/to/projA'));
1009
+ * const projectB = await runtime.openProject(resolveDualScopeDbPath('project', '/path/to/projB'));
1010
+ * // projectA and projectB are distinct, independent handles.
1011
+ * projectA.close(); // Only closes projectA; projectB and any global handle are intact.
1012
+ * ```
1013
+ *
1014
+ * @task T12036 (E6-L12)
1015
+ * @epic T11249 (E6)
1016
+ * @saga T11242
1017
+ */
1018
+ export function createCleoRuntime() {
1019
+ return new CleoRuntimeImpl();
1020
+ }
638
1021
  /**
639
1022
  * Attempt to insert `row` into `table`. If a row with the same value for
640
1023
  * `keyColumn` already exists (UNIQUE conflict), the insert is silently skipped.
@@ -656,6 +1039,11 @@ export function _resetDualScopeDbCache(scope) {
656
1039
  * the consolidated-schema MUTATION primitives, so the guard is write-only and
657
1040
  * never affects read paths.
658
1041
  *
1042
+ * Derives the writer-lease identity from the DB handle itself via
1043
+ * {@link resolveDbIdentity} — the identity was registered at
1044
+ * {@link DualScopeDbHandle} construction, so a caller cannot pair file-A
1045
+ * identity with file-B DB (T12042).
1046
+ *
659
1047
  * @example
660
1048
  * ```ts
661
1049
  * import { tasksTasksTable } from '@cleocode/core/store/schema/cleo-project';
@@ -664,6 +1052,7 @@ export function _resetDualScopeDbCache(scope) {
664
1052
  *
665
1053
  * @task T11513 (E4-T2)
666
1054
  * @task T11828 (write-side exodus-abort guard)
1055
+ * @task T12042 (E6-L12b — exact DB-bound identity)
667
1056
  * @epic T11247 (E4)
668
1057
  * @saga T11242
669
1058
  */
@@ -673,17 +1062,11 @@ db, table, row,
673
1062
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
674
1063
  _keyColumn) {
675
1064
  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 () => {
1065
+ const identity = resolveDbIdentity(db);
1066
+ return withWriterLease(identity.scope, 'tasks', async () => {
684
1067
  const result = await db.insert(table).values(row).onConflictDoNothing().returning();
685
1068
  return result.length;
686
- }, { dbPath: activeScopeDbPath() });
1069
+ }, { dbPath: identity.dbPath });
687
1070
  }
688
1071
  /**
689
1072
  * Upsert `row` into `table`, updating all non-key columns when a row with
@@ -706,6 +1089,9 @@ _keyColumn) {
706
1089
  * Refuses the write (throws {@link ExodusAbortWriteUnsafeError}) when a prior
707
1090
  * exodus-on-open aborted in this process (T11828 · DHQ-059) — write-only guard.
708
1091
  *
1092
+ * Derives the writer-lease identity from the DB handle itself via
1093
+ * {@link resolveDbIdentity} (T12042 — exact DB-bound identity, no fallback).
1094
+ *
709
1095
  * @example
710
1096
  * ```ts
711
1097
  * await upsertIdempotent(db, tasksTasksTable, updatedTask, 'idempotencyKey',
@@ -714,6 +1100,7 @@ _keyColumn) {
714
1100
  *
715
1101
  * @task T11513 (E4-T2)
716
1102
  * @task T11828 (write-side exodus-abort guard)
1103
+ * @task T12042 (E6-L12b — exact DB-bound identity)
717
1104
  * @epic T11247 (E4)
718
1105
  * @saga T11242
719
1106
  */
@@ -725,10 +1112,8 @@ _keyColumn,
725
1112
  // biome-ignore lint/suspicious/noExplicitAny: column reference type varies by table
726
1113
  conflictTarget, set) {
727
1114
  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 () => {
1115
+ const identity = resolveDbIdentity(db);
1116
+ return withWriterLease(identity.scope, 'tasks', async () => {
732
1117
  const updateSet = set ?? row;
733
1118
  const result = await db
734
1119
  .insert(table)
@@ -740,6 +1125,6 @@ conflictTarget, set) {
740
1125
  })
741
1126
  .returning();
742
1127
  return result.length;
743
- }, { dbPath: activeScopeDbPath() });
1128
+ }, { dbPath: identity.dbPath });
744
1129
  }
745
1130
  //# sourceMappingURL=dual-scope-db.js.map