@cleocode/core 2026.9.15 → 2026.9.17

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 (220) hide show
  1. package/dist/changesets/parser.d.ts.map +1 -1
  2. package/dist/changesets/parser.js +67 -0
  3. package/dist/changesets/parser.js.map +1 -1
  4. package/dist/crypto/credentials.d.ts +128 -26
  5. package/dist/crypto/credentials.d.ts.map +1 -1
  6. package/dist/crypto/credentials.js +183 -142
  7. package/dist/crypto/credentials.js.map +1 -1
  8. package/dist/docs/export-document.js +14705 -12949
  9. package/dist/docs/export-document.js.map +3 -3
  10. package/dist/doctor/index.d.ts +1 -1
  11. package/dist/doctor/index.d.ts.map +1 -1
  12. package/dist/doctor/index.js +1 -1
  13. package/dist/doctor/index.js.map +1 -1
  14. package/dist/doctor/nexus-schema-residency.d.ts +1 -1
  15. package/dist/doctor/nexus-schema-residency.d.ts.map +1 -1
  16. package/dist/doctor/nexus-schema-residency.js +1 -0
  17. package/dist/doctor/nexus-schema-residency.js.map +1 -1
  18. package/dist/doctor/project-identity.d.ts +124 -0
  19. package/dist/doctor/project-identity.d.ts.map +1 -0
  20. package/dist/doctor/project-identity.js +307 -0
  21. package/dist/doctor/project-identity.js.map +1 -0
  22. package/dist/doctor/superseded-store.d.ts +8 -0
  23. package/dist/doctor/superseded-store.d.ts.map +1 -1
  24. package/dist/doctor/superseded-store.js +48 -3
  25. package/dist/doctor/superseded-store.js.map +1 -1
  26. package/dist/init.d.ts +7 -0
  27. package/dist/init.d.ts.map +1 -1
  28. package/dist/init.js +17 -2
  29. package/dist/init.js.map +1 -1
  30. package/dist/internal.d.ts +5 -2
  31. package/dist/internal.d.ts.map +1 -1
  32. package/dist/internal.js +4 -2
  33. package/dist/internal.js.map +1 -1
  34. package/dist/llm/credentials-store.d.ts +10 -0
  35. package/dist/llm/credentials-store.d.ts.map +1 -1
  36. package/dist/llm/credentials-store.js +7 -1
  37. package/dist/llm/credentials-store.js.map +1 -1
  38. package/dist/llm/plugin-facade.js +13368 -1550
  39. package/dist/llm/plugin-facade.js.map +3 -3
  40. package/dist/nexus/analyze-orchestrator.d.ts +20 -3
  41. package/dist/nexus/analyze-orchestrator.d.ts.map +1 -1
  42. package/dist/nexus/analyze-orchestrator.js +159 -10
  43. package/dist/nexus/analyze-orchestrator.js.map +1 -1
  44. package/dist/nexus/assessment-store.d.ts +57 -0
  45. package/dist/nexus/assessment-store.d.ts.map +1 -0
  46. package/dist/nexus/assessment-store.js +73 -0
  47. package/dist/nexus/assessment-store.js.map +1 -0
  48. package/dist/nexus/freshness.d.ts +131 -0
  49. package/dist/nexus/freshness.d.ts.map +1 -0
  50. package/dist/nexus/freshness.js +391 -0
  51. package/dist/nexus/freshness.js.map +1 -0
  52. package/dist/nexus/graph-manifest.d.ts +88 -0
  53. package/dist/nexus/graph-manifest.d.ts.map +1 -0
  54. package/dist/nexus/graph-manifest.js +114 -0
  55. package/dist/nexus/graph-manifest.js.map +1 -0
  56. package/dist/nexus/knowledge.d.ts +21 -1
  57. package/dist/nexus/knowledge.d.ts.map +1 -1
  58. package/dist/nexus/knowledge.js +76 -37
  59. package/dist/nexus/knowledge.js.map +1 -1
  60. package/dist/nexus/path-map.d.ts +55 -0
  61. package/dist/nexus/path-map.d.ts.map +1 -0
  62. package/dist/nexus/path-map.js +87 -0
  63. package/dist/nexus/path-map.js.map +1 -0
  64. package/dist/nexus/projects-clean.d.ts +21 -28
  65. package/dist/nexus/projects-clean.d.ts.map +1 -1
  66. package/dist/nexus/projects-clean.js +168 -85
  67. package/dist/nexus/projects-clean.js.map +1 -1
  68. package/dist/nexus/query.d.ts.map +1 -1
  69. package/dist/nexus/query.js +3 -2
  70. package/dist/nexus/query.js.map +1 -1
  71. package/dist/nexus/registry-hygiene.d.ts +70 -0
  72. package/dist/nexus/registry-hygiene.d.ts.map +1 -0
  73. package/dist/nexus/registry-hygiene.js +113 -0
  74. package/dist/nexus/registry-hygiene.js.map +1 -0
  75. package/dist/nexus/registry.d.ts +2 -2
  76. package/dist/nexus/registry.d.ts.map +1 -1
  77. package/dist/nexus/registry.js +48 -12
  78. package/dist/nexus/registry.js.map +1 -1
  79. package/dist/paths.d.ts +26 -0
  80. package/dist/paths.d.ts.map +1 -1
  81. package/dist/paths.js +99 -6
  82. package/dist/paths.js.map +1 -1
  83. package/dist/render/nexus/graph/index.d.ts.map +1 -1
  84. package/dist/render/nexus/graph/index.js +8 -0
  85. package/dist/render/nexus/graph/index.js.map +1 -1
  86. package/dist/resources/spawn-wrapper.d.ts +0 -11
  87. package/dist/resources/spawn-wrapper.d.ts.map +1 -1
  88. package/dist/resources/spawn-wrapper.js +62 -6
  89. package/dist/resources/spawn-wrapper.js.map +1 -1
  90. package/dist/scaffold/ensure-config.d.ts +12 -3
  91. package/dist/scaffold/ensure-config.d.ts.map +1 -1
  92. package/dist/scaffold/ensure-config.js +61 -38
  93. package/dist/scaffold/ensure-config.js.map +1 -1
  94. package/dist/scaffold/project-identity.d.ts +136 -0
  95. package/dist/scaffold/project-identity.d.ts.map +1 -0
  96. package/dist/scaffold/project-identity.js +225 -0
  97. package/dist/scaffold/project-identity.js.map +1 -0
  98. package/dist/selfimprove/fix-gen.js +13319 -1556
  99. package/dist/selfimprove/fix-gen.js.map +3 -3
  100. package/dist/store/agent-api-key.d.ts +114 -0
  101. package/dist/store/agent-api-key.d.ts.map +1 -0
  102. package/dist/store/agent-api-key.js +136 -0
  103. package/dist/store/agent-api-key.js.map +1 -0
  104. package/dist/store/agent-registry-accessor.d.ts +4 -2
  105. package/dist/store/agent-registry-accessor.d.ts.map +1 -1
  106. package/dist/store/agent-registry-accessor.js +39 -89
  107. package/dist/store/agent-registry-accessor.js.map +1 -1
  108. package/dist/store/api-key-kdf.d.ts +7 -0
  109. package/dist/store/api-key-kdf.d.ts.map +1 -1
  110. package/dist/store/api-key-kdf.js +7 -0
  111. package/dist/store/api-key-kdf.js.map +1 -1
  112. package/dist/store/backup-crypto.d.ts +49 -0
  113. package/dist/store/backup-crypto.d.ts.map +1 -1
  114. package/dist/store/backup-crypto.js +154 -0
  115. package/dist/store/backup-crypto.js.map +1 -1
  116. package/dist/store/credential-transfer.d.ts +303 -0
  117. package/dist/store/credential-transfer.d.ts.map +1 -0
  118. package/dist/store/credential-transfer.js +774 -0
  119. package/dist/store/credential-transfer.js.map +1 -0
  120. package/dist/store/exodus/column-transforms.d.ts +79 -4
  121. package/dist/store/exodus/column-transforms.d.ts.map +1 -1
  122. package/dist/store/exodus/column-transforms.js +121 -4
  123. package/dist/store/exodus/column-transforms.js.map +1 -1
  124. package/dist/store/exodus/index.d.ts +1 -0
  125. package/dist/store/exodus/index.d.ts.map +1 -1
  126. package/dist/store/exodus/index.js +1 -0
  127. package/dist/store/exodus/index.js.map +1 -1
  128. package/dist/store/exodus/migrate.d.ts +17 -1
  129. package/dist/store/exodus/migrate.d.ts.map +1 -1
  130. package/dist/store/exodus/migrate.js +155 -33
  131. package/dist/store/exodus/migrate.js.map +1 -1
  132. package/dist/store/exodus/on-open.d.ts +14 -0
  133. package/dist/store/exodus/on-open.d.ts.map +1 -1
  134. package/dist/store/exodus/on-open.js +115 -6
  135. package/dist/store/exodus/on-open.js.map +1 -1
  136. package/dist/store/exodus/reconcile.d.ts +103 -0
  137. package/dist/store/exodus/reconcile.d.ts.map +1 -0
  138. package/dist/store/exodus/reconcile.js +728 -0
  139. package/dist/store/exodus/reconcile.js.map +1 -0
  140. package/dist/store/exodus/recovery.d.ts.map +1 -1
  141. package/dist/store/exodus/recovery.js +14 -2
  142. package/dist/store/exodus/recovery.js.map +1 -1
  143. package/dist/store/exodus/runtime-targets.d.ts +39 -0
  144. package/dist/store/exodus/runtime-targets.d.ts.map +1 -0
  145. package/dist/store/exodus/runtime-targets.js +63 -0
  146. package/dist/store/exodus/runtime-targets.js.map +1 -0
  147. package/dist/store/exodus/table-name-map.d.ts.map +1 -1
  148. package/dist/store/exodus/table-name-map.js +5 -0
  149. package/dist/store/exodus/table-name-map.js.map +1 -1
  150. package/dist/store/exodus/table-order.d.ts +35 -0
  151. package/dist/store/exodus/table-order.d.ts.map +1 -0
  152. package/dist/store/exodus/table-order.js +62 -0
  153. package/dist/store/exodus/table-order.js.map +1 -0
  154. package/dist/store/exodus/verify-migration.d.ts +4 -1
  155. package/dist/store/exodus/verify-migration.d.ts.map +1 -1
  156. package/dist/store/exodus/verify-migration.js +142 -10
  157. package/dist/store/exodus/verify-migration.js.map +1 -1
  158. package/dist/store/global-salt.d.ts +16 -0
  159. package/dist/store/global-salt.d.ts.map +1 -1
  160. package/dist/store/global-salt.js +22 -7
  161. package/dist/store/global-salt.js.map +1 -1
  162. package/dist/store/legacy-tasks-lineage.d.ts +86 -0
  163. package/dist/store/legacy-tasks-lineage.d.ts.map +1 -0
  164. package/dist/store/legacy-tasks-lineage.js +285 -0
  165. package/dist/store/legacy-tasks-lineage.js.map +1 -0
  166. package/dist/store/migration-manager.d.ts +27 -0
  167. package/dist/store/migration-manager.d.ts.map +1 -1
  168. package/dist/store/migration-manager.js +63 -0
  169. package/dist/store/migration-manager.js.map +1 -1
  170. package/dist/store/portable-bundle-import.d.ts +117 -0
  171. package/dist/store/portable-bundle-import.d.ts.map +1 -0
  172. package/dist/store/portable-bundle-import.js +589 -0
  173. package/dist/store/portable-bundle-import.js.map +1 -0
  174. package/dist/store/portable-bundle-relocate.d.ts +66 -0
  175. package/dist/store/portable-bundle-relocate.d.ts.map +1 -0
  176. package/dist/store/portable-bundle-relocate.js +304 -0
  177. package/dist/store/portable-bundle-relocate.js.map +1 -0
  178. package/dist/store/portable-bundle-scan.d.ts +167 -0
  179. package/dist/store/portable-bundle-scan.d.ts.map +1 -0
  180. package/dist/store/portable-bundle-scan.js +569 -0
  181. package/dist/store/portable-bundle-scan.js.map +1 -0
  182. package/dist/store/portable-bundle.d.ts +146 -0
  183. package/dist/store/portable-bundle.d.ts.map +1 -0
  184. package/dist/store/portable-bundle.js +674 -0
  185. package/dist/store/portable-bundle.js.map +1 -0
  186. package/dist/store/regenerators.d.ts +3 -1
  187. package/dist/store/regenerators.d.ts.map +1 -1
  188. package/dist/store/regenerators.js +6 -2
  189. package/dist/store/regenerators.js.map +1 -1
  190. package/dist/store/schema/cleo-global/nexus.d.ts +98 -0
  191. package/dist/store/schema/cleo-global/nexus.d.ts.map +1 -1
  192. package/dist/store/schema/cleo-global/nexus.js +19 -0
  193. package/dist/store/schema/cleo-global/nexus.js.map +1 -1
  194. package/dist/store/schema/nexus-schema.d.ts +102 -0
  195. package/dist/store/schema/nexus-schema.d.ts.map +1 -1
  196. package/dist/store/schema/nexus-schema.js +24 -0
  197. package/dist/store/schema/nexus-schema.js.map +1 -1
  198. package/dist/store/split-brain-import.d.ts +176 -0
  199. package/dist/store/split-brain-import.d.ts.map +1 -0
  200. package/dist/store/split-brain-import.js +680 -0
  201. package/dist/store/split-brain-import.js.map +1 -0
  202. package/dist/store/sqlite.d.ts +29 -0
  203. package/dist/store/sqlite.d.ts.map +1 -1
  204. package/dist/store/sqlite.js +57 -5
  205. package/dist/store/sqlite.js.map +1 -1
  206. package/dist/system/health.d.ts.map +1 -1
  207. package/dist/system/health.js +30 -5
  208. package/dist/system/health.js.map +1 -1
  209. package/dist/upgrade.d.ts.map +1 -1
  210. package/dist/upgrade.js +60 -0
  211. package/dist/upgrade.js.map +1 -1
  212. package/dist/validation/doctor/checks.d.ts +14 -0
  213. package/dist/validation/doctor/checks.d.ts.map +1 -1
  214. package/dist/validation/doctor/checks.js +33 -0
  215. package/dist/validation/doctor/checks.js.map +1 -1
  216. package/migrations/drizzle-cleo-global/20260924000000_t12354-project-paths/migration.sql +40 -0
  217. package/migrations/drizzle-nexus/20260924000000_t12315-nexus-parse-cache/migration.sql +21 -0
  218. package/package.json +13 -13
  219. package/templates/CLEO-INJECTION.md +3 -3
  220. package/templates/cleo-gitignore +7 -0
@@ -0,0 +1,728 @@
1
+ /**
2
+ * Explicit, verified reconcile of stranded legacy project stores into `cleo.db`.
3
+ *
4
+ * ## Why this exists (T12319)
5
+ *
6
+ * Measured 2026-09-24: ~21 projects ran on an EMPTY consolidated `cleo.db`
7
+ * while their legacy `.cleo/tasks.db` / `.cleo/brain.db` still held everything
8
+ * (llmtxt: 849 tasks + 1,696 observations; claude-todo: 5,330 + 5,148). Two
9
+ * things kept exodus-on-open from ever copying them: a machine-wide
10
+ * `CLEO_DISABLE_EXODUS_ON_OPEN=1` incident kill-switch (skip, silently), and —
11
+ * with the switch off — a copy engine that aborted on real legacy data (FK
12
+ * order, pre-T1408 archive reasons, pre-T877 stages, grandfathered hierarchy
13
+ * rows, NULL `valid_at`). `cleo doctor superseded-store` saw the rows but
14
+ * could only say "reconcile the contents first", with no command to do it.
15
+ *
16
+ * This module is that command's engine. It does NOT write a second copier: the
17
+ * copy is {@link runExodusMigrate} (same transforms, receipts and recovery),
18
+ * restricted to the project target. What it adds is the envelope:
19
+ *
20
+ * - **Assessment by key, not count.** For every legacy table the number of
21
+ * source rows whose primary key is ABSENT from the live table. A count can
22
+ * look complete while unrelated rows written into the empty shell mask real
23
+ * gaps; a key cannot.
24
+ * - **Additive only.** `INSERT OR IGNORE` never overwrites or duplicates a row
25
+ * already in `cleo.db`; the legacy files are read-only inputs and are never
26
+ * moved, archived or deleted.
27
+ * - **Verified or reverted.** After the copy the assessment is re-run; any
28
+ * missing key (or a live table that shrank) reverts exactly the rows this run
29
+ * inserted, via their receipts, and the outcome is `refused`.
30
+ * - **Idempotent.** A fully reconciled project is a no-op: nothing is written.
31
+ *
32
+ * @module
33
+ * @task T12319
34
+ * @see ./migrate.ts — the single copy engine
35
+ * @see ../../doctor/superseded-store.ts — the survey that points here
36
+ */
37
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
38
+ import { tmpdir } from 'node:os';
39
+ import { basename, join } from 'node:path';
40
+ import { getLogger } from '../../logger.js';
41
+ import { resolveCleoDir } from '../../paths.js';
42
+ import { resolveDualScopeDbPath } from '../dual-scope-db.js';
43
+ import { withLock } from '../lock.js';
44
+ import { openCleoDbSnapshot } from '../open-cleo-db.js';
45
+ import { legacyRowProjection } from './column-transforms.js';
46
+ import { runExodusMigrate } from './migrate.js';
47
+ import { buildExodusPlan } from './plan.js';
48
+ import { rollbackExodusReceipts } from './recovery.js';
49
+ import { buildRuntimeTargetResolver } from './runtime-targets.js';
50
+ import { resolveConsolidatedTableName, resolveTableTargetScope } from './table-name-map.js';
51
+ import { orderTablesForCopy } from './table-order.js';
52
+ const log = getLogger('exodus-reconcile');
53
+ /** Prefix of a reconcile staging dir — deliberately NOT `exodus-staging-`, which on-open resumes. */
54
+ const RECONCILE_DIR_PREFIX = 'exodus-reconcile-';
55
+ /** Receipt filename written inside the reconcile staging dir. */
56
+ const RECEIPT_FILENAME = 'reconcile-receipt.json';
57
+ /** Quote an SQLite identifier. */
58
+ function ident(name) {
59
+ return `"${name.replace(/"/g, '""')}"`;
60
+ }
61
+ /** Whether `schema.table` exists on `db`. */
62
+ function hasTable(db, schema, table) {
63
+ return (db
64
+ .prepare(`SELECT 1 AS ok FROM ${ident(schema)}.sqlite_master WHERE type='table' AND name=?`)
65
+ .get(table) !== undefined);
66
+ }
67
+ /** Row count of `schema.table`. */
68
+ function countRows(db, schema, table) {
69
+ const row = db.prepare(`SELECT COUNT(*) AS n FROM ${ident(schema)}.${ident(table)}`).get();
70
+ return Number(row?.n ?? 0);
71
+ }
72
+ /**
73
+ * Count rows of the attached legacy table whose primary key is absent from the
74
+ * live table, or `null` when the tables share no complete primary key.
75
+ */
76
+ function countMissing(live, alias, sourceTable, targetTable, transformTable) {
77
+ const targetPk = live.prepare(`PRAGMA main.table_info(${ident(targetTable)})`).all()
78
+ .filter((c) => c.pk > 0)
79
+ .sort((a, b) => a.pk - b.pk)
80
+ .map((c) => c.name);
81
+ const sourceCols = new Set(live.prepare(`PRAGMA ${ident(alias)}.table_info(${ident(sourceTable)})`).all().map((c) => c.name));
82
+ // A projected key (e.g. release_manifests.id → 'legacy:' || version, T12346)
83
+ // is compared in its PROJECTED form — the form migrate writes.
84
+ const projection = legacyRowProjection(transformTable, sourceTable);
85
+ const sourceKey = (c) => {
86
+ const project = projection.get(c);
87
+ if (project)
88
+ return project((name) => `s.${ident(name)}`);
89
+ return sourceCols.has(c) ? `s.${ident(c)}` : null;
90
+ };
91
+ if (targetPk.length === 0 || !targetPk.every((c) => sourceKey(c) !== null))
92
+ return null;
93
+ const match = targetPk.map((c) => `t.${ident(c)} = ${sourceKey(c)}`).join(' AND ');
94
+ const row = live
95
+ .prepare(`SELECT COUNT(*) AS n FROM ${ident(alias)}.${ident(sourceTable)} s ` +
96
+ `WHERE NOT EXISTS (SELECT 1 FROM main.${ident(targetTable)} t WHERE ${match})`)
97
+ .get();
98
+ return Number(row?.n ?? 0);
99
+ }
100
+ /**
101
+ * Account for every data-bearing legacy project table against the live store.
102
+ *
103
+ * Read-only on both sides: the live store is opened `readOnly` and each legacy
104
+ * file is ATTACHed to that connection, which SQLite opens read-only too.
105
+ *
106
+ * @param liveStorePath - Absolute path of the live project `cleo.db`.
107
+ * @param sources - Existing project-scope legacy sources.
108
+ * @param resolveTarget - Where each legacy table lands; a reconcile passes the
109
+ * runtime-read targets ({@link buildRuntimeTargetResolver}).
110
+ * @returns One entry per legacy table holding rows, in copy order.
111
+ * @task T12319
112
+ */
113
+ export function assessSupersededProjectStores(liveStorePath, sources, resolveTarget = resolveConsolidatedTableName) {
114
+ const counts = [];
115
+ const live = openCleoDbSnapshot(liveStorePath, { readOnly: true });
116
+ try {
117
+ sources.forEach((src, index) => {
118
+ const alias = `_reconcile_src_${index}`;
119
+ live.db.exec(`ATTACH DATABASE '${src.path.replace(/'/g, "''")}' AS ${ident(alias)}`);
120
+ const snap = openCleoDbSnapshot(src.path, { readOnly: true });
121
+ try {
122
+ for (const sourceTable of orderTablesForCopy(snap.db)) {
123
+ const resolution = resolveTarget(src.name, sourceTable);
124
+ const consolidated = resolveConsolidatedTableName(src.name, sourceTable);
125
+ const transformTable = consolidated.kind === 'skip' ? sourceTable : consolidated.targetName;
126
+ if (resolution.kind === 'skip')
127
+ continue;
128
+ if (resolveTableTargetScope(src.name, sourceTable, 'project') !== 'project')
129
+ continue;
130
+ const sourceRows = countRows(live.db, alias, sourceTable);
131
+ if (sourceRows === 0)
132
+ continue;
133
+ const targetTable = resolution.targetName;
134
+ const present = hasTable(live.db, 'main', targetTable);
135
+ counts.push({
136
+ sourceDb: src.name,
137
+ sourceTable,
138
+ targetTable,
139
+ sourceRows,
140
+ liveRows: present ? countRows(live.db, 'main', targetTable) : 0,
141
+ missingInLive: present
142
+ ? countMissing(live.db, alias, sourceTable, targetTable, transformTable)
143
+ : sourceRows,
144
+ });
145
+ }
146
+ }
147
+ finally {
148
+ snap.close();
149
+ live.db.exec(`DETACH DATABASE ${ident(alias)}`);
150
+ }
151
+ });
152
+ }
153
+ finally {
154
+ live.close();
155
+ }
156
+ return counts;
157
+ }
158
+ /**
159
+ * Whether any of `sources` holds at least one row in a table exodus would copy.
160
+ *
161
+ * Cheap by construction — stops at the first non-empty table — because it runs
162
+ * on the open path whenever a consolidated store is empty and its migration is
163
+ * being skipped, to decide whether that skip is stranding real data.
164
+ *
165
+ * @param sources - Legacy source descriptors of ONE scope.
166
+ * @returns `true` when a legacy file still holds copyable rows.
167
+ * @task T12319
168
+ */
169
+ export function legacySourcesHoldRows(sources) {
170
+ for (const src of sources) {
171
+ if (!existsSync(src.path))
172
+ continue;
173
+ const snap = openCleoDbSnapshot(src.path, { readOnly: true });
174
+ try {
175
+ for (const table of orderTablesForCopy(snap.db)) {
176
+ if (resolveConsolidatedTableName(src.name, table).kind === 'skip')
177
+ continue;
178
+ if (snap.db.prepare(`SELECT 1 AS ok FROM ${ident(table)} LIMIT 1`).get() !== undefined)
179
+ return true;
180
+ }
181
+ }
182
+ finally {
183
+ snap.close();
184
+ }
185
+ }
186
+ return false;
187
+ }
188
+ /**
189
+ * Whether a bare legacy table in the live `cleo.db` is one the runtime NO LONGER
190
+ * reads — i.e. its runtime target is a different (prefixed) table. Derived from
191
+ * the runtime's own table bindings via `resolveTarget`, never a hand-kept list:
192
+ * `tasks` → `tasks_tasks` is dead-bare; `audit_log` → `audit_log` is live.
193
+ *
194
+ * @task T12346
195
+ */
196
+ function isRuntimeDeadBare(resolveTarget, table) {
197
+ const target = resolveTarget('tasks', table);
198
+ return target.kind !== 'skip' && target.targetName !== table;
199
+ }
200
+ /** Logical source name for the live store's bare task-core family. */
201
+ const BARE_SOURCE_NAME = 'tasks (cleo.db bare task-core)';
202
+ /**
203
+ * The live store's own dead bare task-core family as a reconcile source — item
204
+ * 2 of T12319, found real by the T12346 sweep: a stranded `cleo.db` can hold
205
+ * rows in bare tables the runtime no longer reads that exist in NO legacy file
206
+ * (llmtxt: 1,972 `task_labels`; versionguard: 20 `task_dependencies`).
207
+ *
208
+ * Offered ONLY while the prefixed `tasks_tasks` is still empty — i.e. the
209
+ * project never ran on the consolidated store. Once it has, a bare row missing
210
+ * from the prefixed family may be one the runtime deliberately removed, and
211
+ * re-copying it would resurrect it.
212
+ *
213
+ * Materialised as a standalone file in `outDir` holding exactly the qualifying
214
+ * rows, built table by table from the live file's own DDL (a whole-file copy
215
+ * cannot be pruned: sqlite-vec `vec0` tables cannot be dropped without their
216
+ * module). Rows a FRESH project store already holds (e.g. the lineage's 18
217
+ * backfilled `commits`) are not project data and are excluded — measured by
218
+ * building such a store, not by guessing — so a reconciled project ends up
219
+ * with exactly what a fresh one has there, the same as exodus-on-open.
220
+ * The table set is fixed when the file is built, so the post-copy verification
221
+ * judges exactly what was copied.
222
+ *
223
+ * @returns The descriptor (path = the materialised file), or `null`.
224
+ */
225
+ async function bareTaskCoreSource(liveStorePath, resolveTarget, outDir) {
226
+ const live = openCleoDbSnapshot(liveStorePath, { readOnly: true });
227
+ let tables;
228
+ try {
229
+ if (hasTable(live.db, 'main', 'tasks_tasks') && countRows(live.db, 'main', 'tasks_tasks') > 0)
230
+ return null;
231
+ tables = live.db
232
+ .prepare("SELECT name, sql FROM main.sqlite_master WHERE type='table' AND sql IS NOT NULL")
233
+ .all().filter((t) => isRuntimeDeadBare(resolveTarget, t.name) && countRows(live.db, 'main', t.name) > 0);
234
+ }
235
+ finally {
236
+ live.close();
237
+ }
238
+ if (tables.length === 0)
239
+ return null;
240
+ const seedPath = await buildFreshProjectStore(join(outDir, 'fresh-project-seed.db'));
241
+ const path = join(outDir, 'cleo-bare-task-core.db');
242
+ const snap = openCleoDbSnapshot(path, { readOnly: false });
243
+ let kept = 0;
244
+ try {
245
+ snap.db.exec('PRAGMA foreign_keys=OFF');
246
+ snap.db.exec(`ATTACH DATABASE '${liveStorePath.replace(/'/g, "''")}' AS live`);
247
+ snap.db.exec(`ATTACH DATABASE '${seedPath.replace(/'/g, "''")}' AS seed`);
248
+ for (const t of tables) {
249
+ snap.db.exec(t.sql);
250
+ snap.db.exec(`INSERT INTO main.${ident(t.name)} SELECT * FROM live.${ident(t.name)}`);
251
+ if (hasTable(snap.db, 'seed', t.name)) {
252
+ const seedCols = new Set(snap.db.prepare(`PRAGMA seed.table_info(${ident(t.name)})`).all().map((c) => c.name));
253
+ const cols = snap.db.prepare(`PRAGMA main.table_info(${ident(t.name)})`).all();
254
+ // A seed row is identified by its KEY: seeded rows carry the time they
255
+ // were seeded (e.g. `commits.created_at`), so whole-row equality never
256
+ // matches a seed written on another day. Keyless tables compare rows.
257
+ const pk = cols.filter((c) => c.pk > 0 && seedCols.has(c.name)).map((c) => c.name);
258
+ const match = (pk.length > 0 ? pk : cols.map((c) => c.name).filter((c) => seedCols.has(c)))
259
+ .map((c) => `s.${ident(c)} IS m.${ident(c)}`)
260
+ .join(' AND ');
261
+ if (match.length > 0)
262
+ snap.db.exec(`DELETE FROM main.${ident(t.name)} AS m WHERE EXISTS (SELECT 1 FROM seed.${ident(t.name)} s WHERE ${match})`);
263
+ }
264
+ const n = countRows(snap.db, 'main', t.name);
265
+ if (n === 0)
266
+ snap.db.exec(`DROP TABLE main.${ident(t.name)}`);
267
+ kept += n;
268
+ }
269
+ snap.db.exec('DETACH DATABASE live');
270
+ snap.db.exec('DETACH DATABASE seed');
271
+ }
272
+ finally {
273
+ snap.close();
274
+ }
275
+ return kept > 0 ? { name: BARE_SOURCE_NAME, path, targetScope: 'project' } : null;
276
+ }
277
+ /**
278
+ * Build a FRESH project store — consolidated schema plus the tasks-domain
279
+ * lineage, exactly as a brand-new project gets it — and return its path. Its
280
+ * rows are, by construction, only what CLEO seeds (e.g. the 18 backfilled
281
+ * `commits`), never project data.
282
+ */
283
+ async function buildFreshProjectStore(path) {
284
+ const { getDualScopeNativeDb, openDualScopeDbAtPath } = await import('../dual-scope-db.js');
285
+ const { ensureTasksDomainTables, seedTasksMeta } = await import('../sqlite.js');
286
+ const handle = await openDualScopeDbAtPath('project', path, undefined, { dedicated: true });
287
+ try {
288
+ const native = getDualScopeNativeDb(handle);
289
+ ensureTasksDomainTables(native, path);
290
+ seedTasksMeta(native);
291
+ }
292
+ finally {
293
+ handle.close();
294
+ }
295
+ return path;
296
+ }
297
+ /**
298
+ * From the bare task-core snapshot, extract the rows that are strictly NEWER
299
+ * (`updated_at`) than the same-key row in the legacy `tasks.db`, into their own
300
+ * file — copied FIRST so the fresher version of a task wins.
301
+ *
302
+ * Legacy files normally hold the newer copy, but not always: in forge-ts 58 of
303
+ * the 65 tasks that differ were last updated in `cleo.db`'s bare table (7 with
304
+ * a different status). Copy order alone decides which version `INSERT OR
305
+ * IGNORE` keeps, so the freshest rows are given their own, earlier source.
306
+ *
307
+ * @returns The file path, or `null` when no bare row is fresher.
308
+ */
309
+ function snapshotFresherBareRows(barePath, legacyTasksPath, stagingDir) {
310
+ if (legacyTasksPath === undefined)
311
+ return null;
312
+ const path = join(stagingDir, 'cleo-bare-task-core-fresher.db');
313
+ const snap = openCleoDbSnapshot(barePath, { readOnly: true });
314
+ try {
315
+ snap.db.exec(`VACUUM INTO '${path.replace(/'/g, "''")}'`);
316
+ }
317
+ finally {
318
+ snap.close();
319
+ }
320
+ const fresher = openCleoDbSnapshot(path, { readOnly: false });
321
+ let kept = 0;
322
+ try {
323
+ fresher.db.exec('PRAGMA foreign_keys=OFF');
324
+ fresher.db.exec(`ATTACH DATABASE '${legacyTasksPath.replace(/'/g, "''")}' AS legacy`);
325
+ const tables = fresher.db.prepare("SELECT name FROM main.sqlite_master WHERE type='table'").all().map((t) => t.name);
326
+ for (const table of tables) {
327
+ const cols = fresher.db.prepare(`PRAGMA main.table_info(${ident(table)})`).all();
328
+ const pk = cols.filter((c) => c.pk > 0).map((c) => c.name);
329
+ const inLegacy = hasTable(fresher.db, 'legacy', table);
330
+ const legacyCols = inLegacy
331
+ ? new Set(fresher.db.prepare(`PRAGMA legacy.table_info(${ident(table)})`).all().map((c) => c.name))
332
+ : new Set();
333
+ const comparable = inLegacy &&
334
+ pk.length > 0 &&
335
+ cols.some((c) => c.name === 'updated_at') &&
336
+ legacyCols.has('updated_at') &&
337
+ pk.every((k) => legacyCols.has(k));
338
+ if (!comparable) {
339
+ fresher.db.exec(`DELETE FROM main.${ident(table)}`);
340
+ continue;
341
+ }
342
+ const match = pk.map((k) => `l.${ident(k)} = b.${ident(k)}`).join(' AND ');
343
+ fresher.db.exec(`DELETE FROM main.${ident(table)} WHERE rowid NOT IN (SELECT b.rowid FROM main.${ident(table)} b ` +
344
+ `JOIN legacy.${ident(table)} l ON ${match} WHERE COALESCE(b.updated_at, '') > COALESCE(l.updated_at, ''))`);
345
+ kept += countRows(fresher.db, 'main', table);
346
+ }
347
+ }
348
+ finally {
349
+ fresher.close();
350
+ }
351
+ return kept > 0 ? path : null;
352
+ }
353
+ /** Whether an assessment shows every legacy row present in the live store. */
354
+ function isComplete(counts) {
355
+ return counts.every((c) => c.missingInLive === null ? c.liveRows >= c.sourceRows : c.missingInLive === 0);
356
+ }
357
+ /** Tables whose live row count went DOWN between two assessments (never expected). */
358
+ function shrunk(before, after) {
359
+ return after
360
+ .filter((a) => {
361
+ const b = before.find((x) => x.sourceDb === a.sourceDb && x.sourceTable === a.sourceTable);
362
+ return b !== undefined && a.liveRows < b.liveRows;
363
+ })
364
+ .map((a) => a.targetTable);
365
+ }
366
+ /** Human-readable list of the tables that still have rows missing. */
367
+ function describeGaps(counts) {
368
+ return counts
369
+ .filter((c) => (c.missingInLive === null ? c.liveRows < c.sourceRows : c.missingInLive > 0))
370
+ .map((c) => `${c.targetTable} (${c.missingInLive ?? c.sourceRows - c.liveRows} of ${c.sourceRows} missing)`)
371
+ .join(', ');
372
+ }
373
+ /** Revert every row this reconcile inserted, proven by its receipts. */
374
+ async function revertReconcile(liveStorePath, stagingDir) {
375
+ const { getDualScopeNativeDb, openDualScopeDbAtPath } = await import('../dual-scope-db.js');
376
+ const handle = await openDualScopeDbAtPath('project', liveStorePath, undefined, {
377
+ dedicated: true,
378
+ });
379
+ try {
380
+ return rollbackExodusReceipts(getDualScopeNativeDb(handle), stagingDir);
381
+ }
382
+ finally {
383
+ handle.close();
384
+ }
385
+ }
386
+ /**
387
+ * Whether a legacy table's runtime home belongs to the task graph the project
388
+ * edits live — derived, like every target, from the runtime bindings: a tasks
389
+ * table the runtime re-points at a prefixed twin (`tasks`, `task_dependencies`,
390
+ * `task_acceptance_criteria`, `lifecycle_*`, …). A missing legacy row there may
391
+ * have been deleted or rewritten since the cutover, so an additive run never
392
+ * writes it. History tables the runtime reads under their own name
393
+ * (`audit_log`, `token_usage`, `pipeline_manifest`, …) and brain/conduit rows
394
+ * are append-only and may be filled in.
395
+ */
396
+ function isLiveAuthoritative(sourceName, legacyTable, target) {
397
+ return sourceName.toLowerCase().startsWith('tasks') && target !== legacyTable;
398
+ }
399
+ /** Every legacy row set an assessment shows as still missing, as conflicts. */
400
+ function conflictsOf(counts) {
401
+ return counts
402
+ .map((c) => ({
403
+ c,
404
+ rows: c.missingInLive ?? Math.max(0, c.sourceRows - c.liveRows),
405
+ }))
406
+ .filter(({ rows }) => rows > 0)
407
+ .map(({ c, rows }) => ({
408
+ sourceDb: c.sourceDb,
409
+ sourceTable: c.sourceTable,
410
+ targetTable: c.targetTable,
411
+ rows,
412
+ reason: isLiveAuthoritative(c.sourceDb, c.sourceTable, c.targetTable)
413
+ ? 'live-authoritative'
414
+ : 'collides-with-live',
415
+ }));
416
+ }
417
+ /** One line naming every conflict. */
418
+ function describeConflicts(conflicts) {
419
+ if (conflicts.length === 0)
420
+ return 'no conflicts';
421
+ return `left ${conflicts.reduce((n, c) => n + c.rows, 0)} row(s) uncopied: ${conflicts
422
+ .map((c) => `${c.targetTable} ${c.rows} (${c.reason})`)
423
+ .join(', ')}`;
424
+ }
425
+ /** Full-fidelity snapshot of the live store (reads through WAL). */
426
+ function snapshotLive(liveStorePath, to) {
427
+ const live = openCleoDbSnapshot(liveStorePath, { readOnly: true });
428
+ try {
429
+ live.db.exec(`VACUUM INTO '${to.replace(/'/g, "''")}'`);
430
+ }
431
+ finally {
432
+ live.close();
433
+ }
434
+ }
435
+ /**
436
+ * Tables among the copy targets in which a row that existed before the copy is
437
+ * no longer present unchanged — compared over the columns both shapes share,
438
+ * so a column a migration ADDS during the run is not an alteration, while a
439
+ * dropped column that held data is. The tasks-domain default `task_id_sequence` is
440
+ * not data (the sequence module treats it as "no state") and is expected to
441
+ * yield to a legacy counter, so `sequenceSeed` rows are exempt.
442
+ */
443
+ function alteredLiveTables(liveStorePath, beforePath, counts, sequenceSeed) {
444
+ const live = openCleoDbSnapshot(liveStorePath, { readOnly: true });
445
+ try {
446
+ live.db.exec(`ATTACH DATABASE '${beforePath.replace(/'/g, "''")}' AS before`);
447
+ const altered = [];
448
+ for (const table of new Set(counts.map((c) => c.targetTable))) {
449
+ if (!hasTable(live.db, 'before', table) || !hasTable(live.db, 'main', table))
450
+ continue;
451
+ const seed = table === 'schema_meta'
452
+ ? ` WHERE NOT (key = 'task_id_sequence' AND value = '${sequenceSeed.replace(/'/g, "''")}')`
453
+ : '';
454
+ // The table's SHAPE may legitimately change during the run: the
455
+ // tasks-domain lineage step can rebuild a bare table with new columns
456
+ // (T12346; claude-todo). Decided deliberately:
457
+ // - a column ADDED by a migration is not an alteration of any
458
+ // pre-existing row — it did not exist before, so it is not compared;
459
+ // - a column DROPPED is an alteration if it held any value — that data is
460
+ // no longer in the row — and harmless if it was NULL throughout;
461
+ // - every column both shapes share must still hold every prior row.
462
+ const colsOf = (schema) => live.db.prepare(`PRAGMA ${ident(schema)}.table_info(${ident(table)})`).all().map((c) => c.name);
463
+ const after = new Set(colsOf('main'));
464
+ const beforeCols = colsOf('before');
465
+ const dropped = beforeCols.filter((c) => !after.has(c));
466
+ const droppedWithData = dropped.filter((c) => Number(live.db
467
+ .prepare(`SELECT COUNT(*) AS n FROM before.${ident(table)} WHERE ${ident(c)} IS NOT NULL`)
468
+ .get()?.n ?? 0) > 0);
469
+ if (droppedWithData.length > 0) {
470
+ altered.push(`${table} (dropped populated column(s): ${droppedWithData.join(', ')})`);
471
+ continue;
472
+ }
473
+ const shared = beforeCols.filter((c) => after.has(c));
474
+ if (shared.length === 0) {
475
+ altered.push(table);
476
+ continue;
477
+ }
478
+ const list = shared.map(ident).join(', ');
479
+ const row = live.db
480
+ .prepare(`SELECT COUNT(*) AS n FROM (SELECT ${list} FROM before.${ident(table)}${seed} EXCEPT SELECT ${list} FROM main.${ident(table)})`)
481
+ .get();
482
+ if (Number(row?.n ?? 0) > 0)
483
+ altered.push(table);
484
+ }
485
+ return altered;
486
+ }
487
+ finally {
488
+ live.close();
489
+ }
490
+ }
491
+ /**
492
+ * The unmigrated `cleo.db`'s own dead bare task-core family as exodus sources,
493
+ * for exodus-on-open (T12355): the same source the reconcile reads, so the two
494
+ * converge whichever runs first — without it, on-open left e.g. llmtxt's 1,972
495
+ * bare-only `task_labels` behind while a reconcile recovered them.
496
+ *
497
+ * @param liveStorePath - The project `cleo.db` being migrated.
498
+ * @param resolveTarget - The runtime target resolver.
499
+ * @param legacyTasksPath - The legacy `tasks.db`, if present (its fresher-row comparison).
500
+ * @param outDir - Scratch directory the materialised sources are written to.
501
+ * @returns Sources to copy BEFORE the legacy files (bare rows newer than their
502
+ * legacy copy) and AFTER them (the rest of the bare family); both empty when
503
+ * the bare family is not a source.
504
+ * @task T12355
505
+ */
506
+ export async function unmigratedBareSources(liveStorePath, resolveTarget, legacyTasksPath, outDir) {
507
+ const bare = await bareTaskCoreSource(liveStorePath, resolveTarget, outDir);
508
+ if (bare === null)
509
+ return { first: [], last: [] };
510
+ const fresher = snapshotFresherBareRows(bare.path, legacyTasksPath, outDir);
511
+ return {
512
+ first: fresher === null
513
+ ? []
514
+ : [{ name: `${BARE_SOURCE_NAME} (fresher)`, path: fresher, targetScope: 'project' }],
515
+ last: [bare],
516
+ };
517
+ }
518
+ /**
519
+ * Reconcile a project's stranded legacy stores into its live `cleo.db`.
520
+ *
521
+ * @param projectRoot - Absolute project root.
522
+ * @param options - `dryRun` assesses and reports without writing anything.
523
+ * @returns The receipt; `outcome: 'refused'` means the copy did not verify and
524
+ * every row it inserted was reverted.
525
+ * @example
526
+ * ```ts
527
+ * const plan = await reconcileSupersededStores('/mnt/projects/llmtxt', { dryRun: true });
528
+ * for (const t of plan.before) console.log(t.targetTable, t.missingInLive);
529
+ * ```
530
+ * @task T12319
531
+ */
532
+ export async function reconcileSupersededStores(projectRoot, options = {}) {
533
+ const liveStorePath = resolveDualScopeDbPath('project', projectRoot);
534
+ // Never let exodus-on-open fire for this store while it is being reconciled
535
+ // (it would archive the legacy files mid-run). Everything — including the
536
+ // plan's source discovery — happens inside the suppression.
537
+ const { withExodusOnOpenSuppressed } = await import('./on-open.js');
538
+ return withExodusOnOpenSuppressed(liveStorePath, () => reconcileSuppressed(projectRoot, options, liveStorePath));
539
+ }
540
+ /** {@link reconcileSupersededStores} with exodus-on-open suppressed for the store. */
541
+ async function reconcileSuppressed(projectRoot, options, liveStorePath) {
542
+ const dryRun = options.dryRun === true;
543
+ const additive = options.additive === true;
544
+ const plan = buildExodusPlan(projectRoot);
545
+ // Rows land where the RUNTIME reads them (T12346), derived from its bindings.
546
+ const resolveTarget = await buildRuntimeTargetResolver();
547
+ // Scratch space for the materialised bare-family source; outside the project
548
+ // so a dry-run writes nothing there (an apply copies it into its staging dir).
549
+ const scratch = mkdtempSync(join(tmpdir(), 'cleo-reconcile-'));
550
+ try {
551
+ return await reconcileWithScratch(projectRoot, dryRun, additive, liveStorePath, plan, resolveTarget, scratch);
552
+ }
553
+ finally {
554
+ rmSync(scratch, { recursive: true, force: true });
555
+ }
556
+ }
557
+ /** The body of {@link reconcileSupersededStores}, with a scratch dir it owns. */
558
+ async function reconcileWithScratch(projectRoot, dryRun, additive, liveStorePath, plan, resolveTarget, scratch) {
559
+ const fileSources = plan.sources.filter((s) => s.targetScope === 'project' && existsSync(s.path));
560
+ // An additive run is for a project already live on the consolidated store;
561
+ // its bare family is not a source (and bareTaskCoreSource agrees).
562
+ const bare = !additive && existsSync(liveStorePath)
563
+ ? await bareTaskCoreSource(liveStorePath, resolveTarget, scratch)
564
+ : null;
565
+ // Additive: the live task graph is never written — only history tables.
566
+ const copyResolver = additive
567
+ ? (sourceName, legacyTable) => {
568
+ const r = resolveTarget(sourceName, legacyTable);
569
+ return r.kind !== 'skip' && isLiveAuthoritative(sourceName, legacyTable, r.targetName)
570
+ ? { kind: 'skip', reason: 'additive: live-authoritative task graph' }
571
+ : r;
572
+ }
573
+ : resolveTarget;
574
+ // Legacy FILES first so they win any key both hold; the bare family fills gaps.
575
+ const sources = bare ? [...fileSources, bare] : fileSources;
576
+ const base = {
577
+ mode: additive ? 'additive' : 'full',
578
+ conflicts: [],
579
+ dryRun,
580
+ projectRoot,
581
+ liveStorePath,
582
+ sourcePaths: sources.map((s) => s.path),
583
+ after: [],
584
+ rowsCopied: 0,
585
+ rolledBack: 0,
586
+ stagingDir: null,
587
+ receiptPath: null,
588
+ };
589
+ if (sources.length === 0 || !existsSync(liveStorePath)) {
590
+ return {
591
+ ...base,
592
+ outcome: 'nothing-to-reconcile',
593
+ before: [],
594
+ reason: sources.length === 0
595
+ ? 'no legacy project store (tasks.db / brain.db / conduit.db, or bare task tables in an unmigrated cleo.db) is present'
596
+ : `no live store at ${liveStorePath}; the legacy files are still the live data`,
597
+ };
598
+ }
599
+ const before = assessSupersededProjectStores(liveStorePath, sources, resolveTarget);
600
+ const plannedConflicts = additive ? conflictsOf(before) : [];
601
+ // Additive: gaps that are ALL live-authoritative leave nothing to copy.
602
+ const onlyConflicts = additive &&
603
+ plannedConflicts.length > 0 &&
604
+ plannedConflicts.every((c) => c.reason === 'live-authoritative');
605
+ if (onlyConflicts) {
606
+ return {
607
+ ...base,
608
+ conflicts: plannedConflicts,
609
+ outcome: 'nothing-to-reconcile',
610
+ before,
611
+ reason: `every history row is already present; ${describeConflicts(plannedConflicts)}`,
612
+ };
613
+ }
614
+ if (isComplete(before)) {
615
+ return {
616
+ ...base,
617
+ outcome: 'nothing-to-reconcile',
618
+ before,
619
+ reason: 'every legacy row is already present in cleo.db — nothing to copy',
620
+ };
621
+ }
622
+ if (dryRun) {
623
+ return {
624
+ ...base,
625
+ conflicts: plannedConflicts.filter((c) => c.reason === 'live-authoritative'),
626
+ outcome: 'planned',
627
+ before,
628
+ reason: additive
629
+ ? `would copy the missing rows of the history tables; ${describeConflicts(plannedConflicts.filter((c) => c.reason === 'live-authoritative'))} (key collisions are counted after the copy)`
630
+ : `would copy the missing rows of: ${describeGaps(before)}`,
631
+ };
632
+ }
633
+ const iso = new Date()
634
+ .toISOString()
635
+ .replace(/[:]/g, '')
636
+ .replace(/\..+Z$/, 'Z');
637
+ const stagingDir = join(resolveCleoDir(projectRoot), `${RECONCILE_DIR_PREFIX}${iso}`);
638
+ mkdirSync(stagingDir, { recursive: true });
639
+ // Keep the materialised bare source with the run's other evidence.
640
+ const copySources = sources.map((s) => {
641
+ if (s !== bare)
642
+ return s;
643
+ const kept = join(stagingDir, basename(s.path));
644
+ copyFileSync(s.path, kept);
645
+ return { ...s, path: kept };
646
+ });
647
+ const bareCopy = copySources.find((s) => s.name === BARE_SOURCE_NAME);
648
+ const fresherPath = bareCopy
649
+ ? snapshotFresherBareRows(bareCopy.path, fileSources.find((s) => s.name === 'tasks')?.path, stagingDir)
650
+ : null;
651
+ if (fresherPath !== null)
652
+ copySources.unshift({
653
+ name: `${BARE_SOURCE_NAME} (fresher)`,
654
+ path: fresherPath,
655
+ targetScope: 'project',
656
+ });
657
+ const reconcilePlan = { ...plan, sources: copySources, stagingDir, resumeFromStaging: false };
658
+ // Serialise with exodus-on-open, which takes the same lock on this target.
659
+ const result = await withLock(`${liveStorePath}.exodus-on-open.lock`, async () => {
660
+ // Prove "never overwrites": every live row that existed before the copy
661
+ // must still exist, byte for byte, afterwards.
662
+ const liveBefore = join(scratch, 'live-before.db');
663
+ snapshotLive(liveStorePath, liveBefore);
664
+ const migrated = await runExodusMigrate(reconcilePlan, false, (msg) => log.debug(msg), {
665
+ projectOnly: true,
666
+ resolveTarget: copyResolver,
667
+ ensureRuntimeTables: true,
668
+ });
669
+ const rowsCopied = migrated.tables.reduce((n, t) => n + t.rowsCopied, 0);
670
+ const after = migrated.ok
671
+ ? assessSupersededProjectStores(liveStorePath, sources, resolveTarget)
672
+ : [];
673
+ const lost = shrunk(before, after);
674
+ const { TASK_ID_SEQUENCE_SEED } = await import('../sqlite.js');
675
+ let altered = [];
676
+ if (migrated.ok) {
677
+ try {
678
+ altered = alteredLiveTables(liveStorePath, liveBefore, before, TASK_ID_SEQUENCE_SEED);
679
+ }
680
+ catch (error) {
681
+ // An unverifiable run is refused and reverted, never reported as done.
682
+ altered = [
683
+ `(verification failed: ${error instanceof Error ? error.message : String(error)})`,
684
+ ];
685
+ }
686
+ }
687
+ const conflicts = additive ? conflictsOf(after) : [];
688
+ const settled = additive ? true : isComplete(after);
689
+ if (migrated.ok && settled && lost.length === 0 && altered.length === 0) {
690
+ return {
691
+ ...base,
692
+ conflicts,
693
+ outcome: 'reconciled',
694
+ before,
695
+ after,
696
+ rowsCopied,
697
+ stagingDir,
698
+ reason: additive
699
+ ? `copied ${rowsCopied} row(s) with keys absent from live; live rows unchanged; ${describeConflicts(conflicts)}`
700
+ : `copied ${rowsCopied} row(s); every legacy row is now present in cleo.db`,
701
+ };
702
+ }
703
+ const rolledBack = await revertReconcile(liveStorePath, stagingDir);
704
+ const cause = !migrated.ok
705
+ ? `copy failed: ${migrated.error ?? `the copy engine gave no message — inspect the journal in ${stagingDir}`}`
706
+ : lost.length > 0
707
+ ? `live tables shrank: ${lost.join(', ')}`
708
+ : altered.length > 0
709
+ ? `pre-existing live rows changed in: ${altered.join(', ')}`
710
+ : `rows still missing after copy: ${describeGaps(after)}`;
711
+ return {
712
+ ...base,
713
+ outcome: 'refused',
714
+ before,
715
+ after,
716
+ rowsCopied,
717
+ rolledBack,
718
+ stagingDir,
719
+ reason: `${cause} — reverted the ${rolledBack} row(s) this run inserted; legacy files untouched`,
720
+ };
721
+ }, { stale: 600_000, retries: 30 });
722
+ const receiptPath = join(stagingDir, RECEIPT_FILENAME);
723
+ const receipt = { ...result, receiptPath };
724
+ writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, 'utf8');
725
+ log.info({ outcome: receipt.outcome, rowsCopied: receipt.rowsCopied, receiptPath }, `exodus-reconcile: ${receipt.reason}`);
726
+ return receipt;
727
+ }
728
+ //# sourceMappingURL=reconcile.js.map