@cleocode/core 2026.10.5 → 2026.10.6

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 (321) hide show
  1. package/dist/bootstrap.d.ts +13 -0
  2. package/dist/bootstrap.d.ts.map +1 -1
  3. package/dist/bootstrap.js +21 -32
  4. package/dist/bootstrap.js.map +1 -1
  5. package/dist/build-provenance.json +2 -2
  6. package/dist/cloud/nexus-cloud-status.d.ts.map +1 -1
  7. package/dist/cloud/nexus-cloud-status.js +3 -1
  8. package/dist/cloud/nexus-cloud-status.js.map +1 -1
  9. package/dist/cloud/nexus-device.d.ts +1 -1
  10. package/dist/cloud/nexus-presence-refresh.d.ts.map +1 -1
  11. package/dist/cloud/nexus-presence-refresh.js +20 -13
  12. package/dist/cloud/nexus-presence-refresh.js.map +1 -1
  13. package/dist/cloud/nexus-vault.d.ts.map +1 -1
  14. package/dist/cloud/nexus-vault.js +4 -1
  15. package/dist/cloud/nexus-vault.js.map +1 -1
  16. package/dist/docs/derive-links.d.ts +69 -0
  17. package/dist/docs/derive-links.d.ts.map +1 -0
  18. package/dist/docs/derive-links.js +85 -0
  19. package/dist/docs/derive-links.js.map +1 -0
  20. package/dist/docs/docs-generator.d.ts.map +1 -1
  21. package/dist/docs/docs-generator.js +2 -0
  22. package/dist/docs/docs-generator.js.map +1 -1
  23. package/dist/docs/docs-update.d.ts.map +1 -1
  24. package/dist/docs/docs-update.js +24 -4
  25. package/dist/docs/docs-update.js.map +1 -1
  26. package/dist/docs/fetch-result.d.ts +77 -0
  27. package/dist/docs/fetch-result.d.ts.map +1 -0
  28. package/dist/docs/fetch-result.js +132 -0
  29. package/dist/docs/fetch-result.js.map +1 -0
  30. package/dist/docs/supersede.d.ts.map +1 -1
  31. package/dist/docs/supersede.js +9 -0
  32. package/dist/docs/supersede.js.map +1 -1
  33. package/dist/doctor/db-substrate.d.ts.map +1 -1
  34. package/dist/doctor/db-substrate.js +64 -29
  35. package/dist/doctor/db-substrate.js.map +1 -1
  36. package/dist/doctor/row-identity-refill.d.ts.map +1 -1
  37. package/dist/doctor/row-identity-refill.js +25 -10
  38. package/dist/doctor/row-identity-refill.js.map +1 -1
  39. package/dist/doctor/row-identity.d.ts.map +1 -1
  40. package/dist/doctor/row-identity.js +8 -2
  41. package/dist/doctor/row-identity.js.map +1 -1
  42. package/dist/doctor/sync-repair.d.ts +5 -0
  43. package/dist/doctor/sync-repair.d.ts.map +1 -1
  44. package/dist/doctor/sync-repair.js +11 -0
  45. package/dist/doctor/sync-repair.js.map +1 -1
  46. package/dist/doctor/sync-triggers.d.ts.map +1 -1
  47. package/dist/doctor/sync-triggers.js +3 -17
  48. package/dist/doctor/sync-triggers.js.map +1 -1
  49. package/dist/hygiene/validate-spawn-readiness.d.ts +6 -1
  50. package/dist/hygiene/validate-spawn-readiness.d.ts.map +1 -1
  51. package/dist/hygiene/validate-spawn-readiness.js +11 -6
  52. package/dist/hygiene/validate-spawn-readiness.js.map +1 -1
  53. package/dist/init.d.ts.map +1 -1
  54. package/dist/init.js +10 -1
  55. package/dist/init.js.map +1 -1
  56. package/dist/injection.d.ts +37 -12
  57. package/dist/injection.d.ts.map +1 -1
  58. package/dist/injection.js +152 -35
  59. package/dist/injection.js.map +1 -1
  60. package/dist/memory/brain-search.d.ts.map +1 -1
  61. package/dist/memory/brain-search.js +7 -32
  62. package/dist/memory/brain-search.js.map +1 -1
  63. package/dist/memory/pipeline-manifest-sqlite.d.ts +2 -0
  64. package/dist/memory/pipeline-manifest-sqlite.d.ts.map +1 -1
  65. package/dist/memory/pipeline-manifest-sqlite.js +51 -9
  66. package/dist/memory/pipeline-manifest-sqlite.js.map +1 -1
  67. package/dist/nexus/assessment-projection.d.ts +96 -1
  68. package/dist/nexus/assessment-projection.d.ts.map +1 -1
  69. package/dist/nexus/assessment-projection.js +135 -0
  70. package/dist/nexus/assessment-projection.js.map +1 -1
  71. package/dist/nexus/assessment-store.d.ts +54 -0
  72. package/dist/nexus/assessment-store.d.ts.map +1 -1
  73. package/dist/nexus/assessment-store.js +161 -2
  74. package/dist/nexus/assessment-store.js.map +1 -1
  75. package/dist/nexus/knowledge.d.ts +19 -0
  76. package/dist/nexus/knowledge.d.ts.map +1 -1
  77. package/dist/nexus/knowledge.js +56 -2
  78. package/dist/nexus/knowledge.js.map +1 -1
  79. package/dist/orchestrate/spawn-ops.d.ts.map +1 -1
  80. package/dist/orchestrate/spawn-ops.js +12 -1
  81. package/dist/orchestrate/spawn-ops.js.map +1 -1
  82. package/dist/release/commit-presence.d.ts +24 -0
  83. package/dist/release/commit-presence.d.ts.map +1 -1
  84. package/dist/release/commit-presence.js +31 -0
  85. package/dist/release/commit-presence.js.map +1 -1
  86. package/dist/release/plan.d.ts +8 -6
  87. package/dist/release/plan.d.ts.map +1 -1
  88. package/dist/release/plan.js +11 -6
  89. package/dist/release/plan.js.map +1 -1
  90. package/dist/release/reconcile.d.ts.map +1 -1
  91. package/dist/release/reconcile.js +10 -8
  92. package/dist/release/reconcile.js.map +1 -1
  93. package/dist/release/verify-provenance.d.ts.map +1 -1
  94. package/dist/release/verify-provenance.js +6 -8
  95. package/dist/release/verify-provenance.js.map +1 -1
  96. package/dist/resources/admission-ledger.d.ts +66 -0
  97. package/dist/resources/admission-ledger.d.ts.map +1 -1
  98. package/dist/resources/admission-ledger.js +145 -6
  99. package/dist/resources/admission-ledger.js.map +1 -1
  100. package/dist/resources/run-class.d.ts +71 -0
  101. package/dist/resources/run-class.d.ts.map +1 -1
  102. package/dist/resources/run-class.js +203 -0
  103. package/dist/resources/run-class.js.map +1 -1
  104. package/dist/resources/run-governed.d.ts +2 -0
  105. package/dist/resources/run-governed.d.ts.map +1 -1
  106. package/dist/resources/run-governed.js +1 -0
  107. package/dist/resources/run-governed.js.map +1 -1
  108. package/dist/scaffold/ensure-config.d.ts +20 -9
  109. package/dist/scaffold/ensure-config.d.ts.map +1 -1
  110. package/dist/scaffold/ensure-config.js +56 -23
  111. package/dist/scaffold/ensure-config.js.map +1 -1
  112. package/dist/scaffold/ensure-skills.d.ts +10 -0
  113. package/dist/scaffold/ensure-skills.d.ts.map +1 -1
  114. package/dist/scaffold/ensure-skills.js +47 -3
  115. package/dist/scaffold/ensure-skills.js.map +1 -1
  116. package/dist/scaffold/upgrade-file-journal.d.ts +79 -0
  117. package/dist/scaffold/upgrade-file-journal.d.ts.map +1 -0
  118. package/dist/scaffold/upgrade-file-journal.js +111 -0
  119. package/dist/scaffold/upgrade-file-journal.js.map +1 -0
  120. package/dist/session/engine-ops.d.ts.map +1 -1
  121. package/dist/session/engine-ops.js +10 -2
  122. package/dist/session/engine-ops.js.map +1 -1
  123. package/dist/sessions/index.d.ts.map +1 -1
  124. package/dist/sessions/index.js +3 -0
  125. package/dist/sessions/index.js.map +1 -1
  126. package/dist/spawn/worktree-lock-holder.d.ts +59 -1
  127. package/dist/spawn/worktree-lock-holder.d.ts.map +1 -1
  128. package/dist/spawn/worktree-lock-holder.js +114 -0
  129. package/dist/spawn/worktree-lock-holder.js.map +1 -1
  130. package/dist/store/attachment-store.d.ts.map +1 -1
  131. package/dist/store/attachment-store.js +38 -0
  132. package/dist/store/attachment-store.js.map +1 -1
  133. package/dist/store/backup-recover.d.ts +4 -4
  134. package/dist/store/backup-recover.d.ts.map +1 -1
  135. package/dist/store/backup-recover.js +11 -4
  136. package/dist/store/backup-recover.js.map +1 -1
  137. package/dist/store/backup-sidecar.d.ts +19 -1
  138. package/dist/store/backup-sidecar.d.ts.map +1 -1
  139. package/dist/store/backup-sidecar.js +57 -6
  140. package/dist/store/backup-sidecar.js.map +1 -1
  141. package/dist/store/db-helpers.js +1 -1
  142. package/dist/store/display-id-alias.js +1 -1
  143. package/dist/store/display-id-alias.js.map +1 -1
  144. package/dist/store/dual-scope-db.d.ts +2 -0
  145. package/dist/store/dual-scope-db.d.ts.map +1 -1
  146. package/dist/store/dual-scope-db.js +28 -16
  147. package/dist/store/dual-scope-db.js.map +1 -1
  148. package/dist/store/exodus/archive.d.ts +42 -2
  149. package/dist/store/exodus/archive.d.ts.map +1 -1
  150. package/dist/store/exodus/archive.js +81 -4
  151. package/dist/store/exodus/archive.js.map +1 -1
  152. package/dist/store/exodus/bare-family.d.ts +104 -0
  153. package/dist/store/exodus/bare-family.d.ts.map +1 -0
  154. package/dist/store/exodus/bare-family.js +470 -0
  155. package/dist/store/exodus/bare-family.js.map +1 -0
  156. package/dist/store/exodus/column-transforms.d.ts.map +1 -1
  157. package/dist/store/exodus/column-transforms.js +15 -8
  158. package/dist/store/exodus/column-transforms.js.map +1 -1
  159. package/dist/store/exodus/health.d.ts +2 -0
  160. package/dist/store/exodus/health.d.ts.map +1 -1
  161. package/dist/store/exodus/health.js +7 -1
  162. package/dist/store/exodus/health.js.map +1 -1
  163. package/dist/store/exodus/index.d.ts +2 -2
  164. package/dist/store/exodus/index.d.ts.map +1 -1
  165. package/dist/store/exodus/index.js +2 -2
  166. package/dist/store/exodus/index.js.map +1 -1
  167. package/dist/store/exodus/migrate.d.ts.map +1 -1
  168. package/dist/store/exodus/migrate.js +30 -1
  169. package/dist/store/exodus/migrate.js.map +1 -1
  170. package/dist/store/exodus/on-open.d.ts +11 -0
  171. package/dist/store/exodus/on-open.d.ts.map +1 -1
  172. package/dist/store/exodus/on-open.js +26 -1
  173. package/dist/store/exodus/on-open.js.map +1 -1
  174. package/dist/store/exodus/prior-recoveries.d.ts +3 -2
  175. package/dist/store/exodus/prior-recoveries.d.ts.map +1 -1
  176. package/dist/store/exodus/prior-recoveries.js +7 -3
  177. package/dist/store/exodus/prior-recoveries.js.map +1 -1
  178. package/dist/store/exodus/reconcile.d.ts +44 -6
  179. package/dist/store/exodus/reconcile.d.ts.map +1 -1
  180. package/dist/store/exodus/reconcile.js +246 -173
  181. package/dist/store/exodus/reconcile.js.map +1 -1
  182. package/dist/store/exodus/recovery.d.ts +48 -0
  183. package/dist/store/exodus/recovery.d.ts.map +1 -1
  184. package/dist/store/exodus/recovery.js +104 -6
  185. package/dist/store/exodus/recovery.js.map +1 -1
  186. package/dist/store/exodus/seal.d.ts.map +1 -1
  187. package/dist/store/exodus/seal.js +4 -2
  188. package/dist/store/exodus/seal.js.map +1 -1
  189. package/dist/store/exodus/table-order.d.ts +6 -0
  190. package/dist/store/exodus/table-order.d.ts.map +1 -1
  191. package/dist/store/exodus/table-order.js +15 -0
  192. package/dist/store/exodus/table-order.js.map +1 -1
  193. package/dist/store/exodus/types.d.ts +4 -0
  194. package/dist/store/exodus/types.d.ts.map +1 -1
  195. package/dist/store/exodus/types.js +4 -0
  196. package/dist/store/exodus/types.js.map +1 -1
  197. package/dist/store/exodus/verify-migration.d.ts.map +1 -1
  198. package/dist/store/exodus/verify-migration.js +22 -17
  199. package/dist/store/exodus/verify-migration.js.map +1 -1
  200. package/dist/store/identity-share.d.ts +14 -0
  201. package/dist/store/identity-share.d.ts.map +1 -1
  202. package/dist/store/identity-share.js +33 -0
  203. package/dist/store/identity-share.js.map +1 -1
  204. package/dist/store/lock.d.ts +33 -0
  205. package/dist/store/lock.d.ts.map +1 -1
  206. package/dist/store/lock.js +60 -0
  207. package/dist/store/lock.js.map +1 -1
  208. package/dist/store/portable-bundle-import.d.ts.map +1 -1
  209. package/dist/store/portable-bundle-import.js +7 -0
  210. package/dist/store/portable-bundle-import.js.map +1 -1
  211. package/dist/store/portable-bundle-scan.d.ts +3 -2
  212. package/dist/store/portable-bundle-scan.d.ts.map +1 -1
  213. package/dist/store/portable-bundle-scan.js +7 -5
  214. package/dist/store/portable-bundle-scan.js.map +1 -1
  215. package/dist/store/portable-bundle.d.ts +1 -1
  216. package/dist/store/portable-bundle.d.ts.map +1 -1
  217. package/dist/store/portable-bundle.js +9 -1
  218. package/dist/store/portable-bundle.js.map +1 -1
  219. package/dist/store/repair-malformed-dbs.d.ts.map +1 -1
  220. package/dist/store/repair-malformed-dbs.js +36 -6
  221. package/dist/store/repair-malformed-dbs.js.map +1 -1
  222. package/dist/store/restore-marker.d.ts +20 -2
  223. package/dist/store/restore-marker.d.ts.map +1 -1
  224. package/dist/store/restore-marker.js +41 -1
  225. package/dist/store/restore-marker.js.map +1 -1
  226. package/dist/store/row-identity-flag.d.ts +9 -6
  227. package/dist/store/row-identity-flag.d.ts.map +1 -1
  228. package/dist/store/row-identity-flag.js +10 -7
  229. package/dist/store/row-identity-flag.js.map +1 -1
  230. package/dist/store/row-identity-registry.d.ts +4 -3
  231. package/dist/store/row-identity-registry.d.ts.map +1 -1
  232. package/dist/store/row-identity-registry.js +132 -31
  233. package/dist/store/row-identity-registry.js.map +1 -1
  234. package/dist/store/row-identity.d.ts +81 -14
  235. package/dist/store/row-identity.d.ts.map +1 -1
  236. package/dist/store/row-identity.js +238 -32
  237. package/dist/store/row-identity.js.map +1 -1
  238. package/dist/store/schema/cleo-shared/brain.d.ts +368 -0
  239. package/dist/store/schema/cleo-shared/brain.d.ts.map +1 -1
  240. package/dist/store/schema/cleo-shared/brain.js +104 -2
  241. package/dist/store/schema/cleo-shared/brain.js.map +1 -1
  242. package/dist/store/sync/apply/applier.d.ts +5 -0
  243. package/dist/store/sync/apply/applier.d.ts.map +1 -1
  244. package/dist/store/sync/apply/applier.js +5 -1
  245. package/dist/store/sync/apply/applier.js.map +1 -1
  246. package/dist/store/sync/apply/parent-delete.d.ts.map +1 -1
  247. package/dist/store/sync/apply/parent-delete.js +4 -0
  248. package/dist/store/sync/apply/parent-delete.js.map +1 -1
  249. package/dist/store/sync/capture.d.ts +13 -1
  250. package/dist/store/sync/capture.d.ts.map +1 -1
  251. package/dist/store/sync/capture.js +31 -3
  252. package/dist/store/sync/capture.js.map +1 -1
  253. package/dist/store/sync/flags.d.ts +14 -5
  254. package/dist/store/sync/flags.d.ts.map +1 -1
  255. package/dist/store/sync/flags.js +20 -8
  256. package/dist/store/sync/flags.js.map +1 -1
  257. package/dist/store/sync/genesis.d.ts +130 -0
  258. package/dist/store/sync/genesis.d.ts.map +1 -0
  259. package/dist/store/sync/genesis.js +337 -0
  260. package/dist/store/sync/genesis.js.map +1 -0
  261. package/dist/store/sync/repair.d.ts +2 -2
  262. package/dist/store/sync/repair.d.ts.map +1 -1
  263. package/dist/store/sync/repair.js +55 -15
  264. package/dist/store/sync/repair.js.map +1 -1
  265. package/dist/store/sync/sealer.d.ts +14 -0
  266. package/dist/store/sync/sealer.d.ts.map +1 -1
  267. package/dist/store/sync/sealer.js +27 -6
  268. package/dist/store/sync/sealer.js.map +1 -1
  269. package/dist/store/sync/segments.d.ts +124 -0
  270. package/dist/store/sync/segments.d.ts.map +1 -0
  271. package/dist/store/sync/segments.js +267 -0
  272. package/dist/store/sync/segments.js.map +1 -0
  273. package/dist/store/sync/sequencing.d.ts +44 -1
  274. package/dist/store/sync/sequencing.d.ts.map +1 -1
  275. package/dist/store/sync/sequencing.js +59 -2
  276. package/dist/store/sync/sequencing.js.map +1 -1
  277. package/dist/store/sync/timestamp-canon.d.ts +6 -3
  278. package/dist/store/sync/timestamp-canon.d.ts.map +1 -1
  279. package/dist/store/sync/timestamp-canon.js +55 -4
  280. package/dist/store/sync/timestamp-canon.js.map +1 -1
  281. package/dist/store/sync/txn-signing.d.ts +69 -0
  282. package/dist/store/sync/txn-signing.d.ts.map +1 -0
  283. package/dist/store/sync/txn-signing.js +81 -0
  284. package/dist/store/sync/txn-signing.js.map +1 -0
  285. package/dist/store/table-classification.d.ts.map +1 -1
  286. package/dist/store/table-classification.js +30 -0
  287. package/dist/store/table-classification.js.map +1 -1
  288. package/dist/store/twin-collapse.d.ts.map +1 -1
  289. package/dist/store/twin-collapse.js +65 -2
  290. package/dist/store/twin-collapse.js.map +1 -1
  291. package/dist/system/backup.d.ts +3 -2
  292. package/dist/system/backup.d.ts.map +1 -1
  293. package/dist/system/backup.js +43 -17
  294. package/dist/system/backup.js.map +1 -1
  295. package/dist/system/health.d.ts.map +1 -1
  296. package/dist/system/health.js +2 -1
  297. package/dist/system/health.js.map +1 -1
  298. package/dist/tasks/affected-packages.d.ts +26 -0
  299. package/dist/tasks/affected-packages.d.ts.map +1 -1
  300. package/dist/tasks/affected-packages.js +95 -28
  301. package/dist/tasks/affected-packages.js.map +1 -1
  302. package/dist/tasks/change-set.d.ts.map +1 -1
  303. package/dist/tasks/change-set.js +14 -4
  304. package/dist/tasks/change-set.js.map +1 -1
  305. package/dist/tasks/done-plan.d.ts.map +1 -1
  306. package/dist/tasks/done-plan.js +50 -4
  307. package/dist/tasks/done-plan.js.map +1 -1
  308. package/dist/tasks/test-run-binding.d.ts +14 -0
  309. package/dist/tasks/test-run-binding.d.ts.map +1 -1
  310. package/dist/tasks/test-run-binding.js +35 -4
  311. package/dist/tasks/test-run-binding.js.map +1 -1
  312. package/dist/upgrade.d.ts +6 -0
  313. package/dist/upgrade.d.ts.map +1 -1
  314. package/dist/upgrade.js +58 -11
  315. package/dist/upgrade.js.map +1 -1
  316. package/migrations/drizzle-cleo-global/20261010120000_t12894-brain-row-uids/migration.sql +88 -0
  317. package/migrations/drizzle-cleo-project/20261010120000_t12894-brain-row-uids/migration.sql +92 -0
  318. package/migrations/sync-journal/20261007120000_t12343-segment-outbox/migration.sql +38 -0
  319. package/package.json +13 -13
  320. package/templates/CLEO-INJECTION.md +1 -1
  321. package/templates/CLEO-REFERENCE.md +2 -2
@@ -34,16 +34,16 @@
34
34
  * @see ./migrate.ts — the single copy engine
35
35
  * @see ../../doctor/superseded-store.ts — the survey that points here
36
36
  */
37
- import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
37
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
38
38
  import { tmpdir } from 'node:os';
39
- import { basename, join } from 'node:path';
39
+ import { basename, dirname, isAbsolute, join } from 'node:path';
40
40
  import { getLogger } from '../../logger.js';
41
41
  import { resolveCleoDir } from '../../paths.js';
42
42
  import { resolveDualScopeDbPath } from '../dual-scope-db.js';
43
43
  import { lockCompromiseTracker, withLock } from '../lock.js';
44
44
  import { openCleoDbSnapshot } from '../open-cleo-db.js';
45
45
  import { rowIdentityColumns } from '../row-identity-registry.js';
46
- import { bareTableDigest, recordBareAccounts } from '../sync/flags.js';
46
+ import { bareTableDigest, forgetBareAccounts, recordBareAccounts } from '../sync/flags.js';
47
47
  import { EXODUS_LOCK_STALE_MS, exodusRunLockPath, whileExodusRunHeld } from './abort-events.js';
48
48
  import { legacyRowProjection } from './column-transforms.js';
49
49
  import { runExodusMigrate } from './migrate.js';
@@ -51,28 +51,14 @@ import { buildExodusPlan } from './plan.js';
51
51
  import { rollbackExodusReceipts } from './recovery.js';
52
52
  import { buildRuntimeTargetResolver } from './runtime-targets.js';
53
53
  import { resolveConsolidatedTableName, resolveTableTargetScope } from './table-name-map.js';
54
- import { orderTablesForCopy } from './table-order.js';
54
+ import { countRows, hasTable, ident, orderTablesForCopy } from './table-order.js';
55
55
  import { describeRemaps, describeUndecided, remapCollidingTaskIds, unlandedRemaps, } from './task-id-remap.js';
56
+ import { BARE_SOURCE_NAME, BARE_STRANDS_SOURCE_NAME } from './types.js';
56
57
  const log = getLogger('exodus-reconcile');
57
58
  /** Prefix of a reconcile staging dir — deliberately NOT `exodus-staging-`, which on-open resumes. */
58
59
  const RECONCILE_DIR_PREFIX = 'exodus-reconcile-';
59
60
  /** Receipt filename written inside the reconcile staging dir. */
60
61
  const RECEIPT_FILENAME = 'reconcile-receipt.json';
61
- /** Quote an SQLite identifier. */
62
- function ident(name) {
63
- return `"${name.replace(/"/g, '""')}"`;
64
- }
65
- /** Whether `schema.table` exists on `db`. */
66
- function hasTable(db, schema, table) {
67
- return (db
68
- .prepare(`SELECT 1 AS ok FROM ${ident(schema)}.sqlite_master WHERE type='table' AND name=?`)
69
- .get(table) !== undefined);
70
- }
71
- /** Row count of `schema.table`. */
72
- function countRows(db, schema, table) {
73
- const row = db.prepare(`SELECT COUNT(*) AS n FROM ${ident(schema)}.${ident(table)}`).get();
74
- return Number(row?.n ?? 0);
75
- }
76
62
  /**
77
63
  * Count rows of the attached legacy table whose primary key is absent from the
78
64
  * live table, or `null` when the tables share no complete primary key.
@@ -189,95 +175,6 @@ export function legacySourcesHoldRows(sources) {
189
175
  }
190
176
  return false;
191
177
  }
192
- /**
193
- * Whether a bare legacy table in the live `cleo.db` is one the runtime NO LONGER
194
- * reads — i.e. its runtime target is a different (prefixed) table. Derived from
195
- * the runtime's own table bindings via `resolveTarget`, never a hand-kept list:
196
- * `tasks` → `tasks_tasks` is dead-bare; `audit_log` → `audit_log` is live.
197
- *
198
- * @task T12346
199
- */
200
- function isRuntimeDeadBare(resolveTarget, table) {
201
- const target = resolveTarget('tasks', table);
202
- return target.kind !== 'skip' && target.targetName !== table;
203
- }
204
- /** Logical source name for the live store's bare task-core family. */
205
- const BARE_SOURCE_NAME = 'tasks (cleo.db bare task-core)';
206
- /**
207
- * The live store's own dead bare task-core family as a reconcile source — item
208
- * 2 of T12319, found real by the T12346 sweep: a stranded `cleo.db` can hold
209
- * rows in bare tables the runtime no longer reads that exist in NO legacy file
210
- * (llmtxt: 1,972 `task_labels`; versionguard: 20 `task_dependencies`).
211
- *
212
- * Offered ONLY while the prefixed `tasks_tasks` is still empty — i.e. the
213
- * project never ran on the consolidated store. Once it has, a bare row missing
214
- * from the prefixed family may be one the runtime deliberately removed, and
215
- * re-copying it would resurrect it.
216
- *
217
- * Materialised as a standalone file in `outDir` holding exactly the qualifying
218
- * rows, built table by table from the live file's own DDL (a whole-file copy
219
- * cannot be pruned: sqlite-vec `vec0` tables cannot be dropped without their
220
- * module). Rows a FRESH project store already holds (e.g. the lineage's 18
221
- * backfilled `commits`) are not project data and are excluded — measured by
222
- * building such a store, not by guessing — so a reconciled project ends up
223
- * with exactly what a fresh one has there, the same as exodus-on-open.
224
- * The table set is fixed when the file is built, so the post-copy verification
225
- * judges exactly what was copied.
226
- *
227
- * @returns The descriptor (path = the materialised file), or `null`.
228
- */
229
- async function bareTaskCoreSource(liveStorePath, resolveTarget, outDir) {
230
- const live = openCleoDbSnapshot(liveStorePath, { readOnly: true });
231
- let tables;
232
- try {
233
- if (hasTable(live.db, 'main', 'tasks_tasks') && countRows(live.db, 'main', 'tasks_tasks') > 0)
234
- return null;
235
- tables = live.db
236
- .prepare("SELECT name, sql FROM main.sqlite_master WHERE type='table' AND sql IS NOT NULL")
237
- .all().filter((t) => isRuntimeDeadBare(resolveTarget, t.name) && countRows(live.db, 'main', t.name) > 0);
238
- }
239
- finally {
240
- live.close();
241
- }
242
- if (tables.length === 0)
243
- return null;
244
- const seedPath = await buildFreshProjectStore(join(outDir, 'fresh-project-seed.db'));
245
- const path = join(outDir, 'cleo-bare-task-core.db');
246
- const snap = openCleoDbSnapshot(path, { readOnly: false });
247
- let kept = 0;
248
- try {
249
- snap.db.exec('PRAGMA foreign_keys=OFF');
250
- snap.db.exec(`ATTACH DATABASE '${liveStorePath.replace(/'/g, "''")}' AS live`);
251
- snap.db.exec(`ATTACH DATABASE '${seedPath.replace(/'/g, "''")}' AS seed`);
252
- for (const t of tables) {
253
- snap.db.exec(t.sql);
254
- snap.db.exec(`INSERT INTO main.${ident(t.name)} SELECT * FROM live.${ident(t.name)}`);
255
- if (hasTable(snap.db, 'seed', t.name)) {
256
- const seedCols = new Set(snap.db.prepare(`PRAGMA seed.table_info(${ident(t.name)})`).all().map((c) => c.name));
257
- const cols = snap.db.prepare(`PRAGMA main.table_info(${ident(t.name)})`).all();
258
- // A seed row is identified by its KEY: seeded rows carry the time they
259
- // were seeded (e.g. `commits.created_at`), so whole-row equality never
260
- // matches a seed written on another day. Keyless tables compare rows.
261
- const pk = cols.filter((c) => c.pk > 0 && seedCols.has(c.name)).map((c) => c.name);
262
- const match = (pk.length > 0 ? pk : cols.map((c) => c.name).filter((c) => seedCols.has(c)))
263
- .map((c) => `s.${ident(c)} IS m.${ident(c)}`)
264
- .join(' AND ');
265
- if (match.length > 0)
266
- snap.db.exec(`DELETE FROM main.${ident(t.name)} AS m WHERE EXISTS (SELECT 1 FROM seed.${ident(t.name)} s WHERE ${match})`);
267
- }
268
- const n = countRows(snap.db, 'main', t.name);
269
- if (n === 0)
270
- snap.db.exec(`DROP TABLE main.${ident(t.name)}`);
271
- kept += n;
272
- }
273
- snap.db.exec('DETACH DATABASE live');
274
- snap.db.exec('DETACH DATABASE seed');
275
- }
276
- finally {
277
- snap.close();
278
- }
279
- return kept > 0 ? { name: BARE_SOURCE_NAME, path, targetScope: 'project' } : null;
280
- }
281
178
  /**
282
179
  * Key digests ({@link bareTableDigest}) of the live store's bare tables that
283
180
  * the materialised bare source at `barePath` carries (T13319).
@@ -301,26 +198,6 @@ function bareAccounts(liveStorePath, barePath) {
301
198
  live.close();
302
199
  }
303
200
  }
304
- /**
305
- * Build a FRESH project store — consolidated schema plus the tasks-domain
306
- * lineage, exactly as a brand-new project gets it — and return its path. Its
307
- * rows are, by construction, only what CLEO seeds (e.g. the 18 backfilled
308
- * `commits`), never project data.
309
- */
310
- async function buildFreshProjectStore(path) {
311
- const { getDualScopeNativeDb, openDualScopeDbAtPath } = await import('../dual-scope-db.js');
312
- const { ensureTasksDomainTables, seedTasksMeta } = await import('../sqlite.js');
313
- const handle = await openDualScopeDbAtPath('project', path, undefined, { dedicated: true });
314
- try {
315
- const native = getDualScopeNativeDb(handle);
316
- ensureTasksDomainTables(native, path);
317
- seedTasksMeta(native);
318
- }
319
- finally {
320
- handle.close();
321
- }
322
- return path;
323
- }
324
201
  /**
325
202
  * From the bare task-core snapshot, extract the rows that are strictly NEWER
326
203
  * (`updated_at`) than the same-key row in the legacy `tasks.db`, into their own
@@ -421,7 +298,11 @@ async function revertReconcile(liveStorePath, stagingDir) {
421
298
  dedicated: true,
422
299
  });
423
300
  try {
424
- return rollbackExodusReceipts(getDualScopeNativeDb(handle), stagingDir);
301
+ const native = getDualScopeNativeDb(handle);
302
+ const reverted = rollbackExodusReceipts(native, stagingDir);
303
+ // A reverted run carried nothing, so its record of carried tables goes too.
304
+ forgetBareAccounts(native, basename(stagingDir));
305
+ return reverted;
425
306
  }
426
307
  finally {
427
308
  handle.close();
@@ -571,6 +452,7 @@ function alteredLiveTables(liveStorePath, beforePath, counts, sequenceSeed) {
571
452
  * @task T12355
572
453
  */
573
454
  export async function unmigratedBareSources(liveStorePath, resolveTarget, legacyTasksPath, outDir) {
455
+ const { bareTaskCoreSource } = await import('./bare-family.js');
574
456
  const bare = await bareTaskCoreSource(liveStorePath, resolveTarget, outDir);
575
457
  if (bare === null)
576
458
  return { first: [], last: [] };
@@ -582,11 +464,125 @@ export async function unmigratedBareSources(liveStorePath, resolveTarget, legacy
582
464
  last: [bare],
583
465
  };
584
466
  }
467
+ /** The receipt name a rolled-back run's receipt is renamed to (T13309). */
468
+ const ROLLED_BACK_RECEIPT_FILENAME = 'reconcile-receipt.rolled-back.json';
469
+ /**
470
+ * Revert a reconciled run from its receipt (T13309): every row it inserted is
471
+ * removed, but only when each is still exactly as the run wrote it (the
472
+ * exodus receipts guard refuses the whole revert otherwise). Holds the store's
473
+ * exodus lock, so no reconcile or exodus-on-open runs alongside. The receipt
474
+ * is renamed, so neither a later run's earlier-recoveries scan nor the sync
475
+ * check for stranded bare rows trusts it again.
476
+ *
477
+ * @param projectRoot - Absolute project root.
478
+ * @param runDir - The run directory (`.cleo/exodus-reconcile-<iso>`), absolute
479
+ * or relative to the project's `.cleo`.
480
+ * @throws When `runDir` is not a reconciled run of this project, or a row the
481
+ * run inserted has changed since.
482
+ * @example
483
+ * ```ts
484
+ * await rollbackSupersededReconcile('/p', 'exodus-reconcile-20261007T020000Z');
485
+ * ```
486
+ */
487
+ export async function rollbackSupersededReconcile(projectRoot, runDir) {
488
+ const cleoDir = resolveCleoDir(projectRoot);
489
+ const dir = isAbsolute(runDir) ? runDir : join(cleoDir, runDir);
490
+ const receiptPath = join(dir, RECEIPT_FILENAME);
491
+ if (dirname(dir) !== cleoDir || !basename(dir).startsWith(RECONCILE_DIR_PREFIX)) {
492
+ // @sync-invariant none:local-only rolling back a local reconcile run is refused before any write; never replicated
493
+ throw new Error(`${runDir} is not a reconcile run directory under ${cleoDir}`);
494
+ }
495
+ let receipt;
496
+ try {
497
+ receipt = JSON.parse(readFileSync(receiptPath, 'utf8'));
498
+ }
499
+ catch {
500
+ // @sync-invariant none:local-only rolling back a local reconcile run is refused before any write; never replicated
501
+ throw new Error(`no reconcile receipt at ${receiptPath}`);
502
+ }
503
+ const fields = new Map(typeof receipt === 'object' && receipt !== null ? Object.entries(receipt) : []);
504
+ const outcome = fields.get('outcome');
505
+ // A refused run whose own revert failed left rows behind (T13384).
506
+ const unreverted = outcome === 'refused' &&
507
+ Number(fields.get('rowsCopied') ?? 0) > Number(fields.get('rolledBack') ?? 0);
508
+ if (outcome !== 'reconciled' && !unreverted) {
509
+ // @sync-invariant none:local-only rolling back a local reconcile run is refused before any write; never replicated
510
+ throw new Error(`${receiptPath} records outcome ${String(outcome)}; only a reconciled run, or a refused run whose revert failed, can be rolled back`);
511
+ }
512
+ const liveStorePath = resolveDualScopeDbPath('project', projectRoot);
513
+ const lock = lockCompromiseTracker();
514
+ const lockPath = exodusRunLockPath(liveStorePath);
515
+ const rowsReverted = await withLock(lockPath, whileExodusRunHeld(lockPath, async () => revertReconcile(liveStorePath, dir)), { stale: EXODUS_LOCK_STALE_MS, retries: 30, onCompromised: lock.onCompromised });
516
+ const rolledBack = join(dir, ROLLED_BACK_RECEIPT_FILENAME);
517
+ renameSync(receiptPath, rolledBack);
518
+ log.info({ runDir: dir, rowsReverted }, 'exodus-reconcile: rolled back');
519
+ return { runDir: dir, rowsReverted, receiptPath: rolledBack };
520
+ }
521
+ /** Create a fresh run directory under `.cleo/` for a reconcile apply. */
522
+ function newRunDir(cleoDir) {
523
+ const iso = new Date()
524
+ .toISOString()
525
+ .replace(/[:]/g, '')
526
+ .replace(/\..+Z$/, 'Z');
527
+ const dir = join(cleoDir, `${RECONCILE_DIR_PREFIX}${iso}`);
528
+ mkdirSync(dir, { recursive: true });
529
+ return dir;
530
+ }
531
+ /** Write `result`'s receipt into its run directory and return it with the path. */
532
+ function writeReceipt(result, stagingDir) {
533
+ const receiptPath = join(stagingDir, RECEIPT_FILENAME);
534
+ const receipt = { ...result, receiptPath };
535
+ writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, 'utf8');
536
+ log.info({ outcome: receipt.outcome, rowsCopied: receipt.rowsCopied, receiptPath }, `exodus-reconcile: ${receipt.reason}`);
537
+ return receipt;
538
+ }
539
+ /**
540
+ * A bare-strands run with nothing to copy — every stranded row is present, or
541
+ * deliberately skipped: record in the store that every bare table is settled
542
+ * (T13320), so the sync refusal for stranded rows stands down, and a
543
+ * `reconciled` receipt naming any skipped rows (T13309). This is also how a
544
+ * store reconciled before T13319 gets its record. A dry run only reports.
545
+ * A stranded row neither present nor listed as skipped refuses the settle,
546
+ * so the refusal stands (T13333).
547
+ */
548
+ async function settleStrands(none, strands, cleoDir, resolveTarget) {
549
+ if (none.dryRun || strands.accounted.length === 0)
550
+ return none;
551
+ const unaccounted = unaccountedStrands(none.liveStorePath, strands, resolveTarget);
552
+ if (unaccounted.length > 0) {
553
+ return {
554
+ ...none,
555
+ outcome: 'refused',
556
+ reason: `stranded rows neither in cleo.db nor listed as skipped: ${describeGaps(unaccounted)}`,
557
+ };
558
+ }
559
+ const stagingDir = newRunDir(cleoDir);
560
+ await recordCarriedBareTables(none.liveStorePath, strands.accounted, basename(stagingDir));
561
+ return writeReceipt({
562
+ ...none,
563
+ outcome: 'reconciled',
564
+ stagingDir,
565
+ accounted: strands.accounted,
566
+ snapshotPath: null,
567
+ reason: `nothing copied; ${none.reason}`,
568
+ }, stagingDir);
569
+ }
570
+ /**
571
+ * The tables where a bare-strands run's accounting copy (every stranded row
572
+ * it did not list as skipped, renumbered) holds rows the live store lacks by
573
+ * key (T13333). Empty when every such row is in the live store.
574
+ */
575
+ function unaccountedStrands(liveStorePath, strands, resolveTarget) {
576
+ if (strands.accountingPath === null)
577
+ return [];
578
+ return assessSupersededProjectStores(liveStorePath, [{ name: BARE_STRANDS_SOURCE_NAME, path: strands.accountingPath, targetScope: 'project' }], resolveTarget).filter((c) => !isComplete([c]));
579
+ }
585
580
  /**
586
581
  * Reconcile a project's stranded legacy stores into its live `cleo.db`.
587
582
  *
588
583
  * @param projectRoot - Absolute project root.
589
- * @param options - `dryRun` assesses and reports without writing anything.
584
+ * @param options - `dryRun` assesses and reports without writing anything;
585
+ * `bareStrands` copies the live store's stranded bare rows (T13309).
590
586
  * @returns The receipt; `outcome: 'refused'` means the copy did not verify and
591
587
  * every row it inserted was reverted.
592
588
  * @example
@@ -607,7 +603,7 @@ export async function reconcileSupersededStores(projectRoot, options = {}) {
607
603
  /** {@link reconcileSupersededStores} with exodus-on-open suppressed for the store. */
608
604
  async function reconcileSuppressed(projectRoot, options, liveStorePath) {
609
605
  const dryRun = options.dryRun === true;
610
- const additive = options.additive === true;
606
+ const mode = options.bareStrands === true ? 'bare-strands' : options.additive === true ? 'additive' : 'full';
611
607
  const plan = buildExodusPlan(projectRoot);
612
608
  // Rows land where the RUNTIME reads them (T12346), derived from its bindings.
613
609
  const resolveTarget = await buildRuntimeTargetResolver();
@@ -615,29 +611,69 @@ async function reconcileSuppressed(projectRoot, options, liveStorePath) {
615
611
  // so a dry-run writes nothing there (an apply copies it into its staging dir).
616
612
  const scratch = mkdtempSync(join(tmpdir(), 'cleo-reconcile-'));
617
613
  try {
618
- return await reconcileWithScratch(projectRoot, dryRun, additive, liveStorePath, plan, resolveTarget, scratch);
614
+ return await reconcileWithScratch(projectRoot, dryRun, mode, liveStorePath, plan, resolveTarget, scratch);
619
615
  }
620
616
  finally {
621
617
  rmSync(scratch, { recursive: true, force: true });
622
618
  }
623
619
  }
624
620
  /** The body of {@link reconcileSupersededStores}, with a scratch dir it owns. */
625
- async function reconcileWithScratch(projectRoot, dryRun, additive, liveStorePath, plan, resolveTarget, scratch) {
621
+ async function reconcileWithScratch(projectRoot, dryRun, mode, liveStorePath, plan, resolveTarget, scratch) {
622
+ const additive = mode === 'additive';
623
+ const strandsMode = mode === 'bare-strands';
624
+ const cleoDir = resolveCleoDir(projectRoot);
626
625
  const legacyFiles = plan.sources.filter((s) => s.targetScope === 'project' && existsSync(s.path));
626
+ // Bare-strands mode (T13309) reads only the live store's stranded bare rows,
627
+ // and only once the legacy files are carried: a full run recovers a legacy
628
+ // task whose id was reused, and this run then finds that recovery in the
629
+ // full run's receipt instead of recovering the same task a second time.
630
+ if (strandsMode && legacyFiles.length > 0 && existsSync(liveStorePath)) {
631
+ const pending = assessSupersededProjectStores(liveStorePath, legacyFiles, resolveTarget);
632
+ if (!isComplete(pending)) {
633
+ return {
634
+ outcome: 'nothing-to-reconcile',
635
+ mode,
636
+ conflicts: [],
637
+ remaps: [],
638
+ dryRun,
639
+ projectRoot,
640
+ liveStorePath,
641
+ sourcePaths: legacyFiles.map((s) => s.path),
642
+ before: pending,
643
+ after: [],
644
+ rowsCopied: 0,
645
+ rolledBack: 0,
646
+ stagingDir: null,
647
+ receiptPath: null,
648
+ reason: `legacy files still hold rows missing from cleo.db (${describeGaps(pending)}); run ` +
649
+ '`cleo doctor superseded-store --reconcile` first, then the bare-strands reconcile',
650
+ };
651
+ }
652
+ }
653
+ const strands = strandsMode && existsSync(liveStorePath)
654
+ ? await (await import('./bare-family.js')).bareStrandSource(liveStorePath, resolveTarget, scratch, cleoDir)
655
+ : null;
656
+ // Rows bare-strands mode deliberately leaves uncopied, named in the receipt.
657
+ const skipped = strands?.skipped ?? [];
658
+ // What the run reads: the legacy files, or the stranded bare rows.
659
+ const originals = strandsMode ? (strands?.source ? [strands.source] : []) : legacyFiles;
627
660
  // T13172: a legacy task whose id a DIFFERENT live task holds is renumbered in
628
661
  // a scratch copy (references re-pointed) and the run reads that copy, so it
629
662
  // is recovered, never skipped by INSERT OR IGNORE and counted as present.
630
663
  // Additive runs never write the task graph, so they never renumber.
631
- const remap = !additive && existsSync(liveStorePath)
632
- ? remapCollidingTaskIds(liveStorePath, legacyFiles, scratch, resolveCleoDir(projectRoot))
633
- : { sources: legacyFiles, remaps: [], undecided: null, remappedPath: null };
664
+ // Bare-strands mode renumbered before judging its rows (T13333).
665
+ const remap = strandsMode
666
+ ? (strands?.remap ?? { sources: [], remaps: [], undecided: null, remappedPath: null })
667
+ : !additive && existsSync(liveStorePath)
668
+ ? remapCollidingTaskIds(liveStorePath, legacyFiles, scratch, cleoDir)
669
+ : { sources: legacyFiles, remaps: [], undecided: null, remappedPath: null };
634
670
  // An undecided collision withholds the WHOLE task graph (review MED-2): its
635
671
  // children, dependencies and criteria would otherwise attach to the live
636
672
  // task that holds its id. History tables still copy, and from the ORIGINAL
637
673
  // legacy files: the renumbered copy would point history rows at fresh ids
638
674
  // no live task holds, which the next `cleo add` could then mint (review LOW).
639
675
  const graphWithheld = !additive && remap.undecided !== null;
640
- const fileSources = graphWithheld ? legacyFiles : remap.sources;
676
+ const fileSources = graphWithheld ? originals : remap.sources;
641
677
  // The receipt records each remap with the legacy creation time and type,
642
678
  // which later runs check before trusting it (T13183).
643
679
  const remaps = remap.remaps;
@@ -648,8 +684,8 @@ async function reconcileWithScratch(projectRoot, dryRun, additive, liveStorePath
648
684
  (undecided ? `; ${describeUndecided(undecided)}` : '');
649
685
  // An additive run is for a project already live on the consolidated store;
650
686
  // its bare family is not a source (and bareTaskCoreSource agrees).
651
- const bare = !additive && existsSync(liveStorePath)
652
- ? await bareTaskCoreSource(liveStorePath, resolveTarget, scratch)
687
+ const bare = !additive && !strandsMode && existsSync(liveStorePath)
688
+ ? await (await import('./bare-family.js')).bareTaskCoreSource(liveStorePath, resolveTarget, scratch)
653
689
  : null;
654
690
  // The bare tables this run carries, by key digest. A verified run records
655
691
  // them in the store (T13320) — the sync refusal for stranded bare rows
@@ -675,15 +711,17 @@ async function reconcileWithScratch(projectRoot, dryRun, additive, liveStorePath
675
711
  // Legacy FILES first so they win any key both hold; the bare family fills gaps.
676
712
  const sources = bare ? [...fileSources, bare] : fileSources;
677
713
  const base = {
678
- mode: additive ? 'additive' : 'full',
679
- conflicts: (undecided ? [undecided] : []),
714
+ mode,
715
+ conflicts: [...(undecided ? [undecided] : []), ...skipped],
680
716
  // A withheld graph copies no task, so no remap is applied.
681
717
  remaps: graphWithheld ? [] : remaps,
682
718
  dryRun,
683
719
  projectRoot,
684
720
  liveStorePath,
685
721
  // The files read: the legacy originals, never the renumbered scratch copy.
686
- sourcePaths: [...legacyFiles, ...(bare ? [bare] : [])].map((s) => s.path),
722
+ sourcePaths: strandsMode
723
+ ? [liveStorePath]
724
+ : [...legacyFiles, ...(bare ? [bare] : [])].map((s) => s.path),
687
725
  after: [],
688
726
  rowsCopied: 0,
689
727
  rolledBack: 0,
@@ -691,14 +729,19 @@ async function reconcileWithScratch(projectRoot, dryRun, additive, liveStorePath
691
729
  receiptPath: null,
692
730
  };
693
731
  if (sources.length === 0 || !existsSync(liveStorePath)) {
694
- return {
732
+ const none = {
695
733
  ...base,
696
734
  outcome: 'nothing-to-reconcile',
697
735
  before: [],
698
- reason: sources.length === 0
699
- ? 'no legacy project store (tasks.db / brain.db / conduit.db, or bare task tables in an unmigrated cleo.db) is present'
700
- : `no live store at ${liveStorePath}; the legacy files are still the live data`,
736
+ reason: !existsSync(liveStorePath)
737
+ ? `no live store at ${liveStorePath}; the legacy files are still the live data`
738
+ : strandsMode
739
+ ? strands?.populated === false
740
+ ? 'tasks_tasks is empty: the bare family is copied whole by `cleo doctor superseded-store --reconcile`'
741
+ : `no stranded bare row is left to copy${skipped.length > 0 ? `; ${describeConflicts(skipped)}` : ''}`
742
+ : 'no legacy project store (tasks.db / brain.db / conduit.db, or bare task tables in an unmigrated cleo.db) is present',
701
743
  };
744
+ return strands?.populated ? settleStrands(none, strands, cleoDir, resolveTarget) : none;
702
745
  }
703
746
  const before = assessSupersededProjectStores(liveStorePath, sources, resolveTarget);
704
747
  const plannedConflicts = additive ? conflictsOf(before) : [];
@@ -706,7 +749,7 @@ async function reconcileWithScratch(projectRoot, dryRun, additive, liveStorePath
706
749
  const withheldOf = (counts) => conflictsOf(counts)
707
750
  .filter((c) => c.reason === 'live-authoritative')
708
751
  .map((c) => ({ ...c, reason: 'withheld-undecided' }));
709
- const withheldConflicts = (counts) => undecided ? [undecided, ...withheldOf(counts)] : [];
752
+ const withheldConflicts = (counts) => undecided ? [undecided, ...withheldOf(counts), ...skipped] : skipped;
710
753
  const withheldNote = (counts) => `; the task graph was NOT copied: ${undecided ? describeUndecided(undecided) : ''}. ` +
711
754
  `Withheld: ${describeConflicts(withheldOf(counts))}. Correct the legacy created_at of ` +
712
755
  `${(undecided?.ids ?? []).join(', ')} to an ISO-8601 time, then run the reconcile again`;
@@ -735,12 +778,15 @@ async function reconcileWithScratch(projectRoot, dryRun, additive, liveStorePath
735
778
  };
736
779
  }
737
780
  if (isComplete(before)) {
738
- return {
781
+ const none = {
739
782
  ...base,
740
783
  outcome: 'nothing-to-reconcile',
741
784
  before,
742
- reason: `${undecided ? 'every other legacy row' : 'every legacy row'} is already present in cleo.db — nothing to copy${graphWithheld ? (undecided ? `; ${describeUndecided(undecided)}` : '') : remapNote}`,
785
+ reason: `${undecided ? 'every other legacy row' : 'every legacy row'} is already present in cleo.db — nothing to copy${graphWithheld ? (undecided ? `; ${describeUndecided(undecided)}` : '') : remapNote}${skipped.length > 0 ? `; ${describeConflicts(skipped)}` : ''}`,
743
786
  };
787
+ return strands?.populated && !graphWithheld
788
+ ? settleStrands(none, strands, cleoDir, resolveTarget)
789
+ : none;
744
790
  }
745
791
  if (dryRun) {
746
792
  return {
@@ -756,19 +802,14 @@ async function reconcileWithScratch(projectRoot, dryRun, additive, liveStorePath
756
802
  ? `would copy the missing rows of the history tables; ${describeConflicts(plannedConflicts.filter((c) => c.reason === 'live-authoritative'))} (key collisions are counted after the copy)`
757
803
  : graphWithheld
758
804
  ? `would copy the missing history rows only${withheldNote(before)}`
759
- : `would copy the missing rows of: ${describeGaps(before)}${remapNote}`,
805
+ : `would copy the missing rows of: ${describeGaps(before)}${remapNote}${skipped.length > 0 ? `; would leave uncopied: ${describeConflicts(skipped)}` : ''}`,
760
806
  };
761
807
  }
762
- const iso = new Date()
763
- .toISOString()
764
- .replace(/[:]/g, '')
765
- .replace(/\..+Z$/, 'Z');
766
- const stagingDir = join(resolveCleoDir(projectRoot), `${RECONCILE_DIR_PREFIX}${iso}`);
767
- mkdirSync(stagingDir, { recursive: true });
808
+ const stagingDir = newRunDir(cleoDir);
768
809
  // Keep the materialised bare source and the renumbered tasks copy with the
769
810
  // run's other evidence.
770
811
  const copySources = sources.map((s) => {
771
- if (s !== bare && s.path !== remap.remappedPath)
812
+ if (s !== bare && s.path !== remap.remappedPath && s.name !== BARE_STRANDS_SOURCE_NAME)
772
813
  return s;
773
814
  const kept = join(stagingDir, basename(s.path));
774
815
  copyFileSync(s.path, kept);
@@ -792,7 +833,8 @@ async function reconcileWithScratch(projectRoot, dryRun, additive, liveStorePath
792
833
  const result = await withLock(lockPath, whileExodusRunHeld(lockPath, async () => {
793
834
  // Prove "never overwrites": every live row that existed before the copy
794
835
  // must still exist, byte for byte, afterwards.
795
- const liveBefore = join(scratch, 'live-before.db');
836
+ // Bare-strands mode keeps this snapshot with the receipt (T13309).
837
+ const liveBefore = join(strandsMode ? stagingDir : scratch, 'live-before.db');
796
838
  snapshotLive(liveStorePath, liveBefore);
797
839
  const copied = await runExodusMigrate(reconcilePlan, false, (msg) => log.debug(msg), {
798
840
  projectOnly: true,
@@ -832,17 +874,29 @@ async function reconcileWithScratch(projectRoot, dryRun, additive, liveStorePath
832
874
  // allocation and the copy: the copy skipped it, and verification by key
833
875
  // would call it present (review LOW-1).
834
876
  const unlanded = migrated.ok && !graphWithheld ? unlandedRemaps(liveStorePath, remap.remaps) : [];
877
+ // Bare-strands mode: every stranded row not listed as skipped is now in
878
+ // the live store (T13333), and no copied row refers to a task the live
879
+ // store lacks (T13334).
880
+ const strandsChecked = migrated.ok && strands && !graphWithheld;
881
+ const unaccounted = strandsChecked
882
+ ? unaccountedStrands(liveStorePath, strands, resolveTarget)
883
+ : [];
884
+ const dangling = strandsChecked && strands.source
885
+ ? (await import('./bare-family.js')).danglingStrandReferences(liveStorePath, strands.source.path)
886
+ : [];
835
887
  const verified = migrated.ok &&
836
888
  settled &&
837
889
  lost.length === 0 &&
838
890
  altered.length === 0 &&
839
- unlanded.length === 0;
891
+ unlanded.length === 0 &&
892
+ unaccounted.length === 0 &&
893
+ dangling.length === 0;
840
894
  // The store's own record of the bare tables this run carried (T13320),
841
895
  // written only once the copy verified, so a revert never leaves one.
842
896
  let unrecorded = null;
843
- if (verified && bare && !graphWithheld) {
897
+ if (verified && (bare || strands) && !graphWithheld) {
844
898
  try {
845
- await recordCarriedBareTables(liveStorePath, accounted, basename(stagingDir));
899
+ await recordCarriedBareTables(liveStorePath, strands ? strands.accounted : accounted, basename(stagingDir));
846
900
  }
847
901
  catch (error) {
848
902
  unrecorded = error instanceof Error ? error.message : String(error);
@@ -857,15 +911,32 @@ async function reconcileWithScratch(projectRoot, dryRun, additive, liveStorePath
857
911
  after,
858
912
  rowsCopied,
859
913
  stagingDir,
860
- ...(bare && !graphWithheld ? { accounted } : {}),
914
+ // A settled run accounts for each bare table it carried (full mode)
915
+ // or settled (bare-strands mode), so the sync refusal for stranded
916
+ // rows stands down for it (T13319, T13309).
917
+ ...(!graphWithheld && (strands || bare)
918
+ ? { accounted: strands ? strands.accounted : accounted }
919
+ : {}),
920
+ ...(strandsMode ? { snapshotPath: liveBefore } : {}),
861
921
  reason: additive
862
922
  ? `copied ${rowsCopied} row(s) with keys absent from live; live rows unchanged; ${describeConflicts(conflicts)}`
863
923
  : graphWithheld
864
924
  ? `copied ${rowsCopied} history row(s)${withheldNote(after)}`
865
- : `copied ${rowsCopied} row(s); every legacy row is now present in cleo.db${remapNote}`,
925
+ : strandsMode
926
+ ? `copied ${rowsCopied} stranded bare row(s); every other stranded row is accounted for${remapNote}${skipped.length > 0 ? `; ${describeConflicts(skipped)}` : ''}; live snapshot before the copy: ${liveBefore}`
927
+ : `copied ${rowsCopied} row(s); every legacy row is now present in cleo.db${remapNote}`,
866
928
  };
867
929
  }
868
- const rolledBack = await revertReconcile(liveStorePath, stagingDir);
930
+ // A revert that cannot run still leaves a refused receipt naming the
931
+ // rows it left, which --rollback reverts once the conflict is gone (T13384).
932
+ let rolledBack = 0;
933
+ let revertError = null;
934
+ try {
935
+ rolledBack = await revertReconcile(liveStorePath, stagingDir);
936
+ }
937
+ catch (error) {
938
+ revertError = error instanceof Error ? error.message : String(error);
939
+ }
869
940
  const cause = !migrated.ok
870
941
  ? `copy failed: ${migrated.error ?? `the copy engine gave no message — inspect the journal in ${stagingDir}`}`
871
942
  : lost.length > 0
@@ -874,9 +945,13 @@ async function reconcileWithScratch(projectRoot, dryRun, additive, liveStorePath
874
945
  ? `pre-existing live rows changed in: ${altered.join(', ')}`
875
946
  : unlanded.length > 0
876
947
  ? `a concurrent write took the id of a recovered task (${unlanded.join(', ')}); run the reconcile again`
877
- : unrecorded !== null
878
- ? `could not record the carried bare tables in the store: ${unrecorded}`
879
- : `rows still missing after copy: ${describeGaps(after)}`;
948
+ : unaccounted.length > 0
949
+ ? `stranded rows neither copied nor listed as skipped: ${describeGaps(unaccounted)}`
950
+ : dangling.length > 0
951
+ ? `copied rows refer to tasks cleo.db does not hold: ${dangling.join(', ')}`
952
+ : unrecorded !== null
953
+ ? `could not record the carried bare tables in the store: ${unrecorded}`
954
+ : `rows still missing after copy: ${describeGaps(after)}`;
880
955
  return {
881
956
  ...base,
882
957
  outcome: 'refused',
@@ -885,13 +960,11 @@ async function reconcileWithScratch(projectRoot, dryRun, additive, liveStorePath
885
960
  rowsCopied,
886
961
  rolledBack,
887
962
  stagingDir,
888
- reason: `${cause} — reverted the ${rolledBack} row(s) this run inserted; legacy files untouched`,
963
+ reason: revertError === null
964
+ ? `${cause} — reverted the ${rolledBack} row(s) this run inserted; legacy files untouched`
965
+ : `${cause} — the revert FAILED (${revertError}): the ${rowsCopied} row(s) this run inserted are still in cleo.db; once that is resolved, \`cleo doctor superseded-store --rollback ${basename(stagingDir)}\` reverts them; legacy files untouched`,
889
966
  };
890
967
  }), { stale: EXODUS_LOCK_STALE_MS, retries: 30, onCompromised: lock.onCompromised });
891
- const receiptPath = join(stagingDir, RECEIPT_FILENAME);
892
- const receipt = { ...result, receiptPath };
893
- writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, 'utf8');
894
- log.info({ outcome: receipt.outcome, rowsCopied: receipt.rowsCopied, receiptPath }, `exodus-reconcile: ${receipt.reason}`);
895
- return receipt;
968
+ return writeReceipt(result, stagingDir);
896
969
  }
897
970
  //# sourceMappingURL=reconcile.js.map