@remnic/core 9.3.652 → 9.3.654

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 (249) hide show
  1. package/dist/access-cli.js +17 -17
  2. package/dist/access-http.d.ts +4 -4
  3. package/dist/access-http.js +11 -11
  4. package/dist/access-mcp.d.ts +4 -4
  5. package/dist/access-mcp.js +10 -10
  6. package/dist/access-schema.d.ts +15 -12
  7. package/dist/access-schema.js +1 -1
  8. package/dist/{access-service-CdJFd3_b.d.ts → access-service-C8A5hoXJ.d.ts} +11 -2
  9. package/dist/access-service.d.ts +4 -4
  10. package/dist/access-service.js +8 -8
  11. package/dist/action-confidence.d.ts +1 -1
  12. package/dist/active-memory-bridge.d.ts +1 -1
  13. package/dist/active-recall.d.ts +1 -1
  14. package/dist/active-recall.js +1 -1
  15. package/dist/behavior-learner.d.ts +1 -1
  16. package/dist/behavior-signals.d.ts +1 -1
  17. package/dist/bootstrap.d.ts +3 -3
  18. package/dist/briefing.d.ts +1 -1
  19. package/dist/briefing.js +3 -3
  20. package/dist/buffer-surprise-report.d.ts +1 -1
  21. package/dist/buffer.d.ts +1 -1
  22. package/dist/calibration.d.ts +1 -1
  23. package/dist/causal-behavior.d.ts +1 -1
  24. package/dist/causal-consolidation.d.ts +1 -1
  25. package/dist/causal-consolidation.js +4 -4
  26. package/dist/{chunk-KJDKZVF3.js → chunk-2DSTAWNZ.js} +3 -3
  27. package/dist/chunk-3RACUBII.js +212 -0
  28. package/dist/chunk-3RACUBII.js.map +1 -0
  29. package/dist/{chunk-Y7NWBBHV.js → chunk-6CVI6BP6.js} +2 -2
  30. package/dist/{chunk-R3PQUPQ4.js → chunk-6IMKOIZ6.js} +85 -3
  31. package/dist/chunk-6IMKOIZ6.js.map +1 -0
  32. package/dist/{chunk-WTI35CVJ.js → chunk-BJA6DQOC.js} +5 -5
  33. package/dist/{chunk-GI45G4BK.js → chunk-BP2EV6W5.js} +3 -3
  34. package/dist/{chunk-WLGE6KEO.js → chunk-DBM2BD22.js} +3 -3
  35. package/dist/{chunk-IENGGY2C.js → chunk-ENV6RDTD.js} +2 -2
  36. package/dist/{chunk-BEMWL2FZ.js → chunk-FVRBLJP6.js} +2 -2
  37. package/dist/{chunk-H3PHZLMF.js → chunk-GKKAXVAJ.js} +20 -11
  38. package/dist/chunk-GKKAXVAJ.js.map +1 -0
  39. package/dist/{chunk-MGGNV3H2.js → chunk-GPW2E4LN.js} +23 -8
  40. package/dist/chunk-GPW2E4LN.js.map +1 -0
  41. package/dist/{chunk-KWM33SPU.js → chunk-JMQSYGXS.js} +2 -2
  42. package/dist/{chunk-WSFNYPAT.js → chunk-JYN7QNTA.js} +87 -18
  43. package/dist/chunk-JYN7QNTA.js.map +1 -0
  44. package/dist/{chunk-AJE7FJVE.js → chunk-K6X553JB.js} +2 -2
  45. package/dist/{chunk-5V3TAB7D.js → chunk-LJCEWTG3.js} +19 -8
  46. package/dist/{chunk-5V3TAB7D.js.map → chunk-LJCEWTG3.js.map} +1 -1
  47. package/dist/{chunk-YOVKPOMD.js → chunk-NAZWHTYV.js} +13 -6
  48. package/dist/chunk-NAZWHTYV.js.map +1 -0
  49. package/dist/{chunk-XMN6MMTU.js → chunk-NCGWXCSW.js} +2 -2
  50. package/dist/{chunk-C43KEWEV.js → chunk-NE2JBMLN.js} +1 -1
  51. package/dist/chunk-NE2JBMLN.js.map +1 -0
  52. package/dist/{chunk-TCX4WLKK.js → chunk-OL2364SB.js} +2020 -368
  53. package/dist/chunk-OL2364SB.js.map +1 -0
  54. package/dist/{chunk-JF7SFXTG.js → chunk-QKK64Z6M.js} +2 -2
  55. package/dist/{chunk-IVYSVAC6.js → chunk-QW6JZO5P.js} +2 -2
  56. package/dist/{chunk-EHQLDFSH.js → chunk-RGPUQ66K.js} +2 -2
  57. package/dist/{chunk-CFOCZPIQ.js → chunk-T2C6QJG2.js} +2 -2
  58. package/dist/{chunk-4HYSMH7D.js → chunk-UAU5U5ML.js} +3 -2
  59. package/dist/chunk-UAU5U5ML.js.map +1 -0
  60. package/dist/{chunk-V4UDXYGG.js → chunk-XWQ6ERUG.js} +2 -2
  61. package/dist/{chunk-IJHLC5CH.js → chunk-Y2RIIF6H.js} +32 -22
  62. package/dist/{chunk-IJHLC5CH.js.map → chunk-Y2RIIF6H.js.map} +1 -1
  63. package/dist/{chunk-C63WC454.js → chunk-YLZLPVKK.js} +22 -1
  64. package/dist/chunk-YLZLPVKK.js.map +1 -0
  65. package/dist/{chunk-RZOBQ23O.js → chunk-Z5MQI7K2.js} +2 -2
  66. package/dist/{chunk-PRQXUSQV.js → chunk-ZCORQM74.js} +2 -2
  67. package/dist/{cli-DDo7Qgs-.d.ts → cli-uQgvDFNE.d.ts} +3 -3
  68. package/dist/cli.d.ts +5 -5
  69. package/dist/cli.js +23 -23
  70. package/dist/compounding/engine.d.ts +1 -1
  71. package/dist/compounding/engine.js +3 -3
  72. package/dist/compounding/preference-consolidator.d.ts +1 -1
  73. package/dist/compression-optimizer.d.ts +1 -1
  74. package/dist/config.d.ts +1 -1
  75. package/dist/config.js +1 -1
  76. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  77. package/dist/connectors/codex-materialize-runner.js +3 -3
  78. package/dist/connectors/codex-materialize.d.ts +1 -1
  79. package/dist/connectors/index.d.ts +1 -1
  80. package/dist/connectors/index.js +3 -3
  81. package/dist/consolidation-provenance-check.d.ts +1 -1
  82. package/dist/consolidation-undo.d.ts +1 -1
  83. package/dist/contradiction/index.d.ts +19 -1
  84. package/dist/contradiction/index.js +1 -1
  85. package/dist/conversation-index/backend.d.ts +1 -1
  86. package/dist/conversation-index/chunker.d.ts +1 -1
  87. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  88. package/dist/conversation-index/indexer.d.ts +1 -1
  89. package/dist/conversation-index/search.d.ts +1 -1
  90. package/dist/day-summary.d.ts +1 -1
  91. package/dist/delinearize.d.ts +1 -1
  92. package/dist/direct-answer-wiring.d.ts +1 -1
  93. package/dist/direct-answer.d.ts +1 -1
  94. package/dist/embedding-fallback.d.ts +1 -1
  95. package/dist/enrichment/index.d.ts +1 -1
  96. package/dist/entity-retrieval.d.ts +1 -1
  97. package/dist/entity-retrieval.js +3 -3
  98. package/dist/entity-schema.d.ts +1 -1
  99. package/dist/explicit-capture.d.ts +3 -3
  100. package/dist/explicit-capture.js +1 -1
  101. package/dist/extraction-judge-telemetry.d.ts +1 -1
  102. package/dist/extraction-judge-training.d.ts +1 -1
  103. package/dist/extraction-judge.d.ts +1 -1
  104. package/dist/extraction.d.ts +1 -1
  105. package/dist/fallback-llm.d.ts +1 -1
  106. package/dist/identity-continuity.d.ts +1 -1
  107. package/dist/importance.d.ts +1 -1
  108. package/dist/index.d.ts +8 -8
  109. package/dist/index.js +31 -29
  110. package/dist/index.js.map +1 -1
  111. package/dist/intent.d.ts +1 -1
  112. package/dist/lcm/engine.d.ts +1 -1
  113. package/dist/lcm/index.d.ts +1 -1
  114. package/dist/lcm/tools.d.ts +1 -1
  115. package/dist/lifecycle.d.ts +1 -1
  116. package/dist/live-connectors-runner.d.ts +1 -1
  117. package/dist/local-llm.d.ts +1 -1
  118. package/dist/maintenance/memory-governance.d.ts +1 -1
  119. package/dist/maintenance/memory-governance.js +3 -3
  120. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -3
  121. package/dist/maintenance/rebuild-memory-projection.js +4 -4
  122. package/dist/mcp-memory-inspector-app.d.ts +4 -4
  123. package/dist/memory-action-policy.d.ts +1 -1
  124. package/dist/memory-cache.d.ts +1 -1
  125. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  126. package/dist/memory-projection-store.d.ts +1 -1
  127. package/dist/memory-provenance.d.ts +1 -1
  128. package/dist/memory-worth-outcomes.d.ts +1 -1
  129. package/dist/models-json.d.ts +1 -1
  130. package/dist/namespaces/migrate.d.ts +1 -1
  131. package/dist/namespaces/migrate.js +4 -4
  132. package/dist/namespaces/principal.d.ts +1 -1
  133. package/dist/namespaces/search.d.ts +1 -1
  134. package/dist/namespaces/storage.d.ts +52 -3
  135. package/dist/namespaces/storage.js +9 -5
  136. package/dist/native-knowledge.d.ts +1 -1
  137. package/dist/operator-toolkit.d.ts +1 -1
  138. package/dist/operator-toolkit.js +7 -7
  139. package/dist/{orchestrator-8fTZsa0y.d.ts → orchestrator-B4Y4sWQH.d.ts} +503 -3
  140. package/dist/orchestrator.d.ts +3 -3
  141. package/dist/orchestrator.js +13 -13
  142. package/dist/patterns-cli.d.ts +1 -1
  143. package/dist/policy-runtime.d.ts +1 -1
  144. package/dist/qmd-recall-cache.d.ts +1 -1
  145. package/dist/qmd.d.ts +1 -1
  146. package/dist/recall-disclosure-escalation.d.ts +1 -1
  147. package/dist/recall-explain-renderer.d.ts +1 -1
  148. package/dist/recall-explain-renderer.js +3 -3
  149. package/dist/recall-planner-llm.d.ts +1 -1
  150. package/dist/recall-state.d.ts +1 -1
  151. package/dist/recall-tag-filter.d.ts +1 -1
  152. package/dist/recall-xray-cli.d.ts +1 -1
  153. package/dist/recall-xray-cli.js +4 -4
  154. package/dist/recall-xray-renderer.d.ts +1 -1
  155. package/dist/recall-xray-renderer.js +3 -3
  156. package/dist/recall-xray.d.ts +1 -1
  157. package/dist/recall-xray.js +2 -2
  158. package/dist/{resolution-3SAP4SH2.js → resolution-IDTEBJFS.js} +2 -2
  159. package/dist/resolve-auth-token.d.ts +1 -1
  160. package/dist/resume-bundles.js +2 -2
  161. package/dist/retrieval-agents.d.ts +1 -1
  162. package/dist/retrieval-tiers.d.ts +1 -1
  163. package/dist/routing/engine.d.ts +1 -1
  164. package/dist/routing/store.d.ts +1 -1
  165. package/dist/search/embed-helper.d.ts +1 -1
  166. package/dist/search/factory.d.ts +1 -1
  167. package/dist/search/index.d.ts +1 -1
  168. package/dist/search/lancedb-backend.d.ts +1 -1
  169. package/dist/search/meilisearch-backend.d.ts +1 -1
  170. package/dist/search/noop-backend.d.ts +1 -1
  171. package/dist/search/orama-backend.d.ts +1 -1
  172. package/dist/search/port.d.ts +1 -1
  173. package/dist/search/remote-backend.d.ts +1 -1
  174. package/dist/{semantic-consolidation-DKdYzQOg.d.ts → semantic-consolidation-BKd0Pype.d.ts} +1 -1
  175. package/dist/semantic-consolidation.d.ts +2 -2
  176. package/dist/semantic-consolidation.js +4 -4
  177. package/dist/semantic-rule-promotion.js +3 -3
  178. package/dist/semantic-rule-verifier.d.ts +1 -1
  179. package/dist/semantic-rule-verifier.js +3 -3
  180. package/dist/session-observer-bands.d.ts +1 -1
  181. package/dist/session-observer-state.d.ts +1 -1
  182. package/dist/shared-context/manager.d.ts +1 -1
  183. package/dist/signal.d.ts +1 -1
  184. package/dist/storage.d.ts +1 -1
  185. package/dist/storage.js +2 -2
  186. package/dist/summarizer.d.ts +1 -1
  187. package/dist/summary-snapshot.d.ts +1 -1
  188. package/dist/temporal-supersession.d.ts +1 -1
  189. package/dist/temporal-validity.d.ts +1 -1
  190. package/dist/threading.d.ts +1 -1
  191. package/dist/tier-migration.d.ts +1 -1
  192. package/dist/tier-routing.d.ts +1 -1
  193. package/dist/topics.d.ts +1 -1
  194. package/dist/transcript.d.ts +1 -1
  195. package/dist/{types-D8yUmSik.d.ts → types-BgChEr0M.d.ts} +11 -0
  196. package/dist/types.d.ts +1 -1
  197. package/dist/types.js +1 -1
  198. package/dist/utility-runtime.d.ts +1 -1
  199. package/dist/verified-recall.js +3 -3
  200. package/package.json +1 -1
  201. package/src/access-http.ts +7 -0
  202. package/src/access-mcp.test.ts +70 -1
  203. package/src/access-mcp.ts +19 -2
  204. package/src/access-schema.ts +1 -0
  205. package/src/access-service.ts +12 -0
  206. package/src/briefing.test.ts +70 -0
  207. package/src/briefing.ts +30 -20
  208. package/src/cli.ts +104 -0
  209. package/src/config.test.ts +40 -0
  210. package/src/config.ts +29 -0
  211. package/src/contradiction/contradiction.test.ts +284 -0
  212. package/src/contradiction/resolution.ts +151 -4
  213. package/src/explicit-capture.ts +31 -10
  214. package/src/index.ts +10 -0
  215. package/src/namespaces/catalog.test.ts +3356 -0
  216. package/src/namespaces/catalog.ts +2123 -0
  217. package/src/namespaces/storage.ts +210 -30
  218. package/src/orchestrator-flush.test.ts +300 -0
  219. package/src/orchestrator.ts +851 -240
  220. package/src/types.ts +11 -0
  221. package/dist/chunk-4HYSMH7D.js.map +0 -1
  222. package/dist/chunk-C43KEWEV.js.map +0 -1
  223. package/dist/chunk-C63WC454.js.map +0 -1
  224. package/dist/chunk-H3PHZLMF.js.map +0 -1
  225. package/dist/chunk-MGGNV3H2.js.map +0 -1
  226. package/dist/chunk-ORGWWNJG.js +0 -131
  227. package/dist/chunk-ORGWWNJG.js.map +0 -1
  228. package/dist/chunk-R3PQUPQ4.js.map +0 -1
  229. package/dist/chunk-TCX4WLKK.js.map +0 -1
  230. package/dist/chunk-WSFNYPAT.js.map +0 -1
  231. package/dist/chunk-YOVKPOMD.js.map +0 -1
  232. /package/dist/{chunk-KJDKZVF3.js.map → chunk-2DSTAWNZ.js.map} +0 -0
  233. /package/dist/{chunk-Y7NWBBHV.js.map → chunk-6CVI6BP6.js.map} +0 -0
  234. /package/dist/{chunk-WTI35CVJ.js.map → chunk-BJA6DQOC.js.map} +0 -0
  235. /package/dist/{chunk-GI45G4BK.js.map → chunk-BP2EV6W5.js.map} +0 -0
  236. /package/dist/{chunk-WLGE6KEO.js.map → chunk-DBM2BD22.js.map} +0 -0
  237. /package/dist/{chunk-IENGGY2C.js.map → chunk-ENV6RDTD.js.map} +0 -0
  238. /package/dist/{chunk-BEMWL2FZ.js.map → chunk-FVRBLJP6.js.map} +0 -0
  239. /package/dist/{chunk-KWM33SPU.js.map → chunk-JMQSYGXS.js.map} +0 -0
  240. /package/dist/{chunk-AJE7FJVE.js.map → chunk-K6X553JB.js.map} +0 -0
  241. /package/dist/{chunk-XMN6MMTU.js.map → chunk-NCGWXCSW.js.map} +0 -0
  242. /package/dist/{chunk-JF7SFXTG.js.map → chunk-QKK64Z6M.js.map} +0 -0
  243. /package/dist/{chunk-IVYSVAC6.js.map → chunk-QW6JZO5P.js.map} +0 -0
  244. /package/dist/{chunk-EHQLDFSH.js.map → chunk-RGPUQ66K.js.map} +0 -0
  245. /package/dist/{chunk-CFOCZPIQ.js.map → chunk-T2C6QJG2.js.map} +0 -0
  246. /package/dist/{chunk-V4UDXYGG.js.map → chunk-XWQ6ERUG.js.map} +0 -0
  247. /package/dist/{chunk-RZOBQ23O.js.map → chunk-Z5MQI7K2.js.map} +0 -0
  248. /package/dist/{chunk-PRQXUSQV.js.map → chunk-ZCORQM74.js.map} +0 -0
  249. /package/dist/{resolution-3SAP4SH2.js.map → resolution-IDTEBJFS.js.map} +0 -0
@@ -91,6 +91,7 @@ function makeResolutionStorage(options: {
91
91
  failRollbackFor?: string;
92
92
  partialSupersedeBeforeFailureFor?: string;
93
93
  onSupersede?: (oldId: string, newId: string) => void;
94
+ dir?: string;
94
95
  } = {}) {
95
96
  const memories = new Map<string, MemoryFile>([
96
97
  ["mem-a-001", makeMemory("mem-a-001")],
@@ -111,6 +112,7 @@ function makeResolutionStorage(options: {
111
112
  supersedeCalls,
112
113
  frontmatterWrites,
113
114
  removedFactHashIds,
115
+ dir: options.dir ?? "/tmp/contradiction-namespace-storage",
114
116
  async getMemoryById(id: string) {
115
117
  const memory = memories.get(id);
116
118
  return memory ? cloneMemory(memory) : null;
@@ -1819,6 +1821,213 @@ test("executeResolution rolls back memory changes when pair resolution persisten
1819
1821
  }
1820
1822
  });
1821
1823
 
1824
+ // ── Catalog-write touch ordering (NH1dX, rule #25) ──────────────────────────────
1825
+ // The merge catalog touch (onMergedMemoryWritten) must fire ONLY after the
1826
+ // resolution durably commits past the rollback point. If resolvePair fails and
1827
+ // the merge rolls back, NO catalog write may be recorded for a write that did
1828
+ // not survive.
1829
+
1830
+ test("executeResolution merge records NO catalog write touch when resolution persistence fails and rolls back", async () => {
1831
+ const { dir, cleanup } = await makeTempDir();
1832
+ try {
1833
+ const written = writePair(dir, makePair({ namespace: "work" }));
1834
+ const pairFile = path.join(dir, ".review", "contradictions", `${written.pairId}.json`);
1835
+ // Force resolvePair to fail by removing the pair file once supersession runs,
1836
+ // mirroring the existing rollback test. This drives the rollback path.
1837
+ const storage = makeResolutionStorage({
1838
+ dir: "/tmp/work-namespace-storage",
1839
+ onSupersede: () => {
1840
+ fs.rmSync(pairFile, { force: true });
1841
+ },
1842
+ });
1843
+ const catalogTouches: Array<{ namespace: string | undefined; storageDir: string }> = [];
1844
+
1845
+ const result = await executeResolution(dir, storage, written.pairId, "merge", {
1846
+ mergedContent: "merged canonical fact",
1847
+ storageForNamespace: () => storage,
1848
+ onMergedMemoryWritten: (namespace, storageDir) => {
1849
+ catalogTouches.push({ namespace, storageDir });
1850
+ },
1851
+ });
1852
+
1853
+ assert.match(result.message, /Resolution persistence failed; rolled back memory changes/);
1854
+ assert.deepEqual(result.affectedIds, []);
1855
+ // The merged memory was created then cleaned up by the rollback, and the
1856
+ // resolution never persisted — so the catalog touch must NOT have fired.
1857
+ assert.deepEqual(
1858
+ catalogTouches,
1859
+ [],
1860
+ "no catalog write touch may be recorded for a rolled-back merge",
1861
+ );
1862
+ assert.equal(readPair(dir, written.pairId), null);
1863
+ } finally {
1864
+ await cleanup();
1865
+ }
1866
+ });
1867
+
1868
+ test("executeResolution merge records exactly one catalog write touch for the pair namespace on success", async () => {
1869
+ const { dir, cleanup } = await makeTempDir();
1870
+ try {
1871
+ const written = writePair(dir, makePair({ namespace: "work" }));
1872
+ const storage = makeResolutionStorage({ dir: "/tmp/work-namespace-storage" });
1873
+ const catalogTouches: Array<{ namespace: string | undefined; storageDir: string }> = [];
1874
+
1875
+ const result = await executeResolution(dir, storage, written.pairId, "merge", {
1876
+ mergedContent: "merged canonical fact",
1877
+ storageForNamespace: () => storage,
1878
+ onMergedMemoryWritten: (namespace, storageDir) => {
1879
+ catalogTouches.push({ namespace, storageDir });
1880
+ },
1881
+ });
1882
+
1883
+ assert.match(result.message, /Both memories superseded by merged/);
1884
+ assert.deepEqual(result.affectedIds, ["mem-a-001", "mem-b-002"]);
1885
+ assert.equal(readPair(dir, written.pairId)?.resolution, "merge");
1886
+ // Exactly one touch, carrying the pair namespace and the routed storage dir.
1887
+ assert.deepEqual(catalogTouches, [
1888
+ { namespace: "work", storageDir: "/tmp/work-namespace-storage" },
1889
+ ]);
1890
+ } finally {
1891
+ await cleanup();
1892
+ }
1893
+ });
1894
+
1895
+ // NH3X3: supersede-only resolutions (keep-a / keep-b) also mutate the namespace,
1896
+ // so they must record a catalog touch — post-commit, never on rollback.
1897
+
1898
+ test("executeResolution keep-a records exactly one catalog write touch on success", async () => {
1899
+ const { dir, cleanup } = await makeTempDir();
1900
+ try {
1901
+ const written = writePair(dir, makePair({ namespace: "work" }));
1902
+ const storage = makeResolutionStorage({ dir: "/tmp/work-namespace-storage" });
1903
+ const catalogTouches: Array<{ namespace: string | undefined; storageDir: string }> = [];
1904
+
1905
+ const result = await executeResolution(dir, storage, written.pairId, "keep-a", {
1906
+ storageForNamespace: () => storage,
1907
+ onMergedMemoryWritten: (namespace, storageDir) => {
1908
+ catalogTouches.push({ namespace, storageDir });
1909
+ },
1910
+ });
1911
+
1912
+ assert.deepEqual(result.affectedIds, ["mem-b-002"]);
1913
+ assert.equal(readPair(dir, written.pairId)?.resolution, "keep-a");
1914
+ assert.deepEqual(catalogTouches, [
1915
+ { namespace: "work", storageDir: "/tmp/work-namespace-storage" },
1916
+ ]);
1917
+ } finally {
1918
+ await cleanup();
1919
+ }
1920
+ });
1921
+
1922
+ test("executeResolution keep-b records exactly one catalog write touch on success", async () => {
1923
+ const { dir, cleanup } = await makeTempDir();
1924
+ try {
1925
+ const written = writePair(dir, makePair({ namespace: "work" }));
1926
+ const storage = makeResolutionStorage({ dir: "/tmp/work-namespace-storage" });
1927
+ const catalogTouches: Array<{ namespace: string | undefined; storageDir: string }> = [];
1928
+
1929
+ const result = await executeResolution(dir, storage, written.pairId, "keep-b", {
1930
+ storageForNamespace: () => storage,
1931
+ onMergedMemoryWritten: (namespace, storageDir) => {
1932
+ catalogTouches.push({ namespace, storageDir });
1933
+ },
1934
+ });
1935
+
1936
+ assert.deepEqual(result.affectedIds, ["mem-a-001"]);
1937
+ assert.equal(readPair(dir, written.pairId)?.resolution, "keep-b");
1938
+ assert.deepEqual(catalogTouches, [
1939
+ { namespace: "work", storageDir: "/tmp/work-namespace-storage" },
1940
+ ]);
1941
+ } finally {
1942
+ await cleanup();
1943
+ }
1944
+ });
1945
+
1946
+ test("executeResolution keep-a records NO catalog write touch when resolution persistence fails and rolls back", async () => {
1947
+ const { dir, cleanup } = await makeTempDir();
1948
+ try {
1949
+ const written = writePair(dir, makePair({ namespace: "work" }));
1950
+ const pairFile = path.join(dir, ".review", "contradictions", `${written.pairId}.json`);
1951
+ const storage = makeResolutionStorage({
1952
+ dir: "/tmp/work-namespace-storage",
1953
+ onSupersede: () => {
1954
+ fs.rmSync(pairFile, { force: true });
1955
+ },
1956
+ });
1957
+ const catalogTouches: Array<{ namespace: string | undefined; storageDir: string }> = [];
1958
+
1959
+ const result = await executeResolution(dir, storage, written.pairId, "keep-a", {
1960
+ storageForNamespace: () => storage,
1961
+ onMergedMemoryWritten: (namespace, storageDir) => {
1962
+ catalogTouches.push({ namespace, storageDir });
1963
+ },
1964
+ });
1965
+
1966
+ assert.match(result.message, /Resolution persistence failed; rolled back memory changes/);
1967
+ assert.deepEqual(result.affectedIds, []);
1968
+ assert.deepEqual(catalogTouches, [], "a rolled-back keep-a must not record a catalog write touch");
1969
+ assert.equal(readPair(dir, written.pairId), null);
1970
+ } finally {
1971
+ await cleanup();
1972
+ }
1973
+ });
1974
+
1975
+ test("executeResolution keep-a records a catalog write touch when resolution persistence fails and rollback is incomplete", async () => {
1976
+ const { dir, cleanup } = await makeTempDir();
1977
+ try {
1978
+ const written = writePair(dir, makePair({ namespace: "work" }));
1979
+ const pairFile = path.join(dir, ".review", "contradictions", `${written.pairId}.json`);
1980
+ const storage = makeResolutionStorage({
1981
+ dir: "/tmp/work-namespace-storage",
1982
+ failRollbackFor: "mem-b-002",
1983
+ onSupersede: () => {
1984
+ fs.rmSync(pairFile, { force: true });
1985
+ },
1986
+ });
1987
+ const catalogTouches: Array<{ namespace: string | undefined; storageDir: string }> = [];
1988
+
1989
+ const result = await executeResolution(dir, storage, written.pairId, "keep-a", {
1990
+ storageForNamespace: () => storage,
1991
+ onMergedMemoryWritten: (namespace, storageDir) => {
1992
+ catalogTouches.push({ namespace, storageDir });
1993
+ },
1994
+ });
1995
+
1996
+ assert.match(result.message, /Resolution persistence failed; rollback incomplete/);
1997
+ assert.equal(storage.memories.get("mem-b-002")?.frontmatter.status, "superseded");
1998
+ assert.equal(storage.memories.get("mem-b-002")?.frontmatter.supersededBy, "mem-a-001");
1999
+ assert.deepEqual(catalogTouches, [
2000
+ { namespace: "work", storageDir: "/tmp/work-namespace-storage" },
2001
+ ]);
2002
+ assert.equal(readPair(dir, written.pairId), null);
2003
+ } finally {
2004
+ await cleanup();
2005
+ }
2006
+ });
2007
+
2008
+ // Non-mutating verbs never touch the catalog (no namespace memory changed).
2009
+ test("executeResolution both-valid records no catalog write touch", async () => {
2010
+ const { dir, cleanup } = await makeTempDir();
2011
+ try {
2012
+ const written = writePair(dir, makePair({ namespace: "work" }));
2013
+ const storage = makeResolutionStorage({ dir: "/tmp/work-namespace-storage" });
2014
+ const catalogTouches: Array<{ namespace: string | undefined; storageDir: string }> = [];
2015
+
2016
+ const result = await executeResolution(dir, storage, written.pairId, "both-valid", {
2017
+ storageForNamespace: () => storage,
2018
+ onMergedMemoryWritten: (namespace, storageDir) => {
2019
+ catalogTouches.push({ namespace, storageDir });
2020
+ },
2021
+ });
2022
+
2023
+ assert.deepEqual(result.affectedIds, []);
2024
+ assert.equal(readPair(dir, written.pairId)?.resolution, "both-valid");
2025
+ assert.deepEqual(catalogTouches, [], "both-valid mutates no namespace memory — no catalog touch");
2026
+ } finally {
2027
+ await cleanup();
2028
+ }
2029
+ });
2030
+
1822
2031
  test("executeResolution merge rolls back the first supersession when the second fails", async () => {
1823
2032
  const { dir, cleanup } = await makeTempDir();
1824
2033
  try {
@@ -1948,9 +2157,13 @@ test("executeResolution merge keeps created replacement when rollback fails", as
1948
2157
  failSupersedeFor: "mem-b-002",
1949
2158
  failRollbackFor: "mem-a-001",
1950
2159
  });
2160
+ const catalogTouches: Array<{ namespace: string | undefined; storageDir: string }> = [];
1951
2161
 
1952
2162
  const result = await executeResolution(dir, storage, written.pairId, "merge", {
1953
2163
  mergedContent: "merged canonical fact",
2164
+ onMergedMemoryWritten: (namespace, storageDir) => {
2165
+ catalogTouches.push({ namespace, storageDir });
2166
+ },
1954
2167
  });
1955
2168
 
1956
2169
  const mergedId = storage.supersedeCalls[0]?.newId;
@@ -1960,6 +2173,9 @@ test("executeResolution merge keeps created replacement when rollback fails", as
1960
2173
  assert.equal(storage.memories.get("mem-a-001")?.frontmatter.supersededBy, mergedId);
1961
2174
  assert.equal(storage.memories.get(mergedId)?.content, "merged canonical fact");
1962
2175
  assert.deepEqual(storage.removedFactHashIds, []);
2176
+ assert.deepEqual(catalogTouches, [
2177
+ { namespace: undefined, storageDir: "/tmp/contradiction-namespace-storage" },
2178
+ ]);
1963
2179
  assert.equal(readPair(dir, written.pairId)?.resolution, undefined);
1964
2180
  } finally {
1965
2181
  await cleanup();
@@ -2489,3 +2705,71 @@ test("readPair returns null for non-object JSON", async () => {
2489
2705
  await cleanup();
2490
2706
  }
2491
2707
  });
2708
+
2709
+ // ── issue #1499 sweep: a contradiction merge that CREATES a new merged memory
2710
+ // writes durable data to the pair's (possibly dynamic) namespace storage,
2711
+ // bypassing the extraction write path. executeResolution must invoke
2712
+ // onMergedMemoryWritten(namespace, storageDir) so the caller records the catalog
2713
+ // write — otherwise a dynamic namespace whose only durable mutation is a merge
2714
+ // stays invisible to QMD maintenance / writtenSince.
2715
+ test("executeResolution merge fires onMergedMemoryWritten with the pair namespace when a new memory is created", async () => {
2716
+ const { dir, cleanup } = await makeTempDir();
2717
+ try {
2718
+ const written = writePair(dir, makePair({ namespace: "project-origin-dynamic" }));
2719
+ const storage = makeResolutionStorage();
2720
+ (storage as { dir?: string }).dir = "/memory/namespaces/project-origin-dynamic-token";
2721
+
2722
+ const touches: Array<{ namespace?: string; storageDir: string }> = [];
2723
+
2724
+ const result = await executeResolution(dir, storage, written.pairId, "merge", {
2725
+ mergedContent: "merged canonical fact for dynamic namespace",
2726
+ storageForNamespace: () => storage,
2727
+ onMergedMemoryWritten: (namespace, storageDir) => {
2728
+ touches.push({ namespace, storageDir });
2729
+ },
2730
+ });
2731
+
2732
+ assert.deepEqual(result.affectedIds, ["mem-a-001", "mem-b-002"]);
2733
+ assert.equal(touches.length, 1, "exactly one catalog write touch for a created merge memory");
2734
+ assert.equal(
2735
+ touches[0]!.namespace,
2736
+ "project-origin-dynamic",
2737
+ "the catalog touch carries the pair's (dynamic) namespace",
2738
+ );
2739
+ assert.equal(
2740
+ touches[0]!.storageDir,
2741
+ "/memory/namespaces/project-origin-dynamic-token",
2742
+ "the catalog touch carries the resolved namespace storage dir",
2743
+ );
2744
+ } finally {
2745
+ await cleanup();
2746
+ }
2747
+ });
2748
+
2749
+ // Reusing an EXISTING merged memory id still supersedes BOTH sources — a durable
2750
+ // namespace mutation — so the catalog touch MUST fire so `lastWriteAt` refreshes
2751
+ // (NH3X3). It fires exactly once, post-commit.
2752
+ test("executeResolution merge fires onMergedMemoryWritten exactly once when reusing an existing merged id", async () => {
2753
+ const { dir, cleanup } = await makeTempDir();
2754
+ try {
2755
+ const written = writePair(dir, makePair({ namespace: "project-origin-dynamic" }));
2756
+ const storage = makeResolutionStorage();
2757
+ (storage as { dir?: string }).dir = "/memory/namespaces/project-origin-dynamic-token";
2758
+
2759
+ let touchCount = 0;
2760
+
2761
+ const result = await executeResolution(dir, storage, written.pairId, "merge", {
2762
+ mergedMemoryId: "mem-merged-003", // pre-existing merged memory, sources still superseded
2763
+ storageForNamespace: () => storage,
2764
+ onMergedMemoryWritten: () => {
2765
+ touchCount += 1;
2766
+ },
2767
+ });
2768
+
2769
+ assert.deepEqual(result.affectedIds, ["mem-a-001", "mem-b-002"]);
2770
+ assert.equal(touchCount, 1, "reusing an existing merged memory still supersedes both sources — record one touch");
2771
+ assert.equal(readPair(dir, written.pairId)?.resolution, "merge");
2772
+ } finally {
2773
+ await cleanup();
2774
+ }
2775
+ });
@@ -5,11 +5,11 @@
5
5
  * reimplement supersession logic here (rule 22: deduplicate resolution).
6
6
  */
7
7
 
8
+ import { log } from "../logger.js";
8
9
  import type { StorageManager } from "../storage.js";
9
10
  import type { MemoryCategory, MemoryFile } from "../types.js";
10
11
  import type { ResolutionVerb } from "./contradiction-review.js";
11
- import { resolvePair, readPair } from "./contradiction-review.js";
12
- import { log } from "../logger.js";
12
+ import { readPair, resolvePair } from "./contradiction-review.js";
13
13
 
14
14
  export interface ResolutionResult {
15
15
  pairId: string;
@@ -29,6 +29,24 @@ export interface ExecuteResolutionOptions {
29
29
  mergedCategory?: MemoryCategory;
30
30
  /** Resolve storage for the pair namespace, or the default namespace for legacy unscoped pairs. */
31
31
  storageForNamespace?: (namespace: string | undefined) => StorageManager | Promise<StorageManager>;
32
+ /**
33
+ * Best-effort hook invoked after a contradiction resolution leaves a durable
34
+ * mutation in the namespace's memory files (issue #1499 sweep, NH1dX / NH3X3).
35
+ * Every mutating verb — `merge` (creates a new memory and supersedes both
36
+ * sources), `keep-a`, and `keep-b` (supersede the losing source + rewrite
37
+ * frontmatter) — writes directly to the pair's (possibly DYNAMIC) namespace
38
+ * storage, bypassing the extraction write path that records catalog writes. So
39
+ * without this the namespace's `lastWriteAt` stays stale and QMD maintenance /
40
+ * `writtenSince` can skip a namespace whose only post-write mutation is
41
+ * resolving a contradiction. It fires after the resolution commits, or after a
42
+ * failed resolution/rollback path when durable memory changes are still left on
43
+ * disk. If a failure rolls back cleanly, this is never called, so the catalog
44
+ * never records a write that did not survive (rule #25). Non-mutating verbs
45
+ * (`both-valid`, `needs-more-context`) never trigger it. Callers wire this to
46
+ * `Orchestrator.recordCatalogWrite(namespace, storageDir)`. Must be
47
+ * failure-tolerant: it is fire-and-forget and must never affect resolution.
48
+ */
49
+ onMergedMemoryWritten?: (namespace: string | undefined, storageDir: string) => void;
32
50
  }
33
51
 
34
52
  const VALID_VERBS: ResolutionVerb[] = ["keep-a", "keep-b", "merge", "both-valid", "needs-more-context"];
@@ -81,6 +99,48 @@ export async function executeResolution(
81
99
  let message = "";
82
100
  let supersedeFailed = false;
83
101
  let rollbackAfterResolveFailure: (() => Promise<boolean>) | null = null;
102
+ // Deferred catalog-write touch for any resolution that leaves durable namespace
103
+ // memory mutations (issue #1499 sweep, NH1dX / NH3X3). Rule #25: never record a
104
+ // catalog touch for a write that is fully rolled back. Successful mutating
105
+ // resolutions invoke it after `resolvePair` persists. Failed paths invoke it
106
+ // only when rollback inspection shows the namespace still differs from the
107
+ // pre-mutation snapshot.
108
+ let recordCatalogWriteTouch: (() => void) | null = null;
109
+ // Returns the deferred touch fn for a mutating verb (or null when the caller
110
+ // wired no catalog hook), so each branch assigns `recordCatalogWriteTouch`
111
+ // directly in the function body — keeping TS control-flow narrowing intact at
112
+ // the post-commit invocation below.
113
+ const buildCatalogTouch = (): (() => void) | null => {
114
+ if (!options.onMergedMemoryWritten) return null;
115
+ const onMergedMemoryWritten = options.onMergedMemoryWritten;
116
+ const namespace = pair.namespace;
117
+ const storageDir = resolutionStorage.dir;
118
+ return () => onMergedMemoryWritten(namespace, storageDir);
119
+ };
120
+ const catalogWriteTouch = buildCatalogTouch();
121
+ const recordCatalogWriteTouchSafely = (context: string, touch = catalogWriteTouch): void => {
122
+ if (!touch) return;
123
+ try {
124
+ touch();
125
+ } catch (err) {
126
+ log.warn(
127
+ "[contradiction-resolution] catalog write touch failed for pair=%s context=%s: %s",
128
+ pairId,
129
+ context,
130
+ err instanceof Error ? err.message : err,
131
+ );
132
+ }
133
+ };
134
+ const touchCatalogIfRollbackLeftChange = async (
135
+ context: string,
136
+ snapshots: MemoryFile[],
137
+ replacement?: Extract<MergeReplacement, { ok: true }>,
138
+ ): Promise<void> => {
139
+ if (!catalogWriteTouch) return;
140
+ if (await rollbackLeftDurableMutation(resolutionStorage, snapshots, replacement)) {
141
+ recordCatalogWriteTouchSafely(context);
142
+ }
143
+ };
84
144
 
85
145
  switch (verb) {
86
146
  case "keep-a": {
@@ -98,6 +158,9 @@ export async function executeResolution(
98
158
  affectedIds.push(idB);
99
159
  rollbackAfterResolveFailure = async () =>
100
160
  restoreMemorySnapshot(resolutionStorage, sourceB!, "contradiction-resolution:keep-a-rollback");
161
+ // keep-a superseded idB in this namespace — record a catalog touch once
162
+ // the resolution durably commits (NH3X3).
163
+ recordCatalogWriteTouch = catalogWriteTouch;
101
164
  message = `Kept ${idA}, superseded ${idB}`;
102
165
  }
103
166
  else {
@@ -105,6 +168,9 @@ export async function executeResolution(
105
168
  const rolledBack = sourceB
106
169
  ? await restoreMemorySnapshot(resolutionStorage, sourceB, "contradiction-resolution:keep-a-rollback")
107
170
  : false;
171
+ if (sourceB && !rolledBack) {
172
+ await touchCatalogIfRollbackLeftChange("keep-a-rollback-incomplete", [sourceB]);
173
+ }
108
174
  message = rolledBack
109
175
  ? `Supersede failed for ${idB}; restored ${idB} and did not resolve`
110
176
  : `Supersede failed for ${idB}; rollback incomplete for ${idB} and pair is not resolved`;
@@ -126,6 +192,9 @@ export async function executeResolution(
126
192
  affectedIds.push(idA);
127
193
  rollbackAfterResolveFailure = async () =>
128
194
  restoreMemorySnapshot(resolutionStorage, sourceA!, "contradiction-resolution:keep-b-rollback");
195
+ // keep-b superseded idA in this namespace — record a catalog touch once
196
+ // the resolution durably commits (NH3X3).
197
+ recordCatalogWriteTouch = catalogWriteTouch;
129
198
  message = `Kept ${idB}, superseded ${idA}`;
130
199
  }
131
200
  else {
@@ -133,6 +202,9 @@ export async function executeResolution(
133
202
  const rolledBack = sourceA
134
203
  ? await restoreMemorySnapshot(resolutionStorage, sourceA, "contradiction-resolution:keep-b-rollback")
135
204
  : false;
205
+ if (sourceA && !rolledBack) {
206
+ await touchCatalogIfRollbackLeftChange("keep-b-rollback-incomplete", [sourceA]);
207
+ }
136
208
  message = rolledBack
137
209
  ? `Supersede failed for ${idA}; restored ${idA} and did not resolve`
138
210
  : `Supersede failed for ${idA}; rollback incomplete for ${idA} and pair is not resolved`;
@@ -157,6 +229,9 @@ export async function executeResolution(
157
229
  if (rolledBackA) {
158
230
  await cleanupCreatedReplacement(resolutionStorage, replacement);
159
231
  }
232
+ else {
233
+ await touchCatalogIfRollbackLeftChange("merge-first-rollback-incomplete", [replacement.sourceA], replacement);
234
+ }
160
235
  break;
161
236
  }
162
237
 
@@ -174,6 +249,13 @@ export async function executeResolution(
174
249
  if (rolledBackA && rolledBackB) {
175
250
  await cleanupCreatedReplacement(resolutionStorage, replacement);
176
251
  }
252
+ else {
253
+ await touchCatalogIfRollbackLeftChange(
254
+ "merge-second-rollback-incomplete",
255
+ [replacement.sourceA, replacement.sourceB],
256
+ replacement,
257
+ );
258
+ }
177
259
  break;
178
260
  }
179
261
 
@@ -186,6 +268,17 @@ export async function executeResolution(
186
268
  }
187
269
  return rolledBackA && rolledBackB;
188
270
  };
271
+ // Catalog write touch (issue #1499 sweep): the merge supersedes BOTH sources
272
+ // (and, when created, writes a fresh merged memory) in the pair's (possibly
273
+ // dynamic) namespace storage — but the resolution is not durable yet
274
+ // (resolvePair persists below, and a failure rolls the merge back). Defer
275
+ // the touch so it fires ONLY after the resolution commits past the rollback
276
+ // point (NH1dX, rule #25). Arm it for EVERY successful merge — even reusing
277
+ // an existing merged-id still supersedes both sources, a namespace mutation
278
+ // that must refresh `lastWriteAt` (NH3X3). Otherwise a dynamic namespace
279
+ // whose only durable mutation is a contradiction merge stays invisible to
280
+ // QMD maintenance / `writtenSince`. Best-effort on the caller side.
281
+ recordCatalogWriteTouch = catalogWriteTouch;
189
282
  message = `Both memories superseded by merged ${replacement.mergedId}`;
190
283
  break;
191
284
  }
@@ -217,10 +310,20 @@ export async function executeResolution(
217
310
  affectedIds.length = 0;
218
311
  message = rolledBack
219
312
  ? `Resolution persistence failed; rolled back memory changes and did not resolve ${pairId}`
220
- : `Resolution persistence failed; rollback incomplete and pair is not resolved`;
313
+ : "Resolution persistence failed; rollback incomplete and pair is not resolved";
314
+ if (!rolledBack && recordCatalogWriteTouch) {
315
+ recordCatalogWriteTouchSafely("resolve-persistence-rollback-incomplete", recordCatalogWriteTouch);
316
+ }
221
317
  } else {
222
- message = `Resolution persistence failed; pair is not resolved`;
318
+ message = "Resolution persistence failed; pair is not resolved";
223
319
  }
320
+ } else if (recordCatalogWriteTouch) {
321
+ // The resolution durably committed (memory mutated AND the resolution
322
+ // persisted past the rollback point). Only now is it safe to record the
323
+ // catalog write for the namespace mutation (NH1dX / NH3X3, rule #25).
324
+ // Best-effort: the caller's callback swallows errors; guard here so a
325
+ // throwing callback never derails a successful resolution.
326
+ recordCatalogWriteTouchSafely("resolved", recordCatalogWriteTouch);
224
327
  }
225
328
  }
226
329
  log.info("[contradiction-resolution] pair=%s verb=%s affected=%d", pairId, verb, affectedIds.length);
@@ -376,6 +479,50 @@ async function restoreMemorySnapshot(
376
479
  }
377
480
  }
378
481
 
482
+ async function rollbackLeftDurableMutation(
483
+ storage: StorageManager,
484
+ snapshots: MemoryFile[],
485
+ replacement?: Extract<MergeReplacement, { ok: true }>,
486
+ ): Promise<boolean> {
487
+ for (const snapshot of snapshots) {
488
+ try {
489
+ const current = await storage.getMemoryById(snapshot.frontmatter.id);
490
+ if (!current) return true;
491
+ if (supersessionStateChanged(current, snapshot)) return true;
492
+ } catch (err) {
493
+ log.warn(
494
+ "[contradiction-resolution] rollback inspection failed for %s: %s",
495
+ snapshot.frontmatter.id,
496
+ err instanceof Error ? err.message : err,
497
+ );
498
+ return true;
499
+ }
500
+ }
501
+
502
+ if (replacement?.created) {
503
+ try {
504
+ return (await storage.getMemoryById(replacement.mergedId)) !== null;
505
+ } catch (err) {
506
+ log.warn(
507
+ "[contradiction-resolution] rollback replacement inspection failed for %s: %s",
508
+ replacement.mergedId,
509
+ err instanceof Error ? err.message : err,
510
+ );
511
+ return true;
512
+ }
513
+ }
514
+
515
+ return false;
516
+ }
517
+
518
+ function supersessionStateChanged(current: MemoryFile, snapshot: MemoryFile): boolean {
519
+ return (
520
+ current.frontmatter.status !== snapshot.frontmatter.status ||
521
+ current.frontmatter.supersededBy !== snapshot.frontmatter.supersededBy ||
522
+ current.frontmatter.supersededAt !== snapshot.frontmatter.supersededAt
523
+ );
524
+ }
525
+
379
526
  async function cleanupCreatedReplacement(storage: StorageManager, replacement: Extract<MergeReplacement, { ok: true }>): Promise<void> {
380
527
  if (!replacement.created) return;
381
528
  await cleanupMemoryId(storage, replacement.mergedId);
@@ -435,6 +435,15 @@ export async function persistExplicitCapture(
435
435
  expiresAt: candidate.expiresAt,
436
436
  source: source === "inline" ? "explicit-inline" : "explicit",
437
437
  });
438
+ // Record the catalog write touch (issue #1499, round 5 codex P2). Explicit
439
+ // captures bypass the extraction write path, so without this their namespace
440
+ // never updates `lastWriteAt`. An undefined namespace means the DEFAULT root
441
+ // (round 6, codex P2), which recordCatalogWrite resolves. The method is an
442
+ // optional best-effort hook — guard so Orchestrator-like callers without it
443
+ // don't break (rule #33). Best-effort and failure-tolerant.
444
+ if (typeof orchestrator.recordCatalogWrite === "function") {
445
+ orchestrator.recordCatalogWrite(resolvedNamespace, storage.dir);
446
+ }
438
447
 
439
448
  const created = new Date().toISOString();
440
449
  const event: MemoryLifecycleEvent = {
@@ -531,16 +540,28 @@ export async function queueExplicitCaptureForReview(
531
540
  entityRef: sanitizeReviewMetadata(input.entityRef),
532
541
  source: source === "inline" ? "explicit-inline-review" : "explicit-review",
533
542
  });
534
- const created = await storage.getMemoryById(id);
535
- if (created) {
536
- await storage.writeMemoryFrontmatter(created, {
537
- status: "pending_review",
538
- updated: new Date().toISOString(),
539
- }, {
540
- actor: explicitCaptureActor(source),
541
- reasonCode: reason,
542
- ruleVersion: "explicit-capture.v1",
543
- });
543
+ try {
544
+ const created = await storage.getMemoryById(id);
545
+ if (created) {
546
+ await storage.writeMemoryFrontmatter(created, {
547
+ status: "pending_review",
548
+ updated: new Date().toISOString(),
549
+ }, {
550
+ actor: explicitCaptureActor(source),
551
+ reasonCode: reason,
552
+ ruleVersion: "explicit-capture.v1",
553
+ });
554
+ }
555
+ } finally {
556
+ // Record the catalog write touch (issue #1499, round 5/6 codex P2; NIhUg).
557
+ // A queued review capture writes memory to the namespace's root (the DEFAULT
558
+ // root when undefined), so its `lastWriteAt` must reflect the write once
559
+ // `writeMemory` returns an id. If the later pending-review frontmatter update
560
+ // fails, the memory file is still durable and must not disappear from
561
+ // writtenSince/maintenance scheduling. Guarded optional hook (rule #33).
562
+ if (typeof orchestrator.recordCatalogWrite === "function") {
563
+ orchestrator.recordCatalogWrite(queueNamespace, storage.dir);
564
+ }
544
565
  }
545
566
  const event: MemoryLifecycleEvent = {
546
567
  eventId: `mle-${randomUUID()}`,
package/src/index.ts CHANGED
@@ -323,6 +323,16 @@ export { MeilisearchBackend } from "./search/meilisearch-backend.js";
323
323
 
324
324
  export { buildEntityRecallSection } from "./entity-retrieval.js";
325
325
  export { resolvePrincipal } from "./namespaces/principal.js";
326
+ export {
327
+ NamespaceCatalog,
328
+ type NamespaceRecord,
329
+ type NamespaceKind,
330
+ type NamespaceDiscoverySource,
331
+ type NamespaceCatalogFilter,
332
+ type NamespaceTouchMetadata,
333
+ type NamespaceCatalogRebuildResult,
334
+ type NamespaceCatalogSkippedRoot,
335
+ } from "./namespaces/catalog.js";
326
336
 
327
337
  // ---------------------------------------------------------------------------
328
338
  // Session identity / transcript pathing (issue #1496)