import { y as resolveCleoDir, g as getCleoHome, b as getLogger } from './paths-2M_UxD_H.js';
import { w as withLock } from './lock-BoHF25mx.js';
import './index-IVoIz4D5.js';
import { readFileSync, existsSync, mkdirSync, writeFileSync, renameSync, copyFileSync, unlinkSync } from 'node:fs';
import { join, resolve, dirname, basename } from 'node:path';
import 'node:crypto';
import 'node:fs/promises';
import './index-Ca4C2M45.js';
import { fileURLToPath } from 'node:url';

//#region ../core/dist/scaffold/ensure-config.js
/**
* Resolve the package root directory (where schemas/ and templates/ live).
*
* @returns Absolute path to the @cleocode/core package root
*/
function getPackageRoot() {
	return resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
}
/**
* Read CLEO version from package.json.
*
* @returns Semver version string, or "0.0.0" if unavailable
*/
function getCleoVersion() {
	try {
		const pkgPath = join(getPackageRoot(), "package.json");
		return JSON.parse(readFileSync(pkgPath, "utf-8")).version ?? "0.0.0";
	} catch {
		return "0.0.0";
	}
}

//#region ../core/dist/store/exodus/archive.js
/**
* Exodus source-DB ARCHIVE + completion-marker subsystem (T11777).
*
* ## Why this exists (stranded-residue corruption trigger)
*
* The exodus engine migrates from SIX legacy source DBs (project: `tasks.db`,
* `brain.db`, `conduit.db`; global: `nexus.db`, `signaldock.db`, `skills.db`)
* but, historically, NEVER retired them — `on-open.ts` literally notes "the file
* is never unlinked." Every cutover therefore STRANDS the legacy files, and each
* stranded file re-arms the `tasks_tasks=0` auto-recover / exodus-on-open
* corruption trigger (DHQ-052 · T11662): on the next open the consolidated DB
* looks empty-with-legacy-present and the hook re-fires.
*
* This module closes the loop. After a migration's lossless validation passes
* (row-count parity + integrity — `verifyMigration` / `isDataContinuityOk`), the
* consumed source DBs are ARCHIVED (moved, never deleted) into a per-scope
* `_archive/` directory, and a committed COMPLETION MARKER records the cutover.
* The marker becomes the durable "this scope is already migrated" signal so a
* re-appearing or stranded legacy file can never re-trigger exodus-on-open.
*
* ## Archive destinations (per scope, via the paths SSoT)
*
*   - project sources → `<cleoDir>/_archive/`     (e.g. `.cleo/_archive/`)
*   - global  sources → `<cleoHome>/_archive/`    (e.g. `~/.local/share/cleo/_archive/`)
*
* Both are resolved through `resolveCleoDir(cwd)` / `getCleoHome()` — never a
* hardcoded `~/.local/share` (Paths SSoT · Gate 2 · D009).
*
* ## Reversibility + idempotency invariants
*
*   - **Reversible** — archiving is an atomic `rename` (fallback copy+unlink
*     across filesystems). Nothing is ever deleted; an operator can move a DB
*     back out of `_archive/` to roll the cutover back.
*   - **Idempotent** — a source that is already absent (already archived, or a
*     fresh install that never had it) is a silent no-op. Re-running over an
*     already-archived fleet does nothing and never throws.
*   - **Never blind-move** — only sources the caller asserts were actually
*     consumed + validated by the migration are archived. A source whose
*     migration did not run is left untouched.
*   - **Emergency-archive reconciliation** — this box was emergency-archived
*     (`.cleo/_archive-legacy-postcutover-*` already holds `tasks.db` +
*     `conduit.db`). When the canonical destination already contains a file with
*     the same name, the incoming file is parked under a timestamped sibling name
*     rather than clobbering the prior archive.
*
* @module
* @task T11777 (exodus archives all 6 legacy DBs post-validation + completion marker)
* @epic T11249 (E6)
* @saga T11242 (SG-DB-SUBSTRATE-V2)
* @see packages/core/src/store/exodus/on-open.ts — wires this into the validated success path
* @see packages/core/src/store/exodus/plan.ts — buildSourceDescriptors (the 6 sources)
*/
var log$1 = getLogger("exodus-archive");
/** Per-scope archive directory name (sibling of the migrated DBs). */
var ARCHIVE_DIR_NAME = "_archive";
/** Per-scope completion-marker filename written next to the consolidated cleo.db. */
var MARKER_FILENAME_BY_SCOPE = {
	project: "exodus-complete",
	global: "exodus-complete"
};
/** SQLite sidecar suffixes archived alongside the main DB file. */
var SIDECAR_SUFFIXES = ["-wal", "-shm"];
/**
* Resolve the directory that holds a scope's legacy source DBs (and therefore
* its archive + completion marker): project → `<cleoDir>`, global →
* `<cleoHome>`. Always via the paths SSoT.
*
* @param scope - Target scope.
* @param cwd   - Working directory used to resolve the project `.cleo/` dir.
* @returns Absolute path to the scope's base directory.
*/
function scopeBaseDir(scope, cwd) {
	return scope === "project" ? resolveCleoDir(cwd) : getCleoHome();
}
/**
* Absolute path to a scope's `_archive/` directory.
*
* @param scope - Target scope.
* @param cwd   - Working directory used to resolve the project `.cleo/` dir.
* @returns Absolute path to `<scopeBase>/_archive/`.
*/
function exodusArchiveDir(scope, cwd) {
	return join(scopeBaseDir(scope, cwd), ARCHIVE_DIR_NAME);
}
/**
* Absolute path to a scope's exodus completion marker file.
*
* @param scope - Target scope.
* @param cwd   - Working directory used to resolve the project `.cleo/` dir.
* @returns Absolute path to `<scopeBase>/exodus-complete`.
*/
function exodusMarkerPath(scope, cwd) {
	return join(scopeBaseDir(scope, cwd), MARKER_FILENAME_BY_SCOPE[scope]);
}
/**
* Return `true` if a scope's exodus completion marker exists on disk.
*
* Resolution-safe: when the project `.cleo/` dir cannot be resolved (e.g. `cwd`
* is not inside a CLEO project — `resolveCleoDir` throws), this returns `false`
* (no marker) rather than propagating, so the on-open trigger gate degrades to
* the source-file path safely instead of crashing the open.
*
* @param scope - Target scope.
* @param cwd   - Working directory used to resolve the project `.cleo/` dir.
* @returns Whether `<scopeBase>/exodus-complete` exists.
*/
function hasExodusCompleteMarker(scope, cwd) {
	try {
		return existsSync(exodusMarkerPath(scope, cwd));
	} catch {
		return false;
	}
}
/**
* Write a scope's exodus completion marker atomically (write-then-rename).
*
* Idempotent: re-writing simply refreshes the marker (same path). The marker is
* the SSoT trigger-gate consulted by {@link maybeRunExodusOnOpen} — once present,
* a stranded/re-appearing legacy file cannot re-arm the auto-migration.
*
* @param scope           - Scope being certified as migrated.
* @param archivedSources - Logical names of the sources archived for this scope.
* @param cwd             - Working directory used to resolve the project dir.
* @returns The marker's absolute path.
*
* @task T11777
*/
function writeExodusCompleteMarker(scope, archivedSources, cwd) {
	const markerPath = exodusMarkerPath(scope, cwd);
	mkdirSync(scopeBaseDir(scope, cwd), { recursive: true });
	const marker = {
		version: 1,
		scope,
		cleoVersion: getCleoVersion(),
		completedAt: (/* @__PURE__ */ new Date()).toISOString(),
		archivedSources: [...archivedSources]
	};
	const tmpPath = `${markerPath}.tmp`;
	writeFileSync(tmpPath, JSON.stringify(marker, null, 2) + "\n", "utf8");
	renameSync(tmpPath, markerPath);
	log$1.info({
		scope,
		markerPath,
		archivedSources
	}, "exodus: wrote completion marker");
	return markerPath;
}
/**
* Move a single file to `destDir`, atomically when possible.
*
* Uses `rename`; on a cross-filesystem `EXDEV` (or any rename failure) falls back
* to copy-then-unlink so the move still completes. When `destDir` already holds a
* file with the same name (e.g. a prior emergency archive), the incoming file is
* parked under a timestamped sibling name so the prior archive is never clobbered.
*
* @param srcPath - Absolute source file path (assumed to exist).
* @param destDir - Absolute archive directory (created if missing).
* @returns The absolute destination path the file landed at.
*/
function moveFileInto(srcPath, destDir) {
	mkdirSync(destDir, { recursive: true });
	let dest = join(destDir, basename(srcPath));
	if (existsSync(dest)) {
		const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "").replace(/Z$/, "Z");
		dest = join(destDir, `${basename(srcPath)}.${stamp}`);
	}
	try {
		renameSync(srcPath, dest);
	} catch {
		copyFileSync(srcPath, dest);
		unlinkSync(srcPath);
	}
	return dest;
}
/**
* Archive ONE legacy source DB (and its `-wal` / `-shm` sidecars) into the
* scope's `_archive/` directory.
*
* Idempotent: if the main DB file is already absent, this is a no-op (the file
* was already archived, or never existed). Sidecars are archived best-effort and
* only when the main DB is present.
*
* @param source - The descriptor for the source DB to archive.
* @param cwd    - Working directory used to resolve the project dir.
* @returns A {@link ArchivedSourceResult} describing what happened.
*
* @task T11777
*/
function archiveSourceDb(source, cwd) {
	if (!existsSync(source.path)) return {
		name: source.name,
		sourcePath: source.path,
		archivedTo: null,
		action: "absent"
	};
	const destDir = exodusArchiveDir(source.targetScope, cwd);
	const archivedTo = moveFileInto(source.path, destDir);
	for (const suffix of SIDECAR_SUFFIXES) {
		const sidecar = `${source.path}${suffix}`;
		if (existsSync(sidecar)) try {
			moveFileInto(sidecar, destDir);
		} catch (err) {
			log$1.warn({
				err,
				sidecar,
				sourceName: source.name
			}, "exodus-archive: failed to archive sidecar (non-fatal)");
		}
	}
	log$1.info({
		sourceName: source.name,
		sourcePath: source.path,
		archivedTo,
		scope: source.targetScope
	}, "exodus-archive: archived legacy source DB");
	return {
		name: source.name,
		sourcePath: source.path,
		archivedTo,
		action: "archived"
	};
}
/**
* Archive every consumed legacy source DB AFTER a validated cutover and write a
* per-scope completion marker.
*
* **Never blind-moves**: only the descriptors passed in `consumed` are archived
* — the caller (the validated migrate/on-open success path) passes exactly the
* sources whose migration actually ran and passed parity. A completion marker is
* written for every scope represented in `consumed`, even if some of that scope's
* sources were already absent (already archived) — the marker certifies "this
* scope's cutover is done", which is true once parity passed.
*
* Idempotent + reversible (see module docs). Safe to call repeatedly.
*
* @param consumed - Source descriptors the migration consumed + validated.
* @param cwd      - Working directory used to resolve the project dir.
* @returns A {@link ArchiveMigratedSourcesResult} with per-source + per-scope outcomes.
*
* @task T11777
*/
function archiveMigratedSources(consumed, cwd) {
	const results = [];
	const scopes = /* @__PURE__ */ new Set();
	for (const source of consumed) {
		scopes.add(source.targetScope);
		results.push(archiveSourceDb(source, cwd));
	}
	const markersWritten = [];
	for (const scope of scopes) {
		writeExodusCompleteMarker(scope, consumed.filter((s) => s.targetScope === scope).map((s) => s.name), cwd);
		markersWritten.push(scope);
	}
	return {
		sources: results,
		markersWritten
	};
}

//#region ../core/dist/store/exodus/on-open.js
/**
* Exodus-on-open — lazy, idempotent, parity-gated auto-migration of the legacy
* multi-DB fleet into the consolidated dual-scope `cleo.db` on first open.
*
* ## Why this exists (data-continuity safety net · T11553)
*
* E6 routes every `getDb`/`getBrainDb`/etc read at the consolidated `cleo.db`.
* On an *existing* install the consolidated DB is freshly migrated but **empty**
* (0 base-table rows), while the user's real data still lives in the legacy
* `tasks.db` / `brain.db` / `conduit.db` / `signaldock.db` fleet (e.g. 4465
* tasks). Without an auto-migration the E8/T11251 cutover would make every
* user's data **invisible**. This module wires the existing `runExodusMigrate`
* engine to run **once, automatically, on first open** — gated by the
* `verifyMigration` (T11551) parity check so a partial or lossy migration NEVER
* becomes the live source of truth.
*
* ## Trigger condition (AC1)
*
* On first `openDualScopeDb(scope)` the hook runs iff **both**:
*   1. the consolidated `cleo.db` for that scope is EMPTY (the canonical first
*      base table has zero rows), AND
*   2. at least one legacy source DB for that scope has rows.
*
* ## Idempotency (AC1)
*
* After a successful migration the consolidated DB is non-empty, so the
* emptiness check short-circuits on every subsequent open — a second open is a
* no-op. The check is also re-evaluated *inside* the single-flight lock
* (double-checked locking) so the process that loses a concurrency race never
* re-migrates.
*
* ## Parity gate + clean abort (AC2)
*
* After the copy, `verifyMigration` (T11551) compares row counts + canonical
* digests + FK integrity + enum drift legacy↔consolidated. The data-continuity
* gate is **row-count parity + zero migration-INTRODUCED FK orphans** (hash/enum
* drift are the expected normalisation diagnostics, and pre-existing SOURCE FK
* orphans are tolerated as zero-loss — see {@link isDataContinuityOk}). If a
* genuine deficit/introduced-orphan is detected the hook **aborts the cutover**:
* it rolls the half-migrated consolidated tables back to EMPTY (`DELETE FROM`
* every user table — see {@link rollbackConsolidatedToEmpty}) so the legacy DBs
* remain the source of truth, no half-migrated `cleo.db` is exposed, and there
* are no silent `INSERT OR IGNORE` row drops. The file is never unlinked; the
* chokepoint re-opens a fresh handle afterwards (the migrate engine closes the
* handles it opened, so the rollback re-opens the scope before truncating it).
*
* ## Retry correctness — journal invalidation on abort (T11572)
*
* `runExodusMigrate` is resumable: it journals each table `done` and SKIPS
* already-`done` tables on a re-run. On abort we truncate the consolidated rows
* back to empty, so a stale `done` journal would make the next open re-trigger
* (target empty), copy NOTHING (journal says done), re-verify the still-empty
* target, and re-abort — a permanent loop. The abort path therefore also calls
* `clearExodusJournal(plan.stagingDir)` so a post-abort retry RE-COPIES from
* scratch.
*
* ## Concurrency safety (AC6 · reconcile with T11554 / R13-T11278)
*
* Two processes opening a brand-new empty `cleo.db` simultaneously must not both
* migrate. A `proper-lockfile` single-flight lock on
* `<cleo.db>.exodus-on-open.lock` serialises the attempt; the loser re-checks
* emptiness under the lock and bails. This is the same first-run-race concern
* T11554 raises for schema bootstrap — both are solved by serialising the
* first-open mutation.
*
* @module
* @task T11553 (E6 · exodus-on-open)
* @epic T11249 (E6)
* @saga T11242 (SG-DB-SUBSTRATE-V2)
* @see packages/core/src/store/exodus/migrate.ts — runExodusMigrate engine
* @see packages/core/src/store/exodus/verify-migration.ts — verifyMigration parity gate (T11551)
* @see packages/core/src/store/dual-scope-db.ts — the open chokepoint that calls this
*/
var log = getLogger("exodus-on-open");
/**
* Re-entrancy guard. `runExodusMigrate` itself calls `openDualScopeDb` for both
* scopes to create + populate the consolidated schema. Those nested opens MUST
* NOT recursively trigger another exodus-on-open. This flag is set for the
* duration of an auto-migration so the chokepoint skips the hook while a
* migration is already in flight.
*/
var _exodusInProgress = false;
/**
* Opt-out env flag. Set `CLEO_DISABLE_EXODUS_ON_OPEN=1` to skip the lazy
* auto-migration entirely (e.g. for tooling that intentionally inspects an
* empty consolidated DB). The manual `cleo exodus migrate` path is unaffected.
*/
function isDisabledByEnv() {
	const v = process.env.CLEO_DISABLE_EXODUS_ON_OPEN;
	return v === "1" || v === "true";
}
/**
* The canonical "first base table" for each scope — the same existence anchor
* used by the migration journal reconciliation. If this table has zero rows the
* consolidated DB is considered empty for the purposes of the trigger.
*
* - project → `tasks_tasks`
* - global  → `nexus_project_registry`
*/
function baseTableForScope(scope) {
	return scope === "project" ? "tasks_tasks" : "nexus_project_registry";
}
/**
* Count rows in `table` on `nativeDb`, returning `0` if the table does not yet
* exist or the query fails. Used to decide whether the consolidated DB is empty.
*/
function safeRowCount(nativeDb, table) {
	try {
		return nativeDb.prepare(`SELECT COUNT(*) AS n FROM "${table}"`).get()?.n ?? 0;
	} catch {
		return 0;
	}
}
/**
* Return `true` if the consolidated `cleo.db` for `scope` is empty — i.e. the
* canonical base table for that scope has zero rows.
*/
function consolidatedIsEmpty(nativeDb, scope) {
	return safeRowCount(nativeDb, baseTableForScope(scope)) === 0;
}
/**
* Roll a half-migrated consolidated `cleo.db` back to EMPTY on the given native
* handle — without deleting the file.
*
* Called on a parity-failure abort. As of T11782 (FIX D) the handle passed here
* is a DEDICATED, NON-cached connection opened by {@link rollbackBothScopes}, NOT
* the cached caller handle. This is critical: a scope-wide `DELETE FROM` on the
* CALLER's connection would roll back any concurrent task INSERT (`tasks.add`)
* issued on that same connection during the migrate window. Truncating on an
* isolated connection limits the blast radius to the migration's own writes —
* the caller's concurrent INSERT on its own connection survives. We `DELETE FROM`
* every user table inside a single transaction with foreign keys OFF, restoring
* the post-migration *empty* schema, so the legacy DBs remain the source of
* truth — exactly the AC2 "no half-migrated cleo.db" contract. The file is never
* unlinked; the caller's (separate) cached handle stays valid and sees the
* committed empty state on its next read (WAL).
*
* The schema (tables, indexes, drizzle journal) is preserved; only data rows are
* removed. Idempotent and best-effort: a failure to clear one table is logged
* but does not throw (the next open's emptiness check still sees a populated
* base table and could re-attempt, which is acceptable — it will re-abort).
*
* @param nativeDb - The dedicated consolidated connection to truncate.
* @param scope    - Scope label for logging.
*/
function rollbackConsolidatedToEmpty(nativeDb, scope) {
	let userTables = [];
	try {
		userTables = nativeDb.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle_%'").all().map((r) => r.name);
	} catch (err) {
		log.error({
			err,
			scope
		}, "exodus-on-open: failed to enumerate tables for rollback");
		return;
	}
	try {
		nativeDb.exec("PRAGMA foreign_keys = OFF");
		nativeDb.exec("BEGIN");
		for (const table of userTables) try {
			nativeDb.exec(`DELETE FROM "${table}"`);
		} catch (err) {
			log.warn({
				err,
				table,
				scope
			}, "exodus-on-open: failed to clear table during rollback");
		}
		nativeDb.exec("COMMIT");
	} catch (err) {
		try {
			nativeDb.exec("ROLLBACK");
		} catch {}
		log.error({
			err,
			scope
		}, "exodus-on-open: rollback transaction failed");
	} finally {
		try {
			nativeDb.exec("PRAGMA foreign_keys = ON");
		} catch {}
	}
}
/**
* Roll BOTH consolidated scopes back to empty after a failed auto-migration.
*
* ## Connection isolation (T11782 · FIX D)
*
* The rollback opens each scope on a DEDICATED, NON-cached connection (a second
* SQLite handle to the same file — WAL allows it) and truncates THAT, NEVER the
* cached caller handle. This is the load-bearing half of the write-reliability
* fix: a scope-wide `DELETE FROM`/`BEGIN…COMMIT` on the CALLER's shared
* connection would sweep away any concurrent task INSERT (`tasks.add`) issued on
* that same connection during the migrate window. Truncating on an isolated
* connection means the abort can only ever clear the migration's own writes; a
* caller's concurrent INSERT on its own connection is physically outside this
* rollback's transaction and survives.
*
* `runExodusMigrate` already closed its dedicated migrate connections by the
* time we get here, so the rows it wrote are still on disk; we re-open a fresh
* dedicated connection per scope and truncate. `_exodusInProgress` is still
* `true`, so these opens do not recurse into the hook (dedicated opens never arm
* exodus-on-open anyway).
*
* @param scope         - The scope being opened (logging context only; both
*   scopes are cleared because `runExodusMigrate` populates both).
* @param projectDbPath - Absolute path to the consolidated project `cleo.db`.
* @param globalDbPath  - Absolute path to the consolidated global `cleo.db`.
*/
async function rollbackBothScopes(scope, projectDbPath, globalDbPath) {
	const { openDualScopeDbAtPath } = await import('./dual-scope-db2-DQeSCJbV.js');
	for (const s of ["project", "global"]) {
		const path = s === "project" ? projectDbPath : globalDbPath;
		let handle = null;
		try {
			handle = s === "project" ? await openDualScopeDbAtPath("project", path, void 0, { dedicated: true }) : await openDualScopeDbAtPath("global", path, void 0, { dedicated: true });
			const native = handle.db.$client;
			if (native) rollbackConsolidatedToEmpty(native, s);
		} catch (err) {
			log.warn({
				err,
				scope: s,
				openingScope: scope
			}, "exodus-on-open: could not roll back scope (best-effort)");
		} finally {
			try {
				handle?.close();
			} catch {}
		}
	}
}
/**
* Decide whether a {@link verifyMigration} result clears the **data-continuity**
* gate — the campaign-aligned zero-loss invariant (DHQ-045 · T11551).
*
* `verifyMigration().ok` is intentionally STRICTER than data-continuity: it also
* requires `hashMatch` and zero enum-drift findings. After a CORRECT migration
* those two will legitimately be `false`, because the migration NORMALISES the
* data (legacy enum aliases like `'ACCEPTED'`→`'accepted'`, epoch→ISO
* timestamps) — so the consolidated content digest differs from the un-normalised
* source, and the source still reports its raw drift as a diagnostic. Treating
* that as a parity failure would abort EVERY real migration.
*
* The zero-loss invariant the exodus campaign actually proves (and the one the
* representative real-data parity test asserts) is:
*
*   1. every data-bearing base table copied with NO ROW DEFICIT
*      (`targetCount >= sourceCount` — you cannot LOSE a row you have MORE of),
*      AND
*   2. NO referential orphans the migration INTRODUCED on the consolidated
*      target (`introducedForeignKeyViolations` empty).
*
* ## A row SURPLUS is NOT data loss (T11577)
*
* Data loss means rows are MISSING: `targetCount < sourceCount` (a DEFICIT).
* A SURPLUS (`targetCount > sourceCount`) cannot be loss — every source row is
* still present, plus extra. The canonical benign surplus is the migration's
* OWN audit trail: `runExodusMigrate` opens the nexus registry, whose
* `writeNexusAudit` (`nexus/registry.ts`) appends rows to `nexus_audit_log`
* DURING the migrating open, so the consolidated `nexus_audit_log` legitimately
* has a few MORE rows than the legacy source (e.g. 161923 → 161926). Gating on
* exact `countMatch` (`source === target`) wrongly aborts the cutover on that
* append. The gate therefore fails ONLY on a genuine DEFICIT; a surplus is
* tolerated and logged as a WARN (with the table + delta) so it stays visible
* and a double-copy on a non-append table could still be spotted by an operator.
* Deficits are NEVER tolerated — that is the real data-loss class.
*
* ## Pre-existing source orphans are tolerated (T11572)
*
* A legacy source DB can already contain referential orphans (e.g. a
* `tasks_task_relations` row pointing at a task that was deleted long before the
* migration). Those rows copy through faithfully — that is ZERO loss, not a
* migration defect — so they appear on BOTH sides and `verifyMigration`
* classifies them as `preExistingForeignKeyViolations`. Gating on the *total*
* orphan set (`foreignKeyViolations`) would permanently abort every real cutover
* over a defect the data already had. The gate therefore fails ONLY on
* `introducedForeignKeyViolations` — orphans present on the target that the
* source did not have (i.e. the migration dropped a parent row). Pre-existing
* orphans are logged as a WARN for a data-hygiene follow-up.
*
* Hash mismatch + source enum-drift are surfaced as WARN diagnostics (a true
* content corruption would normally also show up as an introduced FK orphan or a
* count deficit), but they do NOT, on their own, indicate data loss.
*
* @param result - The {@link VerifyMigrationResult} from `verifyMigration`.
* @returns `true` when NO base table has a row DEFICIT (`targetCount <
*   sourceCount`) and the migration introduced no new FK orphans — i.e. the
*   cutover is safe. A surplus (`targetCount > sourceCount`) is tolerated.
*
* @task T11577 (deficit-only gate — tolerate benign migration-time surplus)
*/
function isDataContinuityOk(result) {
	const deficits = result.tables.filter((t) => t.targetCount < t.sourceCount);
	const surpluses = result.tables.filter((t) => t.targetCount > t.sourceCount);
	if (surpluses.length > 0) log.warn({ surpluses: surpluses.map((t) => ({
		table: t.targetTable,
		scope: t.scope,
		source: t.sourceCount,
		target: t.targetCount,
		delta: t.targetCount - t.sourceCount
	})) }, `exodus-on-open: ${surpluses.length} table(s) have MORE rows in target than source (row surplus — NOT data loss, tolerated; e.g. migration-time nexus_audit_log writes). Verify none is an unexpected double-copy on a non-append table.`);
	return deficits.length === 0 && result.introducedForeignKeyViolations.length === 0;
}
/**
* Lazily migrate the legacy fleet into the consolidated `cleo.db` on first open.
*
* This is the thin guard wired into {@link openDualScopeDb}. It is invoked AFTER
* the consolidated schema migrations have run (so the base tables exist) but
* BEFORE the handle is returned to the caller. It is a no-op unless the trigger
* condition (AC1) holds.
*
* Re-entrancy, concurrency, parity gating, and clean abort are all handled here
* — see the module docs. The heavy lifting (copy, journal, backup, attach-leak
* safety) is delegated to the existing {@link runExodusMigrate} engine; this
* function adds only the *when* (lazy trigger) and the *safety envelope*
* (single-flight + verify-or-rollback).
*
* On a parity-failure abort the migration's writes are rolled back IN PLACE on
* the caller's live handle (see {@link rollbackConsolidatedToEmpty}) — the
* handle is never closed and the file is never deleted, so the chokepoint caller
* (`getBrainDb`/`ensureConduitDb`/…) keeps a valid, empty `cleo.db` and the
* legacy DBs remain the source of truth.
*
* @param scope       - The scope being opened (`'project'` | `'global'`).
* @param dbPath      - Absolute path to the consolidated `cleo.db` for `scope`.
* @param nativeDb    - The freshly-opened native handle (post-migration). This is
*                      the SAME cached handle `runExodusMigrate` writes through,
*                      so the rollback truncates it in place rather than deleting.
* @param cwd         - Working directory used to resolve the project root.
* @returns The {@link ExodusOnOpenResult} describing what happened.
*
* @task T11553 (AC1, AC2, AC6)
* @epic T11249 (E6)
* @saga T11242
*/
async function maybeRunExodusOnOpen(scope, dbPath, nativeDb, cwd) {
	if (_exodusInProgress) return {
		outcome: "skipped",
		reason: "re-entrant open during active migration"
	};
	if (isDisabledByEnv()) return {
		outcome: "skipped",
		reason: "CLEO_DISABLE_EXODUS_ON_OPEN set"
	};
	if (hasExodusCompleteMarker(scope, cwd)) return {
		outcome: "skipped",
		reason: "exodus completion marker present — scope already migrated (cutover sealed)"
	};
	if (!consolidatedIsEmpty(nativeDb, scope)) return {
		outcome: "skipped",
		reason: "consolidated cleo.db already populated"
	};
	const { buildExodusPlan, runExodusMigrate, verifyMigration, clearExodusJournal } = await import('./exodus-CKgKImKC.js');
	const plan = buildExodusPlan(cwd);
	if (!plan.sources.filter((s) => s.targetScope === scope).some((s) => existsSync(s.path))) return {
		outcome: "skipped",
		reason: `no legacy ${scope}-scope source DBs present (fresh install or cross-scope-only)`
	};
	return withLock(`${dbPath}.exodus-on-open.lock`, async () => {
		if (!consolidatedIsEmpty(nativeDb, scope)) return {
			outcome: "skipped",
			reason: "migrated by a concurrent process (lock winner)"
		};
		log.info({
			scope,
			dbPath,
			sources: plan.sources.filter((s) => existsSync(s.path)).map((s) => s.name)
		}, "exodus-on-open: consolidated cleo.db is empty and legacy data present — auto-migrating");
		_exodusInProgress = true;
		try {
			const migrateResult = await runExodusMigrate(plan, false, (msg) => log.debug({ scope }, `exodus-on-open: ${msg}`));
			if (!migrateResult.ok) {
				await rollbackBothScopes(scope, plan.projectDbPath, plan.globalDbPath);
				clearExodusJournal(migrateResult.stagingDir);
				const reason = `migration failed: ${migrateResult.error ?? "unknown error"} — legacy DBs kept as source`;
				log.error({
					scope,
					error: migrateResult.error
				}, `exodus-on-open: ${reason}`);
				return {
					outcome: "aborted",
					reason
				};
			}
			const verifyResult = verifyMigration(plan.sources, plan.projectDbPath, plan.globalDbPath, (msg) => log.debug({ scope }, `exodus-on-open verify: ${msg}`));
			if (!verifyResult.ok) log.warn({
				scope,
				enumDrift: verifyResult.enumDrift.length,
				hashMismatches: verifyResult.tables.filter((t) => !t.hashMatch).length
			}, "exodus-on-open: verifyMigration reported non-fatal drift (normalisation expected); checking data-continuity gate");
			if (!isDataContinuityOk(verifyResult)) {
				await rollbackBothScopes(scope, plan.projectDbPath, plan.globalDbPath);
				clearExodusJournal(plan.stagingDir);
				const deficits = verifyResult.tables.filter((t) => t.targetCount < t.sourceCount).map((t) => `${t.targetTable}(${t.sourceCount}→${t.targetCount})`);
				const reason = `parity verification failed — cutover aborted, legacy DBs kept as source. count deficits: [${deficits.join(", ")}]; INTRODUCED fk orphans: ${verifyResult.introducedForeignKeyViolations.length} (pre-existing source orphans tolerated: ${verifyResult.preExistingForeignKeyViolations.length}). ` + `${verifyResult.error ?? ""}`.trim();
				log.error({
					scope,
					countDeficits: deficits,
					introducedFkViolations: verifyResult.introducedForeignKeyViolations.length,
					preExistingFkViolations: verifyResult.preExistingForeignKeyViolations.length
				}, "exodus-on-open: data-continuity FAILED — consolidated cleo.db rolled back to empty, legacy kept");
				return {
					outcome: "aborted",
					reason
				};
			}
			const rowsCopied = migrateResult.tables.filter((t) => !t.skipped).reduce((n, t) => n + t.rowsCopied, 0);
			log.info({
				scope,
				rowsCopied,
				tables: migrateResult.tables.length
			}, "exodus-on-open: parity verified — legacy data migrated into consolidated cleo.db");
			try {
				const archiveResult = archiveMigratedSources(plan.sources.filter((s) => existsSync(s.path)), cwd);
				log.info({
					scope,
					archived: archiveResult.sources.filter((s) => s.action === "archived").length,
					markersWritten: archiveResult.markersWritten
				}, "exodus-on-open: archived legacy sources + sealed completion marker(s)");
			} catch (err) {
				log.error({
					err,
					scope
				}, "exodus-on-open: post-migration archive/marker step failed (migration itself succeeded — legacy DBs left in place, will be re-checked by `cleo doctor exodus-residue`)");
			}
			return {
				outcome: "migrated",
				reason: `migrated ${rowsCopied} rows across ${migrateResult.tables.length} tables; parity verified`,
				rowsCopied
			};
		} finally {
			_exodusInProgress = false;
		}
	}, {
		stale: 6e5,
		retries: 30
	});
}

var onOpen = /*#__PURE__*/Object.freeze({
	__proto__: null,
	maybeRunExodusOnOpen: maybeRunExodusOnOpen
});

export { getCleoVersion as g, onOpen as o };
//# sourceMappingURL=on-open-CU-yEpDZ.js.map
