@ijfw/memory-server 1.4.4 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/bin/ijfw-memorize +14 -7
  2. package/fixtures/team/book.json +6 -6
  3. package/fixtures/team/business.json +146 -20
  4. package/fixtures/team/content.json +6 -6
  5. package/fixtures/team/design.json +148 -20
  6. package/fixtures/team/mixed.json +206 -27
  7. package/fixtures/team/research.json +146 -20
  8. package/fixtures/team/software.json +148 -20
  9. package/fixtures/truncation-corpus/_generate-corpus.js +367 -0
  10. package/fixtures/truncation-corpus/fx-01-clean-exit-01/events.jsonl +2 -0
  11. package/fixtures/truncation-corpus/fx-01-clean-exit-01/intent-journal.jsonl +2 -0
  12. package/fixtures/truncation-corpus/fx-01-clean-exit-01/meta.json +18 -0
  13. package/fixtures/truncation-corpus/fx-01-clean-exit-01/target/.ijfw/state/workflow.json +1 -0
  14. package/fixtures/truncation-corpus/fx-01-clean-exit-02/events.jsonl +2 -0
  15. package/fixtures/truncation-corpus/fx-01-clean-exit-02/intent-journal.jsonl +2 -0
  16. package/fixtures/truncation-corpus/fx-01-clean-exit-02/meta.json +18 -0
  17. package/fixtures/truncation-corpus/fx-01-clean-exit-02/target/.ijfw/state/workflow.json +1 -0
  18. package/fixtures/truncation-corpus/fx-01-clean-exit-03/events.jsonl +2 -0
  19. package/fixtures/truncation-corpus/fx-01-clean-exit-03/intent-journal.jsonl +2 -0
  20. package/fixtures/truncation-corpus/fx-01-clean-exit-03/meta.json +18 -0
  21. package/fixtures/truncation-corpus/fx-01-clean-exit-03/target/.ijfw/state/workflow.json +1 -0
  22. package/fixtures/truncation-corpus/fx-01-clean-exit-04/events.jsonl +2 -0
  23. package/fixtures/truncation-corpus/fx-01-clean-exit-04/intent-journal.jsonl +2 -0
  24. package/fixtures/truncation-corpus/fx-01-clean-exit-04/meta.json +18 -0
  25. package/fixtures/truncation-corpus/fx-01-clean-exit-04/target/.ijfw/state/workflow.json +1 -0
  26. package/fixtures/truncation-corpus/fx-01-clean-exit-05/events.jsonl +2 -0
  27. package/fixtures/truncation-corpus/fx-01-clean-exit-05/intent-journal.jsonl +2 -0
  28. package/fixtures/truncation-corpus/fx-01-clean-exit-05/meta.json +18 -0
  29. package/fixtures/truncation-corpus/fx-01-clean-exit-05/target/.ijfw/state/workflow.json +1 -0
  30. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/events.jsonl +1 -0
  31. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/intent-journal.jsonl +3 -0
  32. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/meta.json +18 -0
  33. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/snapshots/v-midO-1-advance.json +11 -0
  34. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/target/.ijfw/state/workflow.json +1 -0
  35. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/events.jsonl +1 -0
  36. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/intent-journal.jsonl +3 -0
  37. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/meta.json +18 -0
  38. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/snapshots/v-midO-2-advance.json +11 -0
  39. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/target/.ijfw/state/workflow.json +1 -0
  40. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/events.jsonl +1 -0
  41. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/intent-journal.jsonl +3 -0
  42. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/meta.json +18 -0
  43. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/snapshots/v-midO-3-advance.json +11 -0
  44. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/target/.ijfw/state/workflow.json +1 -0
  45. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/events.jsonl +1 -0
  46. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/intent-journal.jsonl +3 -0
  47. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/meta.json +18 -0
  48. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/snapshots/v-midO-4-advance.json +11 -0
  49. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/target/.ijfw/state/workflow.json +1 -0
  50. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/events.jsonl +1 -0
  51. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/intent-journal.jsonl +3 -0
  52. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/meta.json +18 -0
  53. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/snapshots/v-midO-5-advance.json +11 -0
  54. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/target/.ijfw/state/workflow.json +1 -0
  55. package/fixtures/truncation-corpus/fx-03-mid-append-01/events.jsonl +1 -0
  56. package/fixtures/truncation-corpus/fx-03-mid-append-01/intent-journal.jsonl +3 -0
  57. package/fixtures/truncation-corpus/fx-03-mid-append-01/meta.json +18 -0
  58. package/fixtures/truncation-corpus/fx-03-mid-append-01/target/.ijfw/blackboard/decisions.jsonl +1 -0
  59. package/fixtures/truncation-corpus/fx-03-mid-append-02/events.jsonl +1 -0
  60. package/fixtures/truncation-corpus/fx-03-mid-append-02/intent-journal.jsonl +3 -0
  61. package/fixtures/truncation-corpus/fx-03-mid-append-02/meta.json +18 -0
  62. package/fixtures/truncation-corpus/fx-03-mid-append-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  63. package/fixtures/truncation-corpus/fx-03-mid-append-03/events.jsonl +1 -0
  64. package/fixtures/truncation-corpus/fx-03-mid-append-03/intent-journal.jsonl +3 -0
  65. package/fixtures/truncation-corpus/fx-03-mid-append-03/meta.json +18 -0
  66. package/fixtures/truncation-corpus/fx-03-mid-append-03/target/.ijfw/blackboard/decisions.jsonl +1 -0
  67. package/fixtures/truncation-corpus/fx-03-mid-append-04/events.jsonl +1 -0
  68. package/fixtures/truncation-corpus/fx-03-mid-append-04/intent-journal.jsonl +3 -0
  69. package/fixtures/truncation-corpus/fx-03-mid-append-04/meta.json +18 -0
  70. package/fixtures/truncation-corpus/fx-03-mid-append-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  71. package/fixtures/truncation-corpus/fx-03-mid-append-05/events.jsonl +1 -0
  72. package/fixtures/truncation-corpus/fx-03-mid-append-05/intent-journal.jsonl +3 -0
  73. package/fixtures/truncation-corpus/fx-03-mid-append-05/meta.json +18 -0
  74. package/fixtures/truncation-corpus/fx-03-mid-append-05/target/.ijfw/blackboard/decisions.jsonl +1 -0
  75. package/fixtures/truncation-corpus/fx-04-no-events-01/events.jsonl +0 -0
  76. package/fixtures/truncation-corpus/fx-04-no-events-01/intent-journal.jsonl +1 -0
  77. package/fixtures/truncation-corpus/fx-04-no-events-01/meta.json +18 -0
  78. package/fixtures/truncation-corpus/fx-04-no-events-01/snapshots/v-noEv-1-set-phase.json +11 -0
  79. package/fixtures/truncation-corpus/fx-04-no-events-01/target/.ijfw/state/workflow.json +1 -0
  80. package/fixtures/truncation-corpus/fx-04-no-events-02/events.jsonl +0 -0
  81. package/fixtures/truncation-corpus/fx-04-no-events-02/intent-journal.jsonl +1 -0
  82. package/fixtures/truncation-corpus/fx-04-no-events-02/meta.json +18 -0
  83. package/fixtures/truncation-corpus/fx-04-no-events-02/snapshots/v-noEv-2-set-phase.json +11 -0
  84. package/fixtures/truncation-corpus/fx-04-no-events-02/target/.ijfw/state/workflow.json +1 -0
  85. package/fixtures/truncation-corpus/fx-04-no-events-03/events.jsonl +0 -0
  86. package/fixtures/truncation-corpus/fx-04-no-events-03/intent-journal.jsonl +1 -0
  87. package/fixtures/truncation-corpus/fx-04-no-events-03/meta.json +18 -0
  88. package/fixtures/truncation-corpus/fx-04-no-events-03/snapshots/v-noEv-3-set-phase.json +11 -0
  89. package/fixtures/truncation-corpus/fx-04-no-events-03/target/.ijfw/state/workflow.json +1 -0
  90. package/fixtures/truncation-corpus/fx-04-no-events-04/events.jsonl +0 -0
  91. package/fixtures/truncation-corpus/fx-04-no-events-04/intent-journal.jsonl +1 -0
  92. package/fixtures/truncation-corpus/fx-04-no-events-04/meta.json +18 -0
  93. package/fixtures/truncation-corpus/fx-04-no-events-04/snapshots/v-noEv-4-set-phase.json +11 -0
  94. package/fixtures/truncation-corpus/fx-04-no-events-04/target/.ijfw/state/workflow.json +1 -0
  95. package/fixtures/truncation-corpus/fx-04-no-events-05/events.jsonl +0 -0
  96. package/fixtures/truncation-corpus/fx-04-no-events-05/intent-journal.jsonl +1 -0
  97. package/fixtures/truncation-corpus/fx-04-no-events-05/meta.json +18 -0
  98. package/fixtures/truncation-corpus/fx-04-no-events-05/snapshots/v-noEv-5-set-phase.json +11 -0
  99. package/fixtures/truncation-corpus/fx-04-no-events-05/target/.ijfw/state/workflow.json +1 -0
  100. package/fixtures/truncation-corpus/fx-05-error-terminated-01/events.jsonl +2 -0
  101. package/fixtures/truncation-corpus/fx-05-error-terminated-01/intent-journal.jsonl +3 -0
  102. package/fixtures/truncation-corpus/fx-05-error-terminated-01/meta.json +18 -0
  103. package/fixtures/truncation-corpus/fx-05-error-terminated-01/snapshots/v-errT-1-partial.json +11 -0
  104. package/fixtures/truncation-corpus/fx-05-error-terminated-01/target/.ijfw/state/workflow.json +1 -0
  105. package/fixtures/truncation-corpus/fx-05-error-terminated-02/events.jsonl +2 -0
  106. package/fixtures/truncation-corpus/fx-05-error-terminated-02/intent-journal.jsonl +3 -0
  107. package/fixtures/truncation-corpus/fx-05-error-terminated-02/meta.json +18 -0
  108. package/fixtures/truncation-corpus/fx-05-error-terminated-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  109. package/fixtures/truncation-corpus/fx-05-error-terminated-03/events.jsonl +2 -0
  110. package/fixtures/truncation-corpus/fx-05-error-terminated-03/intent-journal.jsonl +3 -0
  111. package/fixtures/truncation-corpus/fx-05-error-terminated-03/meta.json +18 -0
  112. package/fixtures/truncation-corpus/fx-05-error-terminated-03/snapshots/v-errT-3-partial.json +11 -0
  113. package/fixtures/truncation-corpus/fx-05-error-terminated-03/target/.ijfw/state/workflow.json +1 -0
  114. package/fixtures/truncation-corpus/fx-05-error-terminated-04/events.jsonl +2 -0
  115. package/fixtures/truncation-corpus/fx-05-error-terminated-04/intent-journal.jsonl +3 -0
  116. package/fixtures/truncation-corpus/fx-05-error-terminated-04/meta.json +18 -0
  117. package/fixtures/truncation-corpus/fx-05-error-terminated-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  118. package/fixtures/truncation-corpus/fx-05-error-terminated-05/events.jsonl +2 -0
  119. package/fixtures/truncation-corpus/fx-05-error-terminated-05/intent-journal.jsonl +3 -0
  120. package/fixtures/truncation-corpus/fx-05-error-terminated-05/meta.json +18 -0
  121. package/fixtures/truncation-corpus/fx-05-error-terminated-05/snapshots/v-errT-5-partial.json +11 -0
  122. package/fixtures/truncation-corpus/fx-05-error-terminated-05/target/.ijfw/state/workflow.json +1 -0
  123. package/package.json +6 -3
  124. package/src/active-extension-writer.js +144 -64
  125. package/src/api-client.js +43 -5
  126. package/src/audit-roster.js +80 -5
  127. package/src/blackboard.js +298 -6
  128. package/src/cli-run.js +33 -5
  129. package/src/codex-agents.js +96 -5
  130. package/src/cost/aggregator.js +39 -9
  131. package/src/cost/pricing.js +57 -0
  132. package/src/cost/readers/gemini.js +1 -1
  133. package/src/cross-audit-chunker.js +189 -0
  134. package/src/cross-dispatcher.js +124 -21
  135. package/src/cross-orchestrator-cli.js +754 -159
  136. package/src/cross-orchestrator.js +1065 -17
  137. package/src/cross-project-search.js +195 -9
  138. package/src/dashboard-client-waves.html +304 -0
  139. package/src/dashboard-client.html +5 -1
  140. package/src/dashboard-server.js +73 -0
  141. package/src/deploy-alerts.js +150 -0
  142. package/src/design/iframe-bridge.js +242 -0
  143. package/src/design-companion.js +144 -0
  144. package/src/dispatch/checkpoint-cli.js +97 -0
  145. package/src/dispatch/colon-syntax.js +81 -1
  146. package/src/dispatch/extension.js +26 -2
  147. package/src/dispatch/registry-cli.js +4 -1
  148. package/src/dispatch/wave-cli.js +201 -6
  149. package/src/dispatch/worktree-cli.js +40 -0
  150. package/src/dispatch-planner.js +97 -2
  151. package/src/dream/runner.mjs +47 -11
  152. package/src/dream/stage-runner.js +40 -0
  153. package/src/dream/state-file.js +102 -0
  154. package/src/extension-installer.js +70 -24
  155. package/src/extension-quota-tracker.js +4 -2
  156. package/src/extension-registry.js +289 -35
  157. package/src/feedback-detector.js +26 -0
  158. package/src/fs-lock.js +259 -7
  159. package/src/gate-result.js +95 -1
  160. package/src/hardware-signer.js +4 -2
  161. package/src/hero-line.js +86 -5
  162. package/src/intent-router.js +35 -0
  163. package/src/lib/a11y-contract.js +117 -0
  164. package/src/lib/atomic-io.js +29 -8
  165. package/src/lib/cache-keepalive.js +150 -0
  166. package/src/lib/jsonl-rotation.js +104 -0
  167. package/src/lib/lighthouse-pillar.js +121 -0
  168. package/src/lib/llm-call.js +121 -0
  169. package/src/lib/playwright-baseline.js +205 -0
  170. package/src/lib/rekor-bridge.js +221 -0
  171. package/src/lib/repo-map.js +392 -0
  172. package/src/lib/shasum-verify.js +164 -0
  173. package/src/lib/sketches-gc.js +132 -0
  174. package/src/lib/tmp-suffix.js +62 -0
  175. package/src/lib/ui-review-runner.js +595 -0
  176. package/src/lib/uispec-drift.js +301 -0
  177. package/src/lib/uispec-intake.js +381 -0
  178. package/src/lib/worktree-guards.js +118 -0
  179. package/src/lib/worktree-recovery.js +100 -0
  180. package/src/memory/auto-linker.js +267 -0
  181. package/src/memory/benchmark.js +498 -0
  182. package/src/memory/dedup.js +126 -0
  183. package/src/memory/embedding-cache.js +136 -0
  184. package/src/memory/fact-extractor.js +168 -0
  185. package/src/memory/fts5.js +65 -1
  186. package/src/memory/migration-runner.js +6 -1
  187. package/src/memory/migrations/004-bitemporal.js +91 -0
  188. package/src/memory/migrations/005-vector-cache.js +61 -0
  189. package/src/memory/migrations/006-obsidian-graph.js +46 -0
  190. package/src/memory/migrations/007-skill-telemetry.js +24 -0
  191. package/src/memory/migrations/008-write-provenance.js +41 -0
  192. package/src/memory/migrations/009-obsidian-backfill.js +50 -0
  193. package/src/memory/obsidian-parser.js +152 -0
  194. package/src/memory/query-dataview.js +86 -0
  195. package/src/memory/search.js +46 -15
  196. package/src/memory/temporal.js +529 -0
  197. package/src/memory/tokenize.js +10 -0
  198. package/src/memory-facts-handler.js +37 -0
  199. package/src/memory-feedback.js +260 -2
  200. package/src/model-refresh.js +292 -0
  201. package/src/observability/cost-anomaly.js +166 -0
  202. package/src/observability/evaluator-checkpoint-contract.js +117 -0
  203. package/src/observability/trace-id.js +163 -0
  204. package/src/orchestrator/agents-md-blackboard.js +152 -0
  205. package/src/orchestrator/checkpoint-contract.md +140 -0
  206. package/src/orchestrator/debug-trident-trigger.js +374 -0
  207. package/src/orchestrator/debug-trident.js +570 -0
  208. package/src/orchestrator/merge-block-aware.js +350 -0
  209. package/src/orchestrator/plan-checker.js +475 -0
  210. package/src/orchestrator/post-done-runner.js +277 -0
  211. package/src/orchestrator/review.js +38 -3
  212. package/src/orchestrator/skill-telemetry-sink.js +29 -0
  213. package/src/orchestrator/skill-telemetry.js +37 -0
  214. package/src/orchestrator/state-events.js +459 -0
  215. package/src/orchestrator/state-sdk.js +1932 -0
  216. package/src/orchestrator/status-protocol.js +84 -17
  217. package/src/orchestrator/subagent-telemetry.js +471 -0
  218. package/src/orchestrator/termination.js +160 -0
  219. package/src/orchestrator/verification-gate.js +200 -16
  220. package/src/orchestrator/wave-state.js +332 -23
  221. package/src/orchestrator/worktree-provision.js +77 -0
  222. package/src/override-resolver.js +5 -3
  223. package/src/override-use-registry.js +111 -5
  224. package/src/receipts.js +36 -4
  225. package/src/recovery/checkpoint.js +56 -3
  226. package/src/recovery/code-fixer.js +961 -0
  227. package/src/recovery/truncation.js +317 -0
  228. package/src/redactor.js +75 -6
  229. package/src/runtime-mediator.js +15 -1
  230. package/src/sanitizer.js +10 -0
  231. package/src/search-hybrid.js +139 -0
  232. package/src/server.js +795 -112
  233. package/src/swarm/worktree.js +27 -4
  234. package/src/swarm-config.js +102 -17
  235. package/src/team/domain-templates/book.json +51 -0
  236. package/src/team/domain-templates/business.json +44 -0
  237. package/src/team/domain-templates/content.json +50 -0
  238. package/src/team/domain-templates/design.json +44 -0
  239. package/src/team/domain-templates/research.json +44 -0
  240. package/src/team/domain-templates/software.json +40 -0
  241. package/src/team/generator.js +440 -3
  242. package/src/team/modify.js +203 -0
  243. package/src/team/schemas.js +48 -0
  244. package/src/update-apply.js +19 -3
  245. package/src/dashboard-charts.js +0 -239
package/src/server.js CHANGED
@@ -33,9 +33,33 @@ import { checkPrompt } from './prompt-check.js';
33
33
  import { applyCaps, CAP_CONTENT } from './caps.js';
34
34
  import { ensureSchemaHeader, SCHEMA_HEADER } from './schema.js';
35
35
  import { searchCorpus } from './search-bm25.js';
36
+ // r17 (cold-tier wire-up): hybrid BM25+vector ranking when IJFW_VECTORS=on AND
37
+ // @xenova/transformers is installed. Silent BM25-only fallback otherwise.
38
+ // Lives in its own module so tests can drive the rerank helper without
39
+ // importing server.js (whose stdio bootstrap would hang the test runner).
40
+ import { maybeRerankWithVectors } from './search-hybrid.js';
36
41
  import { crossProjectSearch } from './cross-project-search.js';
37
42
  // R2-E -- single source of truth for markdown/HTML/control-char defanger.
38
43
  import { sanitizeContent } from './sanitizer.js';
44
+ // v1.5.1 R4-H3 — secret redaction on the direct ijfw_store write path.
45
+ // The redactor is already wired into FTS5 ingest + auto-memorize; the
46
+ // direct MCP store was the one bypass, so a secret pasted into an
47
+ // ijfw_store call could land in .ijfw/memory/*.md cleartext.
48
+ import { redactSecrets } from './redactor.js';
49
+ // H5.5 / H5.6 — ingest-time fact extraction + semantic dedup. Closes
50
+ // memory-engine.md competitor gaps (mem0/Zep extract facts; Graphiti dedups).
51
+ // Both are pure-JS, zero-LLM, deterministic.
52
+ import { extractFacts, factToJsonl } from './memory/fact-extractor.js';
53
+ import { findNearDuplicate, readDedupConfig } from './memory/dedup.js';
54
+ // v1.5.0 audit H5.4 — Graphiti-style bi-temporal validity. Lets storing a
55
+ // contradictory fact close the prior's valid_to instead of accumulating.
56
+ import {
57
+ openTemporalDbSync,
58
+ storeFactBitemporal,
59
+ getValidAt as temporalGetValidAt,
60
+ getHistory as temporalGetHistory,
61
+ getAllFactsWithWindows as temporalGetAllFactsWithWindows,
62
+ } from './memory/temporal.js';
39
63
  // 1.1.6: update tools (cap 8 -> 10) -- token-issuance + OOB terminal confirm.
40
64
  // Per CLAUDE.md policy: future growth triggers retirement review, not raise.
41
65
  import { ijfwUpdateCheck, TOOL_DEF as UPDATE_CHECK_TOOL } from './update-check.js';
@@ -394,8 +418,11 @@ function readOr(filepath, fallback = '') {
394
418
  // --- Append helper (atomic for entries < PIPE_BUF; append-only growth) ---
395
419
  //
396
420
  // We rely on POSIX O_APPEND atomicity for entries under 4KB. Sanitized
397
- // entries are bounded at MAX_STORE_LENGTH=5000 chars, but the entry header
398
- // keeps each *line* well under 4KB after sanitization (single-line collapse).
421
+ // entries are bounded at MAX_STORE_LENGTH=4096 chars (CAP_CONTENT in caps.js),
422
+ // but the entry header keeps each *line* well under 4KB after sanitization
423
+ // (single-line collapse). Audit MED #8 / F-COR-1: doc-vs-code parity fix --
424
+ // the prior text said 5000, which had drifted from the actual cap.
425
+
399
426
  function appendLine(filepath, line) {
400
427
  try {
401
428
  if (!existsSync(filepath)) {
@@ -569,28 +596,110 @@ function readGlobalKnowledge() {
569
596
  ).join('\n\n');
570
597
  }
571
598
 
572
- function getSessionCount() {
599
+ function getRecentJournalEntries(count = 5) {
600
+ const journal = readOr(join(MEMORY_DIR, 'project-journal.md'));
601
+ if (!journal) return '';
602
+ const entries = journal.split('\n').filter(l => /^- \[\d{4}-/.test(l));
603
+ return entries.slice(-count).join('\n');
604
+ }
605
+
606
+ // H5.6 — dedup needs `recents` shaped as { id, content } for findNearDuplicate.
607
+ // We synthesize id from the timestamp (project-journal entries are unique by ts
608
+ // down to ms; collisions would only happen on near-simultaneous writes which
609
+ // our atomic-append + fs flush ordering already serialise).
610
+ function getRecentMemoriesForDedup(limit = 50) {
611
+ const journal = readOr(join(MEMORY_DIR, 'project-journal.md'));
612
+ if (!journal) return [];
613
+ const lines = journal.split('\n').filter(l => /^- \[\d{4}-/.test(l));
614
+ // Most-recent-last in the file → reverse so findNearDuplicate sees newest first.
615
+ const slice = lines.slice(-limit).reverse();
616
+ return slice.map(line => {
617
+ // Format: "- [<iso>] <body>" → { id: iso, content: body }
618
+ const m = line.match(/^- \[([^\]]+)\]\s*(.*)$/);
619
+ if (!m) return null;
620
+ return { id: m[1], content: m[2] };
621
+ }).filter(Boolean);
622
+ }
623
+
624
+ // H5.5 — sidecar file for structured facts (one JSON object per line).
625
+ // Append-only; consumed by handleRecall({context_hint:'facts'}).
626
+ const FACTS_FILE = join(MEMORY_DIR, 'facts.jsonl');
627
+
628
+ // Stable short id for joining a fact back to its journal entry. We don't have
629
+ // a uuid; the journal-line text itself + ts is unique enough for cross-ref.
630
+ function factMemoryIdFor(journalEntryText) {
631
+ return 'm-' + createHash('sha256')
632
+ .update(String(journalEntryText) + ':' + Date.now())
633
+ .digest('hex')
634
+ .slice(0, 10);
635
+ }
636
+
637
+ function appendFactsToSidecar(facts, meta) {
638
+ if (!Array.isArray(facts) || facts.length === 0) return { ok: true, written: 0 };
573
639
  try {
574
- if (!existsSync(SESSIONS_DIR)) return 0;
575
- return readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.md')).length;
576
- } catch {
577
- return 0;
640
+ const lines = facts.map(f => factToJsonl(f, meta)).join('\n') + '\n';
641
+ appendFileSync(FACTS_FILE, lines);
642
+ return { ok: true, written: facts.length };
643
+ } catch (err) {
644
+ // Non-fatal: facts are augmentation, not source-of-truth. Journal already
645
+ // captured the raw memory.
646
+ return { ok: false, code: err.code || 'EUNKNOWN', message: err.message };
578
647
  }
579
648
  }
580
649
 
581
- function getDecisionCount() {
582
- const journal = readOr(join(MEMORY_DIR, 'project-journal.md'));
583
- if (!journal) return 0;
584
- // Match only journal entry lines (we now prefix with - [timestamp]) -- not
585
- // arbitrary list bullets that might appear in seeded content.
586
- return (journal.match(/^- \[\d{4}-\d{2}-\d{2}T/gm) || []).length;
650
+ // v1.5.0 audit H5.4 — sibling SQL store for bi-temporal facts. Lives next to
651
+ // facts.jsonl so the JSONL sidecar (append-only timeline log) and the SQL
652
+ // table (queryable point-in-time view) stay co-located. Lazy-opened on first
653
+ // use so a project that never stores a memory never pays the better-sqlite3
654
+ // load cost.
655
+ const FACTS_DB_FILE = join(MEMORY_DIR, 'facts.db');
656
+ let _factsDbHandle = null;
657
+ function getFactsDb() {
658
+ if (_factsDbHandle) return _factsDbHandle;
659
+ try {
660
+ _factsDbHandle = openTemporalDbSync(FACTS_DB_FILE);
661
+ return _factsDbHandle;
662
+ } catch (err) {
663
+ // Non-fatal. JSONL sidecar still gets written; we just lose the bi-temporal
664
+ // SQL view for this process. Surface via stderr so operators see the
665
+ // degradation but the user-facing store result still says "ok".
666
+ try {
667
+ process.stderr.write(`[ijfw temporal] facts.db unavailable (${err.code || err.message}); SQL fact view degraded\n`);
668
+ } catch { /* stderr may be detached */ }
669
+ return null;
670
+ }
587
671
  }
588
672
 
589
- function getRecentJournalEntries(count = 5) {
590
- const journal = readOr(join(MEMORY_DIR, 'project-journal.md'));
591
- if (!journal) return '';
592
- const entries = journal.split('\n').filter(l => /^- \[\d{4}-/.test(l));
593
- return entries.slice(-count).join('\n');
673
+ // writeFactsBitemporal -- for each extracted fact, close any prior currently-
674
+ // valid fact with the same (subject, predicate) but different object, then
675
+ // insert the new fact. Wrapped in a per-fact transaction inside temporal.js's
676
+ // storeFactBitemporal helper. Best-effort: a SQL failure logs to stderr but
677
+ // never breaks the journal-or-JSONL path.
678
+ function writeFactsBitemporal(facts, meta) {
679
+ if (!Array.isArray(facts) || facts.length === 0) return { ok: true, written: 0, invalidated: 0 };
680
+ const db = getFactsDb();
681
+ if (!db) return { ok: false, code: 'ENOFACTSDB', written: 0, invalidated: 0 };
682
+ let written = 0;
683
+ let invalidated = 0;
684
+ for (const f of facts) {
685
+ try {
686
+ const r = storeFactBitemporal(db, {
687
+ subject: f.subject,
688
+ predicate: f.predicate,
689
+ object: f.object,
690
+ confidence: f.confidence,
691
+ memory_id: meta && meta.memory_id,
692
+ source: meta && meta.source,
693
+ }, meta && meta.ts);
694
+ invalidated += r.invalidated;
695
+ if (!r.deduped) written += 1;
696
+ } catch (err) {
697
+ try {
698
+ process.stderr.write(`[ijfw temporal] storeFactBitemporal failed: ${err.message}\n`);
699
+ } catch { /* stderr may be detached */ }
700
+ }
701
+ }
702
+ return { ok: true, written, invalidated };
594
703
  }
595
704
 
596
705
  // --- Cross-project registry (Phase 3) ---
@@ -635,55 +744,81 @@ function readProjectMemory(projectPath) {
635
744
  };
636
745
  }
637
746
 
638
- function searchAcrossProjects(query, limit) {
639
- const queryLower = String(query).toLowerCase();
640
- const keywords = queryLower.split(/\s+/).filter(w => w.length > 2);
641
- if (keywords.length === 0) return [];
642
-
643
- const results = [];
644
- for (const entry of readRegistry()) {
645
- const tag = basename(entry.path);
646
- const mem = readProjectMemory(entry.path);
647
- for (const [src, content] of Object.entries(mem)) {
648
- if (!content) continue;
649
- const lines = content.split('\n');
650
- for (let i = 0; i < lines.length; i++) {
651
- const line = lines[i];
652
- if (line.trim().length === 0) continue;
653
- const score = keywords.filter(k => line.toLowerCase().includes(k)).length;
654
- if (score > 0) {
655
- results.push({
656
- source: `${src}@${tag}`,
657
- line: i + 1,
658
- content: `[project:${tag}] ${line.trim().substring(0, 200)}`,
659
- score
660
- });
661
- }
662
- }
663
- }
664
- }
665
- results.sort((a, b) => b.score - a.score);
666
- return results.slice(0, limit);
667
- }
747
+ // v1.5.1 H1.5 (audit memory-engine.md F-FUN-4): the legacy naive-keyword-count
748
+ // `searchAcrossProjects` was removed. `scope:'all'` now routes to the BM25-
749
+ // ranked `crossProjectSearch` in cross-project-search.js (which already
750
+ // returns the same `{ source, line, content, score }` shape via the
751
+ // `[project:<name>]` content prefix). Two parallel cross-project surfaces
752
+ // existed; the worse one was the default. Now there is one.
668
753
 
669
754
  // --- Search ---
670
755
  // P5.1 / H4 -- BM25 ranking over line-level docs. Source tags and line
671
756
  // numbers preserved so callers get the same output shape; scoring is
672
757
  // BM25 (IDF + TF + length-normalized) with per-source boost. Team tier
673
758
  // ranks first via a score bump for ties.
674
- function searchMemory(query, limit = 10, scope = 'project') {
759
+ //
760
+ // r17 (cold-tier wire-up): when IJFW_VECTORS=on AND @xenova/transformers is
761
+ // installed AND the model is loadable, the BM25 top-K is reranked via cosine
762
+ // similarity over the snippet text (blended weights wBm25=0.6, wVec=0.4 from
763
+ // vectors.js defaults). Async because embedder load + embed() are async. The
764
+ // `opts.embedder` parameter lets tests inject a mock embedder without
765
+ // installing @xenova/transformers.
766
+
767
+ // v1.5.0 wire-W1.C — lazy memory.db handle for the embedding cache.
768
+ // Opens once per process and reuses thereafter. Returns null when the
769
+ // project has no .ijfw/index/memory.db (e.g. fresh checkout that never
770
+ // stored a memory) so the rerank still falls back to live embed.
771
+ let _memoryDbForRerank = null;
772
+ async function getMemoryDbForRerank() {
773
+ if (_memoryDbForRerank) return _memoryDbForRerank;
774
+ try {
775
+ const { openDb } = await import('./memory/fts5.js');
776
+ _memoryDbForRerank = await openDb(PROJECT_DIR);
777
+ return _memoryDbForRerank;
778
+ } catch {
779
+ _memoryDbForRerank = null;
780
+ return null;
781
+ }
782
+ }
783
+
784
+ async function searchMemory(query, limit = 10, scope = 'project', opts = {}) {
675
785
  limit = Math.min(Math.max(1, limit | 0), MAX_SEARCH_RESULTS);
676
- if (scope === 'all') return searchAcrossProjects(query, limit);
786
+ if (scope === 'all') {
787
+ // v1.5.1 H1.5 (audit memory-engine.md F-FUN-4): use BM25-ranked
788
+ // crossProjectSearch, not the legacy naive keyword-count scan.
789
+ const projects = readRegistry();
790
+ if (projects.length === 0) return [];
791
+ return crossProjectSearch(query, projects, readProjectMemory, { limit });
792
+ }
677
793
 
678
794
  const sources = [
679
- { name: 'team', content: readTeamKnowledge(), boost: 1.25 },
680
- { name: 'knowledge', content: readKnowledgeBase(), boost: 1.15 },
681
- { name: 'journal', content: readOr(join(MEMORY_DIR, 'project-journal.md')), boost: 1.0 },
682
- { name: 'handoff', content: readHandoff(), boost: 1.1 },
683
- { name: 'global', content: readGlobalKnowledge(), boost: 0.95 },
684
- { name: 'claude-native', content: readNativeClaudeMemory(), boost: 0.95 },
795
+ { name: 'team', content: readTeamKnowledge(), boost: 1.25, path: join(MEMORY_DIR, 'team-knowledge.md') },
796
+ { name: 'knowledge', content: readKnowledgeBase(), boost: 1.15, path: join(MEMORY_DIR, 'knowledge.md') },
797
+ { name: 'journal', content: readOr(join(MEMORY_DIR, 'project-journal.md')), boost: 1.0, path: join(MEMORY_DIR, 'project-journal.md') },
798
+ { name: 'handoff', content: readHandoff(), boost: 1.1, path: join(MEMORY_DIR, 'handoff.md') },
799
+ { name: 'global', content: readGlobalKnowledge(), boost: 0.95, path: null },
800
+ { name: 'claude-native', content: readNativeClaudeMemory(), boost: 0.95, path: null },
685
801
  ];
686
802
 
803
+ // v1.5.0 audit MED #12 (memory-engine.md F-FUN-5): recency decay on the
804
+ // boosted map. Each source file has an mtime; per-result age (days since
805
+ // mtime) feeds Math.exp(-ageDays / 90), so a 90-day-old source decays
806
+ // by ~1/e (0.37) and a 1-year-old source by ~0.018. Fresh entries
807
+ // (< 1 day) stay essentially unchanged (~0.99). Sources without a file
808
+ // (global, claude-native) get no decay (multiplier 1.0).
809
+ const RECENCY_HALFLIFE_DAYS = 90;
810
+ const nowMs = Date.now();
811
+ const sourceDecay = new Map();
812
+ for (const src of sources) {
813
+ if (!src.path) { sourceDecay.set(src.name, 1); continue; }
814
+ let ageDays = 0;
815
+ try {
816
+ const st = statSync(src.path);
817
+ ageDays = Math.max(0, (nowMs - st.mtimeMs) / 86400000);
818
+ } catch { ageDays = 0; }
819
+ sourceDecay.set(src.name, Math.exp(-ageDays / RECENCY_HALFLIFE_DAYS));
820
+ }
821
+
687
822
  const docs = [];
688
823
  const meta = new Map();
689
824
  for (const src of sources) {
@@ -702,19 +837,46 @@ function searchMemory(query, limit = 10, scope = 'project') {
702
837
  const ranked = searchCorpus(query, docs, { limit: limit * 3 });
703
838
  if (ranked.length === 0) return [];
704
839
 
705
- const boosted = ranked.map(r => {
840
+ // r17: cold-tier hybrid rerank. Pure no-op when vectors disabled OR
841
+ // embedder unavailable. Never throws into the caller.
842
+ //
843
+ // v1.5.0 wire-W1.C: when the caller didn't supply a db handle but the
844
+ // memory.db exists for this project, open it lazily + thread through so
845
+ // the embedding cache backs the rerank. The default modelId mirrors
846
+ // vectors.js DEFAULT_MODEL so first-call writes match cache reads from
847
+ // the same process on later calls.
848
+ //
849
+ // r20-MED fix: previously the lazy-open was SKIPPED whenever opts.embedder
850
+ // was supplied (intended as a test-seam guard). That meant any caller
851
+ // passing a custom embedder (e.g. an HTTP-backed one) lost the cache.
852
+ // Now: always lazy-open when !opts.db. Tests that want to disable the
853
+ // cache pass opts.db = null explicitly.
854
+ const rerankOpts = { ...opts };
855
+ if (!rerankOpts.db && rerankOpts.db !== null) {
856
+ try {
857
+ rerankOpts.db = await getMemoryDbForRerank();
858
+ } catch { /* memory db unavailable -- skip cache, fall back to live embed */ }
859
+ }
860
+ if (!rerankOpts.modelId) {
861
+ rerankOpts.modelId = process.env.IJFW_VECTORS_MODEL || 'Xenova/all-MiniLM-L6-v2';
862
+ }
863
+ const reranked = await maybeRerankWithVectors(query, ranked, rerankOpts);
864
+
865
+ const boosted = reranked.map(r => {
706
866
  const m = meta.get(r.id);
867
+ const decay = sourceDecay.get(m.source) ?? 1;
707
868
  return {
708
869
  source: m.source,
709
870
  line: m.line,
710
871
  content: (r.snippet || '').substring(0, 200),
711
- score: r.score * (m.boost || 1),
872
+ score: r.score * (m.boost || 1) * decay,
712
873
  };
713
874
  });
714
875
  boosted.sort((a, b) => b.score - a.score);
715
876
  return boosted.slice(0, limit);
716
877
  }
717
878
 
879
+
718
880
  // --- DESIGN picker (1.2.0 Phase 5) ---
719
881
  // MCP-only delivery of the 12-template design catalog for OpenCode / Qwen
720
882
  // Code / Kimi Code / OpenClaw / Aider. No new tool -- served via existing
@@ -805,7 +967,7 @@ const TOOLS = [
805
967
  inputSchema: {
806
968
  type: 'object',
807
969
  properties: {
808
- content: { type: 'string', description: 'Full statement of what to remember. Max 5000 chars. Sanitised on storage.' },
970
+ content: { type: 'string', description: 'Full statement of what to remember. Max 4096 chars. Sanitised on storage.' },
809
971
  type: { type: 'string', enum: VALID_MEMORY_TYPES, description: 'Memory tier: decision or pattern -> knowledge base (frontmatter). handoff -> overwrites handoff.md. preference -> project-namespaced global. observation -> journal only.' },
810
972
  summary: { type: 'string', description: 'Optional 1-line summary (≤80 chars). Used as the frontmatter name for decisions/patterns.' },
811
973
  why: { type: 'string', description: 'Optional rationale -- why this decision was made. Populates the Why section in the knowledge base entry.' },
@@ -817,7 +979,7 @@ const TOOLS = [
817
979
  },
818
980
  {
819
981
  name: 'ijfw_memory_search',
820
- description: 'Keyword search across memory sources. Up to 20 results. Scope defaults to current project; pass scope:"all" to search across every IJFW project ever opened on this machine (results tagged [project:<name>]). Pass scope:"sandbox" to retrieve sandboxed ijfw_run output -- include label to get the full output of a specific run, or omit label to list all available sandbox entries.',
982
+ description: 'Keyword search across memory sources. Up to 20 results. Scope defaults to current project; pass scope:"all" to search across every IJFW project ever opened on this machine (results tagged [project:<name>]). Pass scope:"sandbox" to retrieve sandboxed ijfw_run output -- include label to get the full output of a specific run, or omit label to list all available sandbox entries. The query field also accepts colon-namespaced commands: "compute:<query>" hits the per-project FTS5 index, "graph:<query>" routes through the knowledge-graph search.',
821
983
  inputSchema: {
822
984
  type: 'object',
823
985
  properties: {
@@ -844,6 +1006,21 @@ const TOOLS = [
844
1006
  required: []
845
1007
  }
846
1008
  },
1009
+ {
1010
+ // v1.5.0 M5 (INT.6) -- bi-temporal facts MCP surface.
1011
+ name: 'ijfw_memory_facts',
1012
+ description: 'Query the bi-temporal facts table (subject/predicate/object timeline with valid_from / valid_to). Default: current-valid rows only. Pass history=true for full timeline; pass valid_at=<ISO-8601> for point-in-time. Subject + predicate are required.',
1013
+ inputSchema: {
1014
+ type: 'object',
1015
+ properties: {
1016
+ subject: { type: 'string', description: 'Fact subject (e.g. "v1.5.0").' },
1017
+ predicate: { type: 'string', description: 'Fact predicate (e.g. "ship_date").' },
1018
+ valid_at: { type: 'string', description: 'Optional ISO-8601 timestamp. Returns rows whose validity window covers this instant.' },
1019
+ history: { type: 'boolean', description: 'If true, return all rows (current + invalidated) ordered DESC by valid_from.' }
1020
+ },
1021
+ required: ['subject', 'predicate']
1022
+ }
1023
+ },
847
1024
  {
848
1025
  name: 'ijfw_prompt_check',
849
1026
  description: 'Call on the first turn when the user prompt is short (<30 tokens) or likely vague. Returns whether the prompt is under-specified and a sharpening suggestion. Deterministic regex detector -- no LLM call. Use for Codex/Cursor/Windsurf/Copilot/Gemini where pre-prompt hooks are not available.',
@@ -861,8 +1038,8 @@ const TOOLS = [
861
1038
  inputSchema: {
862
1039
  type: 'object',
863
1040
  properties: {
864
- period: { type: 'string', enum: ['today', '7d', '30d', 'all'], description: 'Time window (default 7d).' },
865
- metric: { type: 'string', enum: ['tokens', 'cost', 'sessions', 'routing'], description: 'Which metric to render (default tokens).' }
1041
+ period: { type: 'string', enum: ['today', '7d', '30d', 'all'], default: '7d', description: 'Time window (default 7d).' },
1042
+ metric: { type: 'string', enum: ['tokens', 'cost', 'sessions', 'routing'], default: 'tokens', description: 'Which metric to render (default tokens).' }
866
1043
  },
867
1044
  required: []
868
1045
  }
@@ -883,7 +1060,7 @@ const TOOLS = [
883
1060
  UPDATE_APPLY_TOOL,
884
1061
  {
885
1062
  name: 'ijfw_run',
886
- description: 'Run a shell command. For commands likely to produce large output (builds, test suites, grep -r, log tails), use this instead of Bash -- full output is sandboxed to disk and a smart summary is returned to context. For git/nav/quick ops, use Bash directly.',
1063
+ description: 'Run a shell command. For commands likely to produce large output (builds, test suites, grep -r, log tails), use this instead of Bash -- full output is sandboxed to disk and a smart summary is returned to context. For git/nav/quick ops, use Bash directly. Also accepts colon-namespaced commands instead of a shell line: "compute:python", "compute:js", "index:<source>", "detect:project_type".',
887
1064
  inputSchema: {
888
1065
  type: 'object',
889
1066
  properties: {
@@ -893,6 +1070,47 @@ const TOOLS = [
893
1070
  },
894
1071
  required: ['command'],
895
1072
  },
1073
+ },
1074
+ {
1075
+ // v1.5.0 T13: ijfw_state — single MCP face for the state-SDK verb facade.
1076
+ // Absorbs the retired ijfw_subagent_post_done tool (post-done IS a state
1077
+ // transition → reachable as the `subagent.post-done` verb). All 20 frozen
1078
+ // verbs from STATE-SDK-CONTRACT §7 are reachable through this one tool,
1079
+ // keeping the MCP cap at 13/13. The same `query(verb, payload, ctx)` core
1080
+ // is also exposed as a JS import and a CLI colon-namespace (`ijfw state:<verb>`).
1081
+ name: 'ijfw_state',
1082
+ description: 'State-SDK verb facade — invoke any of the 20 frozen verbs over the canonical physical state files. The 20 verbs: workflow.get, workflow.set-phase, wave.get, wave.advance, wave.record-task, phase.plan-check, phase.complete, subagent.dispatch, subagent.checkpoint, subagent.post-done, event.emit, telemetry.record, roster.synthesize, roster.record, extension.set-active, decision.add, blocker.add, blocker.resolve, state.replay, state.validate. Single MCP face for the state-SDK; subagent.post-done is the verb that absorbed the retired ijfw_subagent_post_done tool. Returns the verb result with `ok` + `verbId` + verb-specific fields (see STATE-SDK-CONTRACT §7).',
1083
+ inputSchema: {
1084
+ type: 'object',
1085
+ properties: {
1086
+ verb: { type: 'string', description: 'Verb name from the frozen 20-verb registry (e.g. "workflow.get", "wave.advance", "subagent.post-done", "state.validate").' },
1087
+ payload: { type: 'object', description: 'Verb-specific payload (see STATE-SDK-CONTRACT §7 for each verb signature). Defaults to {} when omitted.' },
1088
+ projectRoot: { type: 'string', description: 'Project root for ctx (defaults to process.cwd()).' },
1089
+ subagentId: { type: 'string', description: 'Subagent id stamped on event/telemetry records (defaults to "parent").' },
1090
+ homeDir: { type: 'string', description: 'Home dir override for the homedir-scope active-extension file (defaults to process.env.HOME / USERPROFILE / os.homedir()).' },
1091
+ },
1092
+ required: ['verb'],
1093
+ },
1094
+ },
1095
+ {
1096
+ // v1.5.0-major W12-C N03: Trident-as-a-service. Multi-lens consensus
1097
+ // convergence (lock-in #47 — canonical Phase E). Dispatches all 3 lenses
1098
+ // (codex/gemini/claude by default) in parallel; if verdicts diverge,
1099
+ // re-runs with a CYCLE_SUMMARY of the disagreement until consensus or
1100
+ // maxIterations (default 3). Stall breaker halts on byte-identical
1101
+ // iterations. Slot 12 of the 13/13 tool cap.
1102
+ name: 'ijfw_cross_audit_converge',
1103
+ description: 'Multi-lens Trident audit with consensus convergence loop. Dispatches codex/gemini/claude in parallel against a commit range, detects verdict divergence, and re-runs with a cycle summary until consensus or maxIterations. Returns {verdict, iterations, findings, divergence?, stalled?}. Verdict: PASS / CONDITIONAL / FAIL / consensus_failed / UNREACHABLE.',
1104
+ inputSchema: {
1105
+ type: 'object',
1106
+ properties: {
1107
+ commitRange: { type: 'string', description: 'Git commit range to audit (e.g. "HEAD~1..HEAD", "main..feature/x"). Required.' },
1108
+ maxIterations: { type: 'number', minimum: 1, maximum: 10, description: 'Max convergence iterations (default 3, capped at 10). 1 → single-shot (fallback mode).' },
1109
+ lenses: { type: 'array', items: { type: 'string' }, description: 'Lens ids to dispatch (default ["codex","gemini","claude"]).' },
1110
+ autoFix: { type: 'boolean', description: 'v1.5.1 (T27) — opt-in consensus auto-fix. When true, after a non-PASS convergence the consensus code-fixer AUTOMATICALLY MODIFIES CODE: it runs an atomic per-finding fix loop (one revertable git commit per fix) over HIGH findings that 2+ lenses agreed on. SAFETY BOUNDS: the fixer can only write files inside the audited project root (path-containment guard refuses out-of-root paths) and touches at most 10 distinct files per run (change cap — beyond it it stops and reports rather than mass-rewriting). Logic bugs are deferred to humans, never auto-patched. Results surface on result.autoFix without changing the verdict. Default false — the audit is read-only unless you explicitly opt in.' },
1111
+ },
1112
+ required: ['commitRange'],
1113
+ },
896
1114
  }
897
1115
  ];
898
1116
 
@@ -901,8 +1119,8 @@ const TOOLS = [
901
1119
  function handleRecall({ context_hint, detail_level = 'standard', from_project }) {
902
1120
  // Cross-project explicit pull. We bypass current-project sources and read
903
1121
  // the target project's knowledge/handoff/journal directly. Search queries
904
- // are routed through searchAcrossProjects via scope:'all' on the search tool;
905
- // recall here is for "give me everything from X."
1122
+ // are routed through crossProjectSearch (BM25) via scope:'all' on the
1123
+ // search tool; recall here is for "give me everything from X."
906
1124
  if (from_project) {
907
1125
  const target = resolveProject(from_project);
908
1126
  if (!target) {
@@ -949,11 +1167,163 @@ function handleRecall({ context_hint, detail_level = 'standard', from_project })
949
1167
  return { text: getRecentJournalEntries(10) || 'No decisions recorded yet.' };
950
1168
  }
951
1169
 
1170
+ // H5.5 / v1.5.0 H5.4 — structured facts feed.
1171
+ // context_hint === 'facts' -> only currently-valid facts (SQL
1172
+ // getValidAt(now)); fallback to raw
1173
+ // facts.jsonl if the SQL table is
1174
+ // empty/unavailable (back-compat for
1175
+ // pre-H5.4 installations).
1176
+ // context_hint === 'facts:history' -> full timeline including invalidated
1177
+ // rows (with their valid_from/valid_to
1178
+ // windows). If the hint is followed by
1179
+ // ":subject/predicate" we narrow to
1180
+ // that pair; otherwise return every
1181
+ // row.
1182
+ if (context_hint === 'facts' || (typeof context_hint === 'string' && context_hint.startsWith('facts:history'))) {
1183
+ const isHistory = typeof context_hint === 'string' && context_hint.startsWith('facts:history');
1184
+ try {
1185
+ const db = getFactsDb();
1186
+ if (db) {
1187
+ if (isHistory) {
1188
+ // Optional ":subject/predicate" narrow-down. Spec: "if subject+
1189
+ // predicate keys can be inferred from the recall query; else return
1190
+ // all facts with their validity windows".
1191
+ const tail = context_hint.slice('facts:history'.length).replace(/^:/, '').trim();
1192
+ let rows;
1193
+ if (tail) {
1194
+ const [subj, pred] = tail.split('/').map(s => s && s.trim()).filter(Boolean);
1195
+ if (subj && pred) {
1196
+ rows = temporalGetHistory(db, subj, pred);
1197
+ } else {
1198
+ rows = temporalGetAllFactsWithWindows(db);
1199
+ }
1200
+ } else {
1201
+ rows = temporalGetAllFactsWithWindows(db);
1202
+ }
1203
+ if (!rows || rows.length === 0) {
1204
+ return { text: 'No structured facts extracted yet. Store memories with key:value lines, "X uses Y", or "decided to ..." phrases to populate.' };
1205
+ }
1206
+ const lines = rows.map(r => JSON.stringify({
1207
+ subject: r.subject,
1208
+ predicate: r.predicate,
1209
+ object: r.object,
1210
+ confidence: r.confidence,
1211
+ valid_from: r.valid_from,
1212
+ valid_to: r.valid_to,
1213
+ memory_id: r.memory_id,
1214
+ source: r.source,
1215
+ }));
1216
+ if (detail_level === 'summary') {
1217
+ const tailLines = lines.slice(-5).join('\n');
1218
+ return { text: `## Fact history (${lines.length} rows)\n${tailLines}` };
1219
+ }
1220
+ return { text: `## Fact history (${lines.length} rows)\n${lines.join('\n')}` };
1221
+ }
1222
+ // Default: currently-valid facts only.
1223
+ const rows = temporalGetValidAt(db, new Date().toISOString());
1224
+ if (rows && rows.length > 0) {
1225
+ const lines = rows.map(r => JSON.stringify({
1226
+ subject: r.subject,
1227
+ predicate: r.predicate,
1228
+ object: r.object,
1229
+ confidence: r.confidence,
1230
+ valid_from: r.valid_from,
1231
+ memory_id: r.memory_id,
1232
+ source: r.source,
1233
+ }));
1234
+ if (detail_level === 'summary') {
1235
+ const tail = lines.slice(-5).join('\n');
1236
+ return { text: `## Currently-valid facts (${lines.length})\n${tail}` };
1237
+ }
1238
+ return { text: `## Currently-valid facts (${lines.length})\n${lines.join('\n')}` };
1239
+ }
1240
+ // SQL table empty -- fall through to JSONL back-compat path below.
1241
+ }
1242
+ if (!existsSync(FACTS_FILE)) {
1243
+ return { text: 'No structured facts extracted yet. Store memories with key:value lines, "X uses Y", or "decided to ..." phrases to populate.' };
1244
+ }
1245
+ const raw = readFileSync(FACTS_FILE, 'utf8');
1246
+ // detail_level === 'summary' → just count + a sample tail. Keeps token
1247
+ // usage bounded for session-start hydration.
1248
+ if (detail_level === 'summary') {
1249
+ const lines = raw.split('\n').filter(Boolean);
1250
+ const tail = lines.slice(-5).join('\n');
1251
+ return { text: `## Structured facts (${lines.length} total)\n${tail}` };
1252
+ }
1253
+ return { text: raw || 'No structured facts extracted yet.' };
1254
+ } catch (err) {
1255
+ return { text: `Facts feed unreadable: ${err.code || err.message}`, isError: true };
1256
+ }
1257
+ }
1258
+
952
1259
  const results = searchMemory(context_hint);
953
1260
  if (results.length === 0) return { text: `No memories matching: ${context_hint}` };
954
1261
  return { text: results.map(r => `[${r.source}] ${r.content}`).join('\n') };
955
1262
  }
956
1263
 
1264
+ // v1.5.1 R4-H2 — wire the v1.5.0 memory-moat to the real write path.
1265
+ //
1266
+ // M1 (Obsidian wikilink/tag/meta indexing -> memory_links/_tags/_meta) and
1267
+ // M2 (A-Mem auto-linking) only fire inside memory/fts5.js#indexEntry. But the
1268
+ // production memory writers never called indexEntry: handleStore wrote the
1269
+ // markdown journal only, and search.js#autoIndex did a raw INSERT. So the
1270
+ // memory-moat's flagship — "memory that learns about you" — ran ONLY in the
1271
+ // benchmark harness. This helper routes a real ijfw_store through indexEntry,
1272
+ // which in one atomic INSERT also fires M1 (synchronous, idempotent) + M2
1273
+ // (fire-and-forget, env-gated via IJFW_AUTOLINK_OFF, budget-capped).
1274
+ //
1275
+ // Best-effort + fire-and-forget: handleStore stays synchronous and a missing
1276
+ // driver / unmigrated schema / DB error never breaks the markdown-or-JSONL
1277
+ // store path. The journal markdown remains the source of truth (hot tier);
1278
+ // the FTS5 row is the warm-tier mirror. Dedup safety: handleStore previously
1279
+ // did NO DB INSERT at all, so this is a NEW row, not a duplicate of an
1280
+ // existing write. search.js#autoIndex only batch-rebuilds when the FTS table
1281
+ // is empty (rowCount === 0) — it will skip an already-populated table — so a
1282
+ // store followed by a search cannot double-index the same entry.
1283
+ async function indexStoredEntryToFts5({ body, source, sessionId }) {
1284
+ if (typeof body !== 'string' || body.length === 0) return null;
1285
+ const fts5Mod = await import('./memory/fts5.js');
1286
+ const root = process.env.IJFW_PROJECT_DIR || PROJECT_DIR;
1287
+ const db = await fts5Mod.openDb(root);
1288
+ try {
1289
+ // indexEntry runs the ingest scrub gate + M1 indexObsidianRelations +
1290
+ // M2 autoLink internally. body is already sanitised + redacted by the
1291
+ // handleStore caller; the scrub gate re-running over already-clean text
1292
+ // is idempotent.
1293
+ const inserted = fts5Mod.indexEntry(db, {
1294
+ body,
1295
+ source: source || 'memory_store',
1296
+ session_id: sessionId || null,
1297
+ });
1298
+ // indexEntry dispatches M2 autoLink + the D2 graph auto-index as
1299
+ // fire-and-forget promises that still hold the db handle. We own the
1300
+ // handle here, so we MUST let those settle before closing the db —
1301
+ // otherwise autoLink races into a "database connection is not open"
1302
+ // error. Capture the promise references SYNCHRONOUSLY right after
1303
+ // indexEntry returns (no await in between) so an interleaved store
1304
+ // can't overwrite the module-level statics before we read them. Both
1305
+ // promises swallow their own failures, so awaiting them never rejects.
1306
+ // This keeps M2 wired on the real store path without changing
1307
+ // handleStore's fire-and-forget contract (the caller already treats
1308
+ // this whole function as fire-and-forget).
1309
+ const autoLinkP = fts5Mod.indexEntry.__lastAutoLinkPromise;
1310
+ const autoIndexP = typeof fts5Mod.__getLastAutoIndexPromise === 'function'
1311
+ ? fts5Mod.__getLastAutoIndexPromise()
1312
+ : null;
1313
+ try { await autoLinkP; } catch { /* swallowed by indexEntry */ }
1314
+ try { await autoIndexP; } catch { /* swallowed by indexEntry */ }
1315
+ return inserted;
1316
+ } finally {
1317
+ try { fts5Mod.closeDb(db); } catch { /* best-effort */ }
1318
+ }
1319
+ }
1320
+
1321
+ // Diagnostic hook for tests — holds the most recent FTS5/M1/M2 indexing
1322
+ // promise fired by handleStore so end-to-end tests can await deterministic
1323
+ // completion before asserting on memory_links / memory_tags. Production
1324
+ // callers do not read this.
1325
+ handleStore.__lastIndexPromise = null;
1326
+
957
1327
  function handleStore({ content, type, tags = [], summary, why, how_to_apply }) {
958
1328
  // --- Input Validation ---
959
1329
  if (!content || typeof content !== 'string') {
@@ -986,23 +1356,94 @@ function handleStore({ content, type, tags = [], summary, why, how_to_apply }) {
986
1356
 
987
1357
  // Sanitize ALL text fields -- never store raw user/agent text in markdown
988
1358
  // that gets re-injected into a future LLM context.
989
- const safeContent = sanitizeContent(content);
1359
+ //
1360
+ // v1.5.1 R4-H3 — secret redaction. sanitizeContent strips prompt-injection
1361
+ // control chars but does NOT scrub secret-shaped tokens (API keys, OAuth
1362
+ // secrets). Without this, a secret pasted into a direct ijfw_store call
1363
+ // lands in .ijfw/memory/*.md cleartext and re-injects into every future
1364
+ // recall. The redactor is already wired into the FTS5 ingest path
1365
+ // (memory/fts5.js#indexEntry) and the auto-memorize path; this closes the
1366
+ // direct MCP store as the one remaining bypass. Redact AFTER sanitize so
1367
+ // the redaction labels ([REDACTED:*]) are never themselves scrubbed.
1368
+ const safeContent = redactSecrets(sanitizeContent(content));
990
1369
  if (!safeContent) {
991
1370
  return { text: 'content was empty after sanitisation (only control/format chars).', isError: true };
992
1371
  }
993
- const safeSummary = summary ? sanitizeContent(summary).substring(0, 120) : '';
994
- const safeWhy = why ? sanitizeContent(why) : '';
995
- const safeHow = how_to_apply ? sanitizeContent(how_to_apply) : '';
1372
+ const safeSummary = summary ? redactSecrets(sanitizeContent(summary)).substring(0, 120) : '';
1373
+ const safeWhy = why ? redactSecrets(sanitizeContent(why)) : '';
1374
+ const safeHow = how_to_apply ? redactSecrets(sanitizeContent(how_to_apply)) : '';
996
1375
 
997
1376
  const tagStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
998
1377
  const journalEntry = `**${type}**${tagStr}: ${safeSummary || safeContent.substring(0, 200)}`;
999
1378
 
1379
+ // H5.6 — Semantic dedup BEFORE append. If this memory is a near-duplicate
1380
+ // of one already in the last N journal entries, short-circuit and return
1381
+ // the existing entry's id. handoff is exempt (always overwrites a single
1382
+ // file by design; deduping would silently drop a handoff swap).
1383
+ const dedupCfg = readDedupConfig();
1384
+ if (dedupCfg.enabled && type !== 'handoff') {
1385
+ const recents = getRecentMemoriesForDedup(dedupCfg.windowSize);
1386
+ // Dedup against the FULL journal-entry line shape -- that's what the next
1387
+ // call would see in `recents`, so comparing apples to apples.
1388
+ const dup = findNearDuplicate(journalEntry, recents);
1389
+ if (dup) {
1390
+ // Spec: emit a stderr line so the user/agent sees the elision.
1391
+ try {
1392
+ process.stderr.write(`[ijfw memory] dedup'd similar entry; keeping prior ${dup.match.id}\n`);
1393
+ } catch { /* stderr may be detached in test harness */ }
1394
+ return {
1395
+ text: `Dedup'd: similar memory already exists (${dup.match.id}, similarity=${dup.similarity.toFixed(2)}). Not appended.`,
1396
+ deduped: true,
1397
+ existing_id: dup.match.id,
1398
+ similarity: dup.similarity,
1399
+ };
1400
+ }
1401
+ }
1402
+
1000
1403
  // 1. Always append to journal (one-line timeline). Hard failure → report.
1001
1404
  const journalResult = appendToJournal(journalEntry);
1002
1405
  if (!journalResult.ok) {
1003
1406
  return { text: `Memory journal is not writable (${journalResult.code}) -- check .ijfw/ directory permissions and retry.`, isError: true };
1004
1407
  }
1005
1408
 
1409
+ // v1.5.1 R4-H2 — mirror the stored entry into the FTS5 warm tier, which
1410
+ // also fires M1 (Obsidian indexing) + M2 (A-Mem auto-linking). Index the
1411
+ // full content (already sanitised + redacted above) so [[wikilinks]],
1412
+ // #tags and [key:: value] metadata land in memory_links/_tags/_meta and
1413
+ // the auto-linker sees the real body. Fire-and-forget: handleStore stays
1414
+ // synchronous and a DB failure never breaks the markdown store. The
1415
+ // promise is exposed for tests that need deterministic completion.
1416
+ try {
1417
+ handleStore.__lastIndexPromise = indexStoredEntryToFts5({
1418
+ body: safeContent,
1419
+ source: `memory_store:${type}`,
1420
+ sessionId: null,
1421
+ }).catch((e) => {
1422
+ try { console.error('[ijfw memory] FTS5/M1/M2 index failed:', e?.message || e); } catch { /* never throw */ }
1423
+ return null;
1424
+ });
1425
+ } catch (e) {
1426
+ handleStore.__lastIndexPromise = null;
1427
+ try { console.error('[ijfw memory] FTS5 index dispatch failed:', e?.message || e); } catch { /* never throw */ }
1428
+ }
1429
+
1430
+ // H5.5 — Fact extraction AFTER successful append. Best-effort: a failure
1431
+ // here is logged in the return text but does NOT poison the store result.
1432
+ // Memory-id ties facts.jsonl rows back to their journal entry.
1433
+ const factMeta = {
1434
+ ts: new Date().toISOString(),
1435
+ memory_id: factMemoryIdFor(journalEntry),
1436
+ source: `memory_store:${type}`,
1437
+ };
1438
+ const facts = extractFacts(safeContent);
1439
+ appendFactsToSidecar(facts, factMeta);
1440
+ // v1.5.0 audit H5.4 — mirror to bi-temporal SQL store. For each fact,
1441
+ // closes any prior currently-valid fact with the same (subject, predicate)
1442
+ // but different object before inserting. Same-object stores are a no-op.
1443
+ // Wrapped in a per-fact transaction inside temporal.js. Best-effort: any
1444
+ // failure is logged to stderr but never breaks the journal-or-JSONL path.
1445
+ writeFactsBitemporal(facts, factMeta);
1446
+
1006
1447
  // 2. Type-specific secondary writes. Each tracked so we report partial
1007
1448
  // success accurately rather than lying about "stored."
1008
1449
  const failures = [];
@@ -1107,22 +1548,60 @@ async function handlePrelude({ detail_level = 'summary' } = {}) {
1107
1548
  const updateNudge = composeUpdateNudge();
1108
1549
  if (updateNudge) parts.push(updateNudge, '');
1109
1550
 
1551
+ // v1.5.0 memory-moat M3 (INT.4): surface top-K recently-successful skills
1552
+ // at session start. The Wayland-pattern "more-you-use-it-better-it-gets"
1553
+ // feedback loop: every skill execution writes to skill_telemetry via the
1554
+ // state-SDK telemetry.record verb (INT.3); this block reads top-5 by
1555
+ // success-count and surfaces them as a hint to the model. Best-effort:
1556
+ // an unmigrated db, empty telemetry, or read failure all skip the block
1557
+ // silently — never breaks the prelude.
1558
+ try {
1559
+ const { topKSuccessfulSkills } = await import('./orchestrator/skill-telemetry.js');
1560
+ const Database = (await import('better-sqlite3')).default;
1561
+ const { join: joinP } = await import('node:path');
1562
+ const root = process.env.IJFW_PROJECT_DIR || process.cwd();
1563
+ const dbPath = joinP(root, '.ijfw', 'index', 'memory.db');
1564
+ if (existsSync(dbPath)) {
1565
+ const db = new Database(dbPath, { readonly: true });
1566
+ try {
1567
+ const top = topKSuccessfulSkills(db, { k: 5 });
1568
+ if (top.length > 0) {
1569
+ const names = top.map((r) => `${r.skill_id} (${r.success_count}×)`).join(', ');
1570
+ parts.push(
1571
+ '<ijfw-recommended-skills>',
1572
+ `Observed success this project: ${names}`,
1573
+ '</ijfw-recommended-skills>',
1574
+ '',
1575
+ );
1576
+ }
1577
+ } finally {
1578
+ try { db.close(); } catch { /* best-effort */ }
1579
+ }
1580
+ }
1581
+ } catch { /* best-effort; never block the prelude */ }
1582
+
1110
1583
  // 1.2.0 Phase 5: surface the DESIGN picker to platforms without a skills tree.
1111
1584
  // Skip when the project already has a DESIGN.md (contract exists; no picker).
1585
+ // Built into a standalone block so the abstention path below can re-emit it:
1586
+ // a fresh project with no memory AND no DESIGN.md is exactly when the picker
1587
+ // matters most, so it must survive the thin-memory short-circuit.
1588
+ let designPickerBlock = '';
1112
1589
  try {
1113
1590
  if (!existsSync(join(PROJECT_DIR, 'DESIGN.md'))) {
1114
1591
  const names = DESIGN_TEMPLATE_CATALOG.map(([n]) => n);
1115
- parts.push('## Design picker');
1116
- parts.push('No DESIGN.md in project. 12 curated templates available:');
1117
- parts.push(names.slice(0, 5).join(', ') + ',');
1118
- parts.push(names.slice(5, 10).join(', ') + ',');
1119
- parts.push(names.slice(10).join(', ') + '.');
1120
- parts.push('');
1121
- parts.push('Pick one: ijfw_memory_recall({context_hint: "design_template:<name>"}).');
1122
- parts.push('Full catalog with descriptions: ijfw_memory_recall({context_hint: "design_template"}).');
1123
- parts.push('');
1592
+ designPickerBlock = [
1593
+ '## Design picker',
1594
+ 'No DESIGN.md in project. 12 curated templates available:',
1595
+ names.slice(0, 5).join(', ') + ',',
1596
+ names.slice(5, 10).join(', ') + ',',
1597
+ names.slice(10).join(', ') + '.',
1598
+ '',
1599
+ 'Pick one: ijfw_memory_recall({context_hint: "design_template:<name>"}).',
1600
+ 'Full catalog with descriptions: ijfw_memory_recall({context_hint: "design_template"}).',
1601
+ ].join('\n');
1124
1602
  }
1125
- } catch { /* cwd unreadable -- skip picker block */ }
1603
+ } catch { /* project dir unreadable -- skip picker block */ }
1604
+ if (designPickerBlock) parts.push(designPickerBlock, '');
1126
1605
 
1127
1606
  // Team knowledge first -- shared decisions/patterns/stack rank above personal.
1128
1607
  const team = readTeamKnowledge();
@@ -1221,16 +1700,51 @@ async function handlePrelude({ detail_level = 'summary' } = {}) {
1221
1700
  // Best-effort; never fail the prelude on memory-feedback issues.
1222
1701
  }
1223
1702
 
1703
+ // v1.5.0 audit-MED-update-M8 (F-REL-2): surface the last-N partial-deploy
1704
+ // alerts so a half-deployed extension is visible at next session-start.
1705
+ // Wrapped in try/catch — alert read failure must NEVER fail the prelude.
1706
+ try {
1707
+ const { renderDeployAlertsForPrelude } = await import('./deploy-alerts.js');
1708
+ const block = await renderDeployAlertsForPrelude({ limit: 10 });
1709
+ if (block && typeof block === 'string' && block.length > 0) {
1710
+ parts.push(block);
1711
+ }
1712
+ } catch {
1713
+ // Best-effort; never fail the prelude on deploy-alert read issues.
1714
+ }
1715
+
1224
1716
  parts.push('</ijfw-memory>');
1225
1717
 
1226
1718
  const text = parts.join('\n');
1227
1719
  if (text.length < 60) {
1228
1720
  return { text: 'Fresh project -- no memory stored yet. Proceed normally.' };
1229
1721
  }
1722
+
1723
+ // v1.5.0 audit MED #11 (memory-engine.md F-FUN-7): abstention.
1724
+ // If memory exists but the body content is thin AND there are no recent
1725
+ // journal entries, surface an honest abstention so the LLM doesn't try
1726
+ // to over-fit a half-page of stale frontmatter to the user's prompt.
1727
+ // Threshold: total content chars below MIN_CONTENT_CHARS for the
1728
+ // knowledge/team/handoff sources combined AND zero recent journal lines.
1729
+ const MIN_CONTENT_CHARS = 200;
1730
+ const knowledgeChars = (knowledge ? knowledge.length : 0) + (team ? team.length : 0) + (handoff ? handoff.length : 0);
1731
+ const recentLines = recent ? recent.split('\n').filter(l => l.trim()).length : 0;
1732
+ if (knowledgeChars < MIN_CONTENT_CHARS && recentLines === 0) {
1733
+ const abstain = [
1734
+ '<ijfw-memory>',
1735
+ 'Memory present but nothing relevant to your prompt -- proceed and I\'ll store any decisions you make.',
1736
+ ];
1737
+ // The DESIGN picker is independent of memory richness — preserve it
1738
+ // through the abstention path, otherwise a fresh project never sees it.
1739
+ if (designPickerBlock) abstain.push('', designPickerBlock);
1740
+ abstain.push('</ijfw-memory>');
1741
+ return { text: abstain.join('\n') };
1742
+ }
1743
+
1230
1744
  return { text };
1231
1745
  }
1232
1746
 
1233
- function handleSearch({ query, limit = 10, scope = 'project', label }) {
1747
+ async function handleSearch({ query, limit = 10, scope = 'project', label }) {
1234
1748
  if (scope === 'sandbox') {
1235
1749
  if (label) {
1236
1750
  const content = readFromSandbox(label);
@@ -1258,7 +1772,49 @@ function handleSearch({ query, limit = 10, scope = 'project', label }) {
1258
1772
  }
1259
1773
  if (query.length > 500) query = query.substring(0, 500);
1260
1774
  if (scope !== 'project' && scope !== 'all') scope = 'project';
1261
- const results = searchMemory(query, limit, scope);
1775
+
1776
+ // v1.5.0 memory-moat M1 (INT.5): "dv:" prefix routes to the declarative
1777
+ // Dataview-grade query mode. Returns structured rows from the FTS5 +
1778
+ // memory_links/_tags/_meta join populated at write time by M1.3 /
1779
+ // INT.1. Best-effort: errors fall through to the standard NL/FTS5 path
1780
+ // so the new mode never breaks existing callers.
1781
+ if (query.startsWith('dv:')) {
1782
+ try {
1783
+ const body = query.slice(3).trim();
1784
+ const dvMod = await import('./memory/query-dataview.js');
1785
+ const fts5Mod = await import('./memory/fts5.js');
1786
+ const root = process.env.IJFW_PROJECT_DIR || process.cwd();
1787
+ const db = await fts5Mod.openDb(root);
1788
+ try {
1789
+ const parsed = dvMod.parseDataviewQuery(body);
1790
+ const result = dvMod.runDataviewQuery(db, parsed);
1791
+ const rows = result.rows.slice(0, limit);
1792
+ if (rows.length === 0) {
1793
+ return { text: `No results for dataview query: "${body}"`, mode: 'dataview', parsed };
1794
+ }
1795
+ // Render in the existing text shape so MCP clients that expect
1796
+ // a single string field keep working.
1797
+ return {
1798
+ text: rows
1799
+ .map((r) => `[id:${r.id} source:${r.source || '?'} created:${r.created_at}] ${(r.body || '').slice(0, 200)}`)
1800
+ .join('\n'),
1801
+ mode: 'dataview',
1802
+ parsed,
1803
+ rowCount: rows.length,
1804
+ };
1805
+ } finally {
1806
+ try { fts5Mod.closeDb(db); } catch { /* best-effort */ }
1807
+ }
1808
+ } catch (e) {
1809
+ return {
1810
+ text: `Dataview query failed: ${e && e.message ? e.message : String(e)}`,
1811
+ mode: 'dataview',
1812
+ isError: true,
1813
+ };
1814
+ }
1815
+ }
1816
+
1817
+ const results = await searchMemory(query, limit, scope);
1262
1818
  if (results.length === 0) {
1263
1819
  const where = scope === 'all' ? ' across all projects' : '';
1264
1820
  return { text: `No results for: "${query}"${where}` };
@@ -1374,32 +1930,6 @@ function handleMetrics({ period = '7d', metric = 'tokens' } = {}) {
1374
1930
  return { text: out.join('\n') };
1375
1931
  }
1376
1932
 
1377
- function handleStatus() {
1378
- const sessionCount = getSessionCount();
1379
- const decisionCount = getDecisionCount();
1380
- const hasKnowledge = existsSync(join(MEMORY_DIR, 'knowledge.md'));
1381
- const hasHandoff = existsSync(join(MEMORY_DIR, 'handoff.md'));
1382
- const hasGlobal = readGlobalKnowledge().trim().length > 0;
1383
-
1384
- const parts = [];
1385
- if (hasKnowledge) {
1386
- const kb = readKnowledgeBase();
1387
- const kbLines = kb.split('\n').filter(l => l.trim().startsWith('**')).length;
1388
- parts.push(`Knowledge: ${kbLines} entries`);
1389
- }
1390
- if (sessionCount > 0 || decisionCount > 0) {
1391
- parts.push(`History: ${sessionCount} sessions, ${decisionCount} decisions`);
1392
- }
1393
- if (hasHandoff) {
1394
- const handoff = readHandoff();
1395
- const statusLine = handoff.split('\n').find(l => l.trim().length > 0 && !l.startsWith('<!--') && !l.startsWith('#'));
1396
- if (statusLine) parts.push(`Last: ${statusLine.trim().substring(0, 150)}`);
1397
- }
1398
- if (hasGlobal) parts.push('Project preferences loaded');
1399
-
1400
- return { text: parts.join('\n') || 'Fresh project -- no memory yet.' };
1401
- }
1402
-
1403
1933
  // --- MCP Protocol Handler (JSON-RPC 2.0 over stdio) ---
1404
1934
 
1405
1935
  function createResponse(id, result) {
@@ -1471,11 +2001,133 @@ function handleMessage(msg) {
1471
2001
  result = { text: JSON.stringify(r, null, 2), isError: !!(r && r.error) };
1472
2002
  break;
1473
2003
  }
2004
+ case 'ijfw_state': {
2005
+ // v1.5.0 T13: single MCP face for the state-SDK. Routes every call
2006
+ // into the same `query(verb, payload, ctx)` core that the JS module
2007
+ // (`./orchestrator/state-sdk.js`) and the CLI colon-namespace
2008
+ // (`ijfw state:<verb>`) use. The retired `ijfw_subagent_post_done`
2009
+ // tool is now reachable as the `subagent.post-done` verb.
2010
+ const a = args || {};
2011
+ if (typeof a.verb !== 'string' || a.verb.length === 0) {
2012
+ result = { text: JSON.stringify({ ok: false, error: 'verb (string) is required' }), isError: true };
2013
+ break;
2014
+ }
2015
+ try {
2016
+ const { query } = await import('./orchestrator/state-sdk.js');
2017
+ const payload = (a.payload && typeof a.payload === 'object') ? a.payload : {};
2018
+ const ctx = {
2019
+ projectRoot: typeof a.projectRoot === 'string' && a.projectRoot.length > 0
2020
+ ? a.projectRoot
2021
+ : process.cwd(),
2022
+ };
2023
+ if (typeof a.subagentId === 'string' && a.subagentId.length > 0) ctx.subagentId = a.subagentId;
2024
+ if (typeof a.homeDir === 'string' && a.homeDir.length > 0) ctx.homeDir = a.homeDir;
2025
+ const r = await query(a.verb, payload, ctx);
2026
+ // A verdict-fail refusal (Model 4) is the verb's correct hard-block —
2027
+ // surface `isError: true` so the orchestrator-LLM treats it as a
2028
+ // hard stop rather than an advisory note (mirrors the prior
2029
+ // ijfw_subagent_post_done `block: true` contract).
2030
+ const refused = r && r.refused === true;
2031
+ result = { text: JSON.stringify(r, null, 2), isError: !!refused };
2032
+ } catch (err) {
2033
+ const msg = err && err.message ? err.message : String(err);
2034
+ result = { text: JSON.stringify({ ok: false, error: msg }), isError: true };
2035
+ }
2036
+ break;
2037
+ }
1474
2038
  case 'ijfw_update_apply': {
1475
2039
  const r = ijfwUpdateApply(args || {});
1476
2040
  result = { text: JSON.stringify(r, null, 2), isError: r && r.status === 'error' };
1477
2041
  break;
1478
2042
  }
2043
+ case 'ijfw_cross_audit_converge': {
2044
+ // v1.5.0-major W12-C N03: Trident-as-a-service.
2045
+ const a = args || {};
2046
+ if (!a.commitRange || typeof a.commitRange !== 'string') {
2047
+ result = { text: JSON.stringify({ error: 'commitRange (string) is required' }), isError: true };
2048
+ break;
2049
+ }
2050
+ const { runPhaseEConverge, defaultConvergeDispatch } = await import('./cross-orchestrator.js');
2051
+ try {
2052
+ const r = await runPhaseEConverge({
2053
+ commitRange: a.commitRange,
2054
+ maxIterations: typeof a.maxIterations === 'number' ? a.maxIterations : 3,
2055
+ lenses: Array.isArray(a.lenses) && a.lenses.length > 0 ? a.lenses : undefined,
2056
+ dispatch: defaultConvergeDispatch,
2057
+ projectRoot: process.cwd(),
2058
+ // v1.5.1 R4-H4 — opt-in consensus auto-fix (T27). Threaded
2059
+ // from the tool schema so the code-fixer can genuinely fire;
2060
+ // default false so the audit stays non-mutating unless the
2061
+ // caller explicitly asks for it.
2062
+ autoFix: a.autoFix === true,
2063
+ });
2064
+ const isErr = r.verdict === 'consensus_failed' || r.verdict === 'FAIL' || r.verdict === 'UNREACHABLE';
2065
+ // v1.5.1 W2.D — emit a canonical gate-result block through the
2066
+ // gate-result-formatter so this Trident-as-a-service surface is
2067
+ // consistent with the Trident gate (dispatch.js) and preflight
2068
+ // gates. appendGateResult guarantees the fenced block is the
2069
+ // LAST content emitted and is idempotent. Failure to format the
2070
+ // block must NOT clobber the verdict payload — observability,
2071
+ // not correctness.
2072
+ let convergeText = JSON.stringify(r, null, 2);
2073
+ try {
2074
+ const { emitGateResult } = await import('./gate-result.js');
2075
+ const { appendGateResult } = await import('./gate-result-formatter.js');
2076
+ // Map the converge verdict onto a schema-valid gate status.
2077
+ const VERDICT_TO_STATUS = {
2078
+ PASS: 'PASS',
2079
+ CONDITIONAL: 'CONDITIONAL',
2080
+ WARN: 'WARN',
2081
+ FLAG: 'FLAG',
2082
+ FAIL: 'FAIL',
2083
+ consensus_failed: 'FAIL',
2084
+ UNREACHABLE: 'FAIL',
2085
+ INCONCLUSIVE: 'FLAG',
2086
+ };
2087
+ const gateStatus = VERDICT_TO_STATUS[r.verdict] || 'FLAG';
2088
+ const block = await emitGateResult(
2089
+ {
2090
+ gate: 'cross-audit',
2091
+ status: gateStatus,
2092
+ lenses: [],
2093
+ affected_artifacts: [],
2094
+ accounting: {
2095
+ duration_ms:
2096
+ typeof r.duration_ms === 'number' ? r.duration_ms : 0,
2097
+ lenses_invoked: Array.isArray(a.lenses)
2098
+ ? a.lenses.length
2099
+ : 0,
2100
+ cost_usd: null,
2101
+ },
2102
+ remediation: [],
2103
+ },
2104
+ { projectRoot: process.cwd() },
2105
+ );
2106
+ // emitGateResult returns the fenced block as a string; the
2107
+ // formatter validates it back into an object before append.
2108
+ const parsed = JSON.parse(
2109
+ block.replace(/^```gate-result\n/, '').replace(/\n```$/, ''),
2110
+ );
2111
+ convergeText = appendGateResult(convergeText, parsed);
2112
+ } catch (gateErr) {
2113
+ try {
2114
+ const msg =
2115
+ gateErr && gateErr.message
2116
+ ? gateErr.message
2117
+ : String(gateErr);
2118
+ process.stderr.write(
2119
+ `ijfw: cross_audit_converge gate-result emit failed: ${msg}\n`,
2120
+ );
2121
+ } catch {
2122
+ /* never crash the tool on a logging-channel failure */
2123
+ }
2124
+ }
2125
+ result = { text: convergeText, isError: isErr };
2126
+ } catch (err) {
2127
+ result = { text: JSON.stringify({ error: err && err.message ? err.message : String(err) }), isError: true };
2128
+ }
2129
+ break;
2130
+ }
1479
2131
  case 'ijfw_memory_recall':
1480
2132
  result = handleRecall(args || {});
1481
2133
  emitRecallObservation(args || {});
@@ -1504,15 +2156,18 @@ function handleMessage(msg) {
1504
2156
  break;
1505
2157
  }
1506
2158
  }
1507
- result = handleSearch(searchArgs);
2159
+ result = await handleSearch(searchArgs);
1508
2160
  break;
1509
2161
  }
1510
- case 'ijfw_memory_status':
1511
- result = handleStatus();
1512
- break;
1513
2162
  case 'ijfw_memory_prelude':
1514
2163
  result = await handlePrelude(args || {});
1515
2164
  break;
2165
+ case 'ijfw_memory_facts': {
2166
+ // v1.5.0 M5 (INT.6) -- surface bi-temporal facts read path.
2167
+ const mod = await import('./memory-facts-handler.js');
2168
+ result = await mod.handleMemoryFacts(args || {});
2169
+ break;
2170
+ }
1516
2171
  case 'ijfw_metrics':
1517
2172
  result = handleMetrics(args || {});
1518
2173
  break;
@@ -1616,6 +2271,27 @@ function handleMessage(msg) {
1616
2271
  }
1617
2272
  }
1618
2273
 
2274
+ // --- B17 WebSocket revocation client (dynamic-import gate) ---
2275
+ // extension-registry-ws.js is dormant by default. Its docstring contract:
2276
+ // "Imported via `await import(...)` ONLY when `process.env.IJFW_REGISTRY_WS_URL`
2277
+ // is set at startup." Firing the gate here at MCP startup keeps the module out
2278
+ // of the import graph entirely unless the operator opts in via the env var, so
2279
+ // MCP startup never opens a socket for the common (unset) case. Best-effort:
2280
+ // a failed WS bind must never block the stdio transport.
2281
+ if (process.env.IJFW_REGISTRY_WS_URL || process.env.IJFW_REGISTRY_WS_SOURCE) {
2282
+ (async () => {
2283
+ try {
2284
+ const { initWsClient } = await import('./extension-registry-ws.js');
2285
+ const res = await initWsClient();
2286
+ if (!res.ok) {
2287
+ process.stderr.write(`IJFW: WS revocation client not started: ${res.error}\n`);
2288
+ }
2289
+ } catch (err) {
2290
+ process.stderr.write(`IJFW: WS revocation client init failed: ${err && err.message ? err.message : err}\n`);
2291
+ }
2292
+ })();
2293
+ }
2294
+
1619
2295
  // --- stdio Transport ---
1620
2296
  const rl = createInterface({ input: process.stdin, terminal: false });
1621
2297
 
@@ -1665,4 +2341,11 @@ process.on('unhandledRejection', (err) => {
1665
2341
  // Export for tests (Node ESM allows this -- only consumed when imported, not on stdio run)
1666
2342
  // gatePermissionAndQuota is exported inline at its declaration above (B16/SEC-M-03)
1667
2343
  // so test-server-quota-integration.js can drive it without spinning a server.
1668
- export { sanitizeContent, atomicWrite, readMarkdownFile, PROJECT_HASH };
2344
+ // H5.5 / H5.6 expose handleStore/handleRecall + path/helpers so the ingest
2345
+ // integration test can drive the full pipeline without spawning a subprocess.
2346
+ export {
2347
+ sanitizeContent, atomicWrite, readMarkdownFile, PROJECT_HASH,
2348
+ handleStore, handleRecall, handleSearch, handlePrelude,
2349
+ MEMORY_DIR, FACTS_FILE, FACTS_DB_FILE,
2350
+ getFactsDb,
2351
+ };