@oomkapwn/enquire-mcp 3.10.0-rc.2 → 3.10.0-rc.20

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 (72) hide show
  1. package/CHANGELOG.md +506 -0
  2. package/README.md +9 -6
  3. package/SECURITY.md +2 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +136 -7
  6. package/dist/cli.js.map +1 -1
  7. package/dist/doctor.d.ts +18 -1
  8. package/dist/doctor.d.ts.map +1 -1
  9. package/dist/doctor.js +15 -6
  10. package/dist/doctor.js.map +1 -1
  11. package/dist/dql.d.ts.map +1 -1
  12. package/dist/dql.js +7 -1
  13. package/dist/dql.js.map +1 -1
  14. package/dist/embed-db.d.ts +14 -0
  15. package/dist/embed-db.d.ts.map +1 -1
  16. package/dist/embed-db.js +30 -7
  17. package/dist/embed-db.js.map +1 -1
  18. package/dist/embed-pipeline.d.ts.map +1 -1
  19. package/dist/embed-pipeline.js +8 -2
  20. package/dist/embed-pipeline.js.map +1 -1
  21. package/dist/embeddings.d.ts +41 -2
  22. package/dist/embeddings.d.ts.map +1 -1
  23. package/dist/embeddings.js +62 -4
  24. package/dist/embeddings.js.map +1 -1
  25. package/dist/fts5.d.ts +15 -0
  26. package/dist/fts5.d.ts.map +1 -1
  27. package/dist/fts5.js +25 -0
  28. package/dist/fts5.js.map +1 -1
  29. package/dist/hnsw.d.ts.map +1 -1
  30. package/dist/hnsw.js +16 -3
  31. package/dist/hnsw.js.map +1 -1
  32. package/dist/http-transport.d.ts +22 -0
  33. package/dist/http-transport.d.ts.map +1 -1
  34. package/dist/http-transport.js +57 -61
  35. package/dist/http-transport.js.map +1 -1
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +1 -1
  39. package/dist/index.js.map +1 -1
  40. package/dist/parser.d.ts +6 -0
  41. package/dist/parser.d.ts.map +1 -1
  42. package/dist/parser.js +8 -0
  43. package/dist/parser.js.map +1 -1
  44. package/dist/server.d.ts +7 -0
  45. package/dist/server.d.ts.map +1 -1
  46. package/dist/server.js +42 -52
  47. package/dist/server.js.map +1 -1
  48. package/dist/shutdown.d.ts +41 -0
  49. package/dist/shutdown.d.ts.map +1 -0
  50. package/dist/shutdown.js +60 -0
  51. package/dist/shutdown.js.map +1 -0
  52. package/dist/staleness.d.ts +28 -0
  53. package/dist/staleness.d.ts.map +1 -1
  54. package/dist/staleness.js +38 -3
  55. package/dist/staleness.js.map +1 -1
  56. package/dist/tool-registry.d.ts +11 -1
  57. package/dist/tool-registry.d.ts.map +1 -1
  58. package/dist/tool-registry.js +18 -6
  59. package/dist/tool-registry.js.map +1 -1
  60. package/dist/tools/meta.d.ts.map +1 -1
  61. package/dist/tools/meta.js +11 -7
  62. package/dist/tools/meta.js.map +1 -1
  63. package/dist/tools/search.d.ts +103 -2
  64. package/dist/tools/search.d.ts.map +1 -1
  65. package/dist/tools/search.js +240 -6
  66. package/dist/tools/search.js.map +1 -1
  67. package/dist/watcher.d.ts.map +1 -1
  68. package/dist/watcher.js +14 -0
  69. package/dist/watcher.js.map +1 -1
  70. package/docs/COMPARISON.md +18 -3
  71. package/docs/api.md +12 -5
  72. package/package.json +8 -5
package/CHANGELOG.md CHANGED
@@ -2,6 +2,512 @@
2
2
 
3
3
  All notable changes to this project will be documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
+ ## [3.10.0-rc.20] — 2026-06-06
6
+
7
+ > **TL;DR:** **Audit MED-batch 5 — M7 privacy / right-to-erasure.** Three privacy-hardening fixes: (1) the HNSW persist **base** was computed independently by the WRITER (`server.ts`) and the ERASER (`EmbedDb.clearOnDisk`) — a duplication that, on drift, would leave the `.hnsw.bin` / `.hnsw.meta.json` sidecars (the meta sidecar carries raw `text_preview`) on disk after `clear-embeddings`, a right-to-erasure gap (the rc.34 P-2 class via a different seam); now both route through ONE shared `hnswPersistBase()` helper, and the erasure-completeness invariant asserts it. (2) The `--watch` handler `handle()` now re-checks `vault.isExcluded()` per file as **defense-in-depth** (chokidar's `ignored` predicate already drops excluded paths; this guards the case where `handle()` is reached another way — mirrors the existing PDF re-check). (3) `SECURITY.md` now documents that privacy filters are **not retroactive** for content already at rest — adding `--exclude-glob` hides matching notes from results immediately (terminal `isExcluded()` filter) but does NOT erase the chunk already written to `.fts5.db` / `.embed.db`; that needs `clear-index` / `clear-embeddings` + rebuild. **1119 → 1124 tests.** M2/M10 docs → rc.21.
8
+
9
+ **Pre-release (v3.10 line) — audit fix batch 5 (M7).**
10
+
11
+ ### Fixed
12
+
13
+ - **M7.1 — shared HNSW persist-base helper (right-to-erasure anti-drift).** `server.ts` (writer) derived `persistFile` as `` `${embedFile.replace(/\.embed\.db$/, "")}.hnsw` `` while `EmbedDb.clearOnDisk` (eraser) recomputed the identical expression independently. If either changed, `clear-embeddings` would erase the wrong sidecar path and leave HNSW files (incl. `.hnsw.meta.json`'s raw `text_preview`) on disk. New exported `hnswPersistBase(embedDbFile)` is the single source of truth; both call sites route through it. The `erasure-completeness invariant` now (a) scans the helper for the `.hnsw` suffix (moved out of the eraser method body) and (b) asserts BOTH the eraser AND the writer call `hnswPersistBase` and that the writer no longer recomputes the base inline.
14
+ - **M7.2 — watcher `handle()` privacy defense-in-depth.** The chokidar `ignored` predicate already drops `--exclude-glob` / `--read-paths` paths before they reach the handler, but `handle()` now ALSO re-checks `vault.isExcluded(relPath)` and returns before any cache-invalidation / index / embed work — so a filtered note can't be indexed even if `handle()` is reached by a direct call or a chokidar edge case. Mirrors the existing defensive PDF re-check.
15
+
16
+ ### Docs
17
+
18
+ - **M7.3 — SECURITY.md: privacy filters are not retroactive for at-rest content.** Added a bullet to the `--read-paths` / `--exclude-glob` posture: a filter added *after* a note was indexed hides it from all tool results immediately (same `isExcluded()` predicate gates search/read/walker) but does NOT erase the copy already on disk (`.fts5.db`, `.embed.db` `text_preview`, `.hnsw.meta.json`); purge via `clear-index` / `clear-embeddings` / `clear-cache` then rebuild. Also updated the "Watcher-aware" bullet to note the new `handle()` re-check.
19
+
20
+ ### Refactor
21
+
22
+ - `hnswPersistBase` lives in `src/embed-db.ts` (alongside `defaultEmbedDbFile`; it owns the `.embed.db` → `.hnsw` relation), imported by `server.ts`. No new import edge beyond server.ts's existing embed-db import; no cycle.
23
+
24
+ ### Tests (1124)
25
+
26
+ `tests/erasure-invariant.test.ts` +3: `hnswPersistBase` behavioral derivation (3 cases) + structural "eraser & writer both route through the helper, writer doesn't inline the base" + NEGATIVE control (the inline-base detector flags the pre-rc.20 shape). The manifest loop now scans `helperFns` so the `.hnsw` suffix is still verified after moving into the helper. `tests/watcher.test.ts` +2: `handle()` skips an excluded path before `invalidateOne` (the M7.2 fix) + POSITIVE control (a non-excluded path DOES reach `invalidateOne`; both build abs paths from the realpath-canonical `v.root` so handle()'s `path.relative` guard doesn't mask the result). 1119 → 1124.
27
+
28
+ ### Files changed
29
+
30
+ - `src/embed-db.ts` (new `hnswPersistBase`; `clearOnDisk` routes through it), `src/server.ts` (writer routes through it), `src/watcher.ts` (`handle()` isExcluded re-check), `SECURITY.md` (non-retroactive note + Watcher-aware update), `tests/erasure-invariant.test.ts` (+3 + `extractFn` + `helperFns` scan), `tests/watcher.test.ts` (+2), test-count claims → 1124.
31
+ - `scripts/check-per-file-coverage.mjs` — refreshed the stale `http-transport.ts` inline coverage comment (72.85% → 77.61%; rc.19's M3 handler removal raised it; caught by OIA Check 6 against the fresh `coverage-summary.json`).
32
+ - version bump 3.10.0-rc.19 → 3.10.0-rc.20.
33
+
34
+ ---
35
+
36
+ ## [3.10.0-rc.19] — 2026-06-06
37
+
38
+ > **TL;DR:** **Audit MED-batch 4 — M3 signal-shutdown race (both transports).** On `SIGINT`/`SIGTERM`, `serve-http` registered **four separate** listeners on the same signal — a cache-`flush` handler, `closeWatcher`, `closeFts`, and `shutdownHttpServer` — and the flush handler called `process.exit(0)` the **moment its fast cache flush resolved**, racing ahead of `shutdownHttpServer`'s up-to-5s in-flight-session drain and **cutting off in-flight requests**. The other three were pure duplication: `shutdownHttpServer` already flushes the cache and closes fts/watcher/embed-db. Fix: ONE `makeHttpShutdownHandler` orchestrator that **awaits** the full graceful teardown, then exits (re-entrancy-guarded). The stdio path (`startServer`) had the same shape (three handlers, the flush one calling `process.exit(0)` on its own — racing the async `watcher.close()`); consolidated into one `shutdownStdioDeps(deps)` that awaits watcher → embed-db → cache → fts before exit. `shutdownStdioDeps` was extracted to `src/shutdown.ts` so it's unit-testable (server.ts is in `no-internal-imports`' RESTRICTED_MODULES — same reason embed-pipeline.ts was split in rc.4). **1113 → 1119 tests.** M7 (privacy/erasure) → rc.20.
39
+
40
+ **Pre-release (v3.10 line) — audit fix batch 4 (M3).**
41
+
42
+ ### Fixed
43
+
44
+ - **M3 (HTTP) — the cache-flush SIGINT/SIGTERM handler raced the session drain.** `startHttpServer` registered FOUR listeners on each of `SIGINT`/`SIGTERM`: a persistent-cache `flush` (calling `process.exit(0)` in its `.finally`), `closeWatcher`, `closeFts`, and `shutdown` (= `shutdownHttpServer`). Because the flush's `saveDiskCache` is fast and the registry drain (`closeAll`, up to 5s) is slow, `process.exit(0)` fired **before** in-flight stateful requests finished — exactly the leak `shutdownHttpServer` (v3.8.7 P2-11) was built to prevent. New `makeHttpShutdownHandler(server, exit?)` returns a single, re-entrancy-guarded handler that `await`s `shutdownHttpServer` (drain → close TCP listener → flush cache → close fts/watcher/embed-db) and only THEN exits. The three redundant handlers are removed (their work is wholly subsumed by `shutdownHttpServer`); `beforeExit` keeps a guarded best-effort teardown for the natural-drain path.
45
+ - **M3 (stdio) — same shape.** `startServer` had three separate signal handlers and the cache-flush one called `process.exit(0)` on its own completion, racing the (async) `watcher.close()`. Consolidated into one orchestrator awaiting `shutdownStdioDeps(deps)` — which closes watcher + embed-db, flushes the persistent cache, then closes fts5, **awaiting each async step** (best-effort: a throw in one step never blocks the rest). The ordering is now deterministic and nothing exits mid-teardown.
46
+
47
+ ### Refactor
48
+
49
+ - **`shutdownStdioDeps` extracted to `src/shutdown.ts`.** `src/server.ts` is in the `no-internal-imports` RESTRICTED_MODULES list ("registration boilerplate"), so a helper there can't be imported by a test — the SAME constraint that drove the rc.4 embed-pipeline extraction. The new module declares a minimal structural `StdioShutdownDeps` interface locally (no import of `ServerDeps`) so there's zero import cycle with the server module; `ServerDeps` structurally satisfies it, so `startServer` passes `deps` directly.
50
+
51
+ ### Tests (1119)
52
+
53
+ `tests/http-transport.test.ts` +2: `makeHttpShutdownHandler` awaits full teardown before exit (asserts exit is NOT synchronous, the TCP listener is closed BEFORE exit fires, and a second signal is a re-entrancy no-op) + NEGATIVE control (a handler that skips the await "exits" while the listener is still up — proving the positive assertion depends on the await). `tests/shutdown.test.ts` (new) +4: ordering watcher→embed-db→cache→fts with awaited async steps; cache flush skipped when persistent cache disabled; best-effort (a throwing step doesn't block the rest); NEGATIVE control (a non-awaiting teardown records the sync "exit" step before the async one finishes). 1113 → 1119.
54
+
55
+ ### Files changed
56
+
57
+ - `src/http-transport.ts` (new `makeHttpShutdownHandler`; four signal handlers → one orchestrator + guarded `beforeExit`), `src/server.ts` (import `shutdownStdioDeps`; three handlers → one orchestrator; drop now-unused `vault`/`ftsIndex`/`watcher` destructuring), `src/shutdown.ts` (new — `StdioShutdownDeps` + `shutdownStdioDeps`), `tests/http-transport.test.ts` (+2), `tests/shutdown.test.ts` (new, +4), test-count claims → 1119.
58
+ - version bump 3.10.0-rc.18 → 3.10.0-rc.19.
59
+
60
+ ---
61
+
62
+ ## [3.10.0-rc.18] — 2026-06-06
63
+
64
+ > **TL;DR:** **Audit MED-batch 3 — M4 DoS-cap completeness (`obsidian_dataview_query` + invariant scope).** The audit flagged `obsidian_dataview_query` (`runDql`) as an uncapped whole-vault `readNote`+parse scan reachable over bearer `serve-http`. Root cause: the rc.36 `resource-bound-invariant`'s `SCANNER_SOURCES` covered `read.ts`/`search.ts`/`meta.ts` but NOT `dql.ts`, so `runDql` was never required to be CAP-or-EXEMPT (scope-too-narrow — the recurring class). Fix: cap `runDql` with `capScanEntries` (defense-in-depth — DQL is a *linear* query so a > MAX_SCAN_NOTES vault yields a partial, logged result, never a hang) + add `src/dql.ts` to `SCANNER_SOURCES` so the invariant patrols it. Also fixed a manifest drift my OWN rc.16 introduced: `getOpenQuestions` began calling `capScanEntries` in rc.16 but the manifest still listed it EXEMPT — reclassified CAPPED. **1113 tests unchanged.** M3 (signal-shutdown) → rc.19.
65
+
66
+ **Pre-release (v3.10 line) — audit fix batch 3 (M4).**
67
+
68
+ ### Fixed
69
+
70
+ - **M4 — `obsidian_dataview_query` whole-vault scan was uncapped AND invisible to the resource-bound invariant.** `runDql` (`src/dql.ts`) does `vault.listMarkdown()` → per-note `readNote` + frontmatter-eval; it's always-registered + bearer-reachable, but lived OUTSIDE the invariant's `SCANNER_SOURCES`, so the rc.36 "every whole-vault scanner is CAP-or-EXEMPT" completeness check never saw it. Now: the scan is wrapped in `capScanEntries` (defense-in-depth — DQL is O(N) linear, so a > MAX_SCAN_NOTES vault yields a partial result with a logged warning, not a hang), and `src/dql.ts` is added to `SCANNER_SOURCES`. The audit's "like the uncapped graph tools" framing is imprecise (graph tools are O(N²)/graph and MUST cap; DQL is linear) — but a cap is the right defense-in-depth for a bearer-reachable whole-vault tool, and closing the invariant scope is the structural fix.
71
+ - **rc.16 manifest drift — `getOpenQuestions` was CAPPED in code but EXEMPT in the manifest.** rc.16 (M5) added `capScanEntries` to `getOpenQuestions` but left its `resource-bound-invariant` classification EXEMPT. Reclassified CAPPED (with its `capScanEntries` token) so the manifest matches reality — a post-rc.16 recursion-sweep catch.
72
+
73
+ ### Tests (1113)
74
+
75
+ No new `it()` — the `resource-bound-invariant` now structurally covers `runDql` (CAPPED → must reference `capScanEntries`) and the corrected `getOpenQuestions` classification; the existing `dql.test.ts` exercises the > MAX_SCAN_NOTES cap path (logs the truncation). 1113 unchanged.
76
+
77
+ ### Files changed
78
+
79
+ - `src/dql.ts` (`capScanEntries` import + scan cap), `tests/resource-bound-invariant.test.ts` (`SCANNER_SOURCES` += `src/dql.ts`; `runDql` + `getOpenQuestions` → CAPPED; drop `getOpenQuestions` from EXEMPT).
80
+ - version bump 3.10.0-rc.17 → 3.10.0-rc.18.
81
+
82
+ ---
83
+
84
+ ## [3.10.0-rc.17] — 2026-06-06
85
+
86
+ > **TL;DR:** **Audit MED-batch 2/6 — M1 chunking parity (embeddings line citations).** The embedding pipeline chunks the frontmatter-STRIPPED body (to keep YAML out of the vectors) while the FTS5 index chunks the FULL note content — so for any note WITH frontmatter, embeddings / `find_similar` / `semantic_search` stored `line_start`/`line_end` that were BODY-relative (too low by the frontmatter line count), pointing deep-links at the wrong line; and the code comments falsely claimed "identical chunking across BM25 and embeddings." Fix: `parseNote` now exposes `bodyStartLine`, and `embedSingleNote` shifts each chunk's line numbers to FILE-absolute (matching FTS5) — keeping the clean body-only embeddings (no quality regression). Comments corrected; the residual `block`-granularity chunk-INDEX divergence for frontmatter'd notes is documented (the default `note` granularity fuses by path, unaffected). **1108 → 1113 tests.**
87
+
88
+ **Pre-release (v3.10 line) — audit fix batch 2/6.**
89
+
90
+ ### Fixed
91
+
92
+ - **M1 — embeddings line citations were body-relative (off by the frontmatter line count) for frontmatter'd notes.** `embedSingleNote` chunks `note.parsed.body` (frontmatter stripped, so YAML never pollutes the vectors), but `chunkContent`'s `lineStart`/`lineEnd` are then relative to the body, not the file — so a hit's deep-link pointed N lines too early (N = the frontmatter line count). `parseNote` now returns `bodyStartLine` (the 1-based file line where the body begins, via `source.indexOf(body)`; 1 with no frontmatter), and `embedSingleNote` adds `(bodyStartLine − 1)` to each chunk's line numbers → FILE-absolute, matching the FTS5 index (which chunks full content). Embedding quality is unchanged (still body-only). Existing embed-dbs apply the corrected lines as notes are re-embedded (on edit, or `enquire-mcp build-embeddings`) — no forced rebuild (proportionate: a full re-embed on every serve for a line-number fix would be disruptive).
93
+ - **M1 (claim-vs-reality) — `embed-db.ts` header claimed "Same chunking as FTS5 … so chunk identity matches across BM25 and embeddings."** False for markdown (FTS5 chunks full content; embeddings chunk body). Corrected to describe the actual design + the file-absolute line alignment + the `block`-granularity caveat. The `reindexPdfFile` "chunk IDs match" claim is accurate (PDFs have no frontmatter) and left as-is.
94
+
95
+ ### Docs
96
+
97
+ - `searchHybrid` granularity `@param` now notes that in `block` granularity a per-note chunk INDEX may not denote the same span across BM25 (content) and embeddings (body) for frontmatter'd notes — prefer the default `note` granularity (fused by path) for frontmatter-heavy vaults.
98
+
99
+ ### Tests (1113)
100
+
101
+ `tests/embed-pipeline.test.ts` +2 (frontmatter'd note → chunk `lineStart` lands on the file line that actually contains the chunk text, not the `---`; NEGATIVE control: no-frontmatter ⇒ offset 0, line 1). `tests/parser.test.ts` +3 (`bodyStartLine` > 1 with frontmatter; === 1 without — NEGATIVE control; points at the first body line). 1108 → 1113.
102
+
103
+ ### Files changed
104
+
105
+ - `src/parser.ts` (`bodyStartLine` field + computation), `src/embed-pipeline.ts` (file-absolute line offset in `embedSingleNote`), `src/embed-db.ts` (header comment), `src/tools/search.ts` (granularity `@param` caveat), `tests/embed-pipeline.test.ts` (+2), `tests/parser.test.ts` (+3), test-count claims → 1113.
106
+ - version bump 3.10.0-rc.16 → 3.10.0-rc.17.
107
+
108
+ ---
109
+
110
+ ## [3.10.0-rc.16] — 2026-06-05
111
+
112
+ > **TL;DR:** **Audit MED-batch 1/6 — retrieval correctness.** A from-scratch 7-agent system audit (core code · transport/CLI · security/STRIDE · privacy · agent-facing surfaces · docs/process · tests), every headline adversarially re-verified against the code, returned **0 CRITICAL / 0 HIGH** for this 30+-round-audited codebase — real findings sat in the apparatus's known behavioral/docs blind spots. This RC fixes the first two verified MEDIUMs. **M5:** `obsidian_open_questions` is documented "oldest-first" but broke at `limit` in vault-WALK order and only THEN sorted — so on a vault with > `limit` questions it returned an arbitrary subset, NOT the oldest. Now collects all (scan capped via `capScanEntries`), sorts oldest-first, slices. **M6:** `HnswIndex.applyDiff` validated vector dim INSIDE the addPoint loop, so a wrong-dim vector threw AFTER some labels were `markDelete`'d and some points added → a half-applied index (silent embed-db↔HNSW divergence in the watcher, which logs + continues rather than rebuilding). Dim is now pre-validated before ANY mutation → atomic for the only caller-data-driven throw. **1104 → 1108 tests.**
113
+
114
+ **Pre-release (v3.10 line) — audit fix batch 1/6.**
115
+
116
+ ### Fixed
117
+
118
+ - **M5 — `obsidian_open_questions` returned an arbitrary `limit`-subset, not the oldest** (`src/tools/meta.ts`). The outer loop `break`'d once `out.length >= limit` in `vault.listMarkdown` (readdir) order, then `out.sort(age desc)` ran on that already-truncated set. On a vault with more than `limit` questions, callers asking for "the most-aged open questions" got whichever notes came first in the walk. Now: cap the scan (`capScanEntries`, defense-in-depth like the graph tools), collect ALL matches, sort oldest-first, then `slice(0, limit)` — the documented contract.
119
+ - **M6 — `HnswIndex.applyDiff` could leave a half-applied index on a dim mismatch** (`src/hnsw.ts`). The `pt.vector.length !== dim` check lived inside the addPoint loop, so a bad vector threw after the `markDelete` loop + earlier `addPoint`s had already mutated the index; the watcher's `syncHnswForFile` catch logs + continues (doesn't rebuild), leaving HNSW out of sync with the freshly-upserted embed-db until the next serve restart (ghost labels / stale `text_preview`). Now ALL dims are pre-validated before any `markDelete`/`resizeIndex`/`addPoint` → applyDiff is atomic for the only caller-data-driven throw (a native addPoint failure after the pre-grow remains the sole, rare, eventually-consistent residual, documented).
120
+
121
+ ### Security (dependency)
122
+
123
+ - **`hono` moderate advisory (transitive, not reachable) — pinned to the patched 4.12.23 via `overrides`.** A fresh `npm audit` moderate advisory hit `hono ≤4.12.20` (GHSA-xrhx-7g5j-rcj5 IPv6-deny bypass + GHSA-3hrh-pfw6-9m5x cookie injection + GHSA-f577-qrjj-4474 JWT scheme + GHSA-2gcr-mfcq-wcc3 mount-prefix), pulled in TRANSITIVELY by `@modelcontextprotocol/sdk@1.29.0` (via `@hono/node-server`). enquire runs its OWN node-`http` transport, not hono, so none of the flagged paths are reachable — but the `audit` CI gate flags the dep tree regardless. Added `"overrides": { "hono": "^4.12.21" }` → resolves to 4.12.23 (in-range for the SDK's `^4.11.4`, non-breaking; build + SDK transport tests green). `npm audit` back to **0 vulnerabilities** (prod-moderate + all-high). Caught by CI's `audit` gate, not my local battery — lesson: run `npm audit` locally too (the battery omitted it).
124
+
125
+ ### Tests (1108)
126
+
127
+ `tests/redos-guard.test.ts` +3 (open-questions oldest-first: limit=1 returns the oldest not the walk-first/newest; limit=2 returns the 2 oldest; full-order regression — names+mtimes chosen so the oldest is never readdir-first, making the revert discriminating). `tests/hnsw.test.ts` +1 (applyDiff wrong-dim throws atomically — the removeLabel survives the failed diff; NEGATIVE control: a valid diff DOES remove it). 1104 → 1108 (the hono override adds no tests).
128
+
129
+ ### Files changed
130
+
131
+ - `src/tools/meta.ts` (M5 + `capScanEntries` import), `src/hnsw.ts` (M6 dim pre-validation), `tests/redos-guard.test.ts` (+3), `tests/hnsw.test.ts` (+1), test-count claims → 1108.
132
+ - `package.json` + `package-lock.json` (hono `overrides` → 4.12.23, security).
133
+ - version bump 3.10.0-rc.15 → 3.10.0-rc.16.
134
+
135
+ ---
136
+
137
+ ## [3.10.0-rc.15] — 2026-06-03
138
+
139
+ > **TL;DR:** **Watcher-test flake stabilized at the root — it was blocking RELEASES, not just PRs.** The rc.13 release run failed at `tests/watcher.test.ts:505` (chokidar FSEvents timing); a re-run published it — but a transient blip must never fail a publish (rc.20 rule). Two root races: **(1)** a brand-new file's FIRST inotify/FSEvents event can be dropped on a loaded runner even after `start()` resolves on `ready`, so a one-shot `writeFile` + `waitFor` can wait forever — the fixed-`setTimeout` warm-ups (rc.7 #36, rc.9 W-FLAKE-2) only approximated a fix; **(2)** the `:505` assertion read the embed-error stderr line IMMEDIATELY after the `fts.totalFiles()` check, but that line is logged a tick LATER in the same handler. Fixed: new `writeAndWaitFor` re-touch-on-miss helper (re-writes the file if the first event is dropped — idempotent reindex) on all 5 new-file-add sites; the lagging embed-error signal is now polled with `waitFor`; `waitFor` default 4000 → 8000 ms. **Verified green 3× back-to-back.** **1104 tests unchanged** (bodies refactored, no `it()` added). **Test-only — zero `src/`.**
140
+
141
+ **Pre-release (v3.10 line) — release-reliability fix; no `src/` / behavior change.**
142
+
143
+ ### Fixed
144
+
145
+ - **Watcher test flake blocked the rc.13 release** (`tests/watcher.test.ts:505`, "expected false to be true"). Two root races, both fixed:
146
+ - **Missed first event.** chokidar can drop the FIRST add event for a brand-new path on a loaded runner (the watch is still arming when the write lands, even though `start()` already resolved on `ready`). New `writeAndWaitFor(filePath, content, cond)` re-writes the file every ~1.2 s while waiting, regenerating a fresh event the watcher reliably catches. Idempotent reindex ⇒ extra writes never change the asserted outcome. Applied to all 5 new-file-add sites (`logged.md`, `added.md`, `added.pdf`, `note-embed.md`, `embed-error.md`).
147
+ - **Asserting a lagging signal too early.** The `:505` check read the "embed-db sync failed" stderr line right after `fts.totalFiles()>=1`, but that line is logged a tick later in the same handler. Now polled with `waitFor`.
148
+ - **`waitFor` default 4000 → 8000 ms** — headroom for the full chain (event → `awaitWriteFinish` 250 ms → per-file queue → reindex → embed-sync) under coverage instrumentation + parallel workers, without masking a genuine hang.
149
+
150
+ ### Method note
151
+
152
+ The durable fix the project's fixed-`setTimeout` warm-ups (rc.7 #36, rc.9 W-FLAKE-2) chased for three RCs: the root isn't "wait longer before writing" — it's "regenerate the event if it's dropped" + "poll lagging signals, don't assert them immediately". Per "a transient blip must never fail a release" (rc.20), but fixed at the ROOT (a fixable test race) rather than masked with a retry (the rc.20 npm-ci retry was for an *unfixable* network flake). Verified empirically — watcher suite green 3× back-to-back — per the rc.25 "run it, don't assume" lesson. Deferred (noted): a structural lint flagging `writeFile`-then-`waitFor` in watcher tests was considered and rejected as noisy; the helper is the durable mechanism and the remaining sites are change/unlink (reliable on already-watched files).
153
+
154
+ ### Tests (1104)
155
+
156
+ `tests/watcher.test.ts` — 5 add-sites → `writeAndWaitFor`, embed-error downstream signal → `waitFor`, default timeout 4000→8000. No `it()` added/removed; 1104 unchanged.
157
+
158
+ ### Files changed
159
+
160
+ - `tests/watcher.test.ts` only (new `writeAndWaitFor` helper + 6 site refactors + `waitFor` timeout bump).
161
+ - version bump 3.10.0-rc.14 → 3.10.0-rc.15.
162
+
163
+ ---
164
+
165
+ ## [3.10.0-rc.14] — 2026-06-03
166
+
167
+ > **TL;DR:** **`query` + `prune` CLI (bug-report Issues 4, 8) — concludes the actionable bug-report batch.** **(Issue 4)** New `enquire-mcp query "<text>" --vault <path>` runs the SAME hybrid `searchHybrid` the MCP `obsidian_search` tool uses and prints the results — a one-shot CLI search for smoke-tests / CI / debugging without an MCP client (the report had to hand-craft JSON-RPC over stdio to verify retrieval). **(Issue 8)** New `enquire-mcp prune --vault <path>` GCs the per-vault index clutter that accumulates in the cache dir (`clear-cache`/`clear-index` only target the current vault); it removes all OTHER vaults' enquire artifacts, **dry-run by default** (opt in with `--yes`), and — via the pure `planCachePrune` with a strict `<12-hex>.{fts5.db,embed.db,hnsw.bin,hnsw.meta.json}` filter — can NEVER touch a file enquire didn't create. **1098 → 1104 tests.**
168
+
169
+ **Minor (pre-release) — v3.10 line; bug-report response batch 3/3 (DX CLI). Concludes the actionable bug-report batch (rc.12 model-path · rc.13 reranker · rc.14 query+prune).**
170
+
171
+ ### Added
172
+
173
+ - **`query` subcommand (Issue 4).** `enquire-mcp query "<text>" --vault <path> [--limit N] [--index-file …] [--json]` — builds/reuses the persistent FTS5 index (peek-safe tokenize, K-1 invariant), runs `searchHybrid`, prints `path:line [kind]` + snippet per hit (or the full JSON with `--json`). Unblocks CLI/CI smoke-testing of retrieval without an MCP client.
174
+ - **`prune` subcommand (Issue 8).** `enquire-mcp prune --vault <path> [--yes]` — removes cached index artifacts for OTHER vaults, keeping the named one. Dry-run preview by default; `--yes` deletes. Backed by the pure, exported `planCachePrune(entries, keepHash)` whose strict enquire-artifact regex is the safety property (verified: ignores user notes / wrong-shaped hashes / wrong extensions).
175
+
176
+ ### Fixed
177
+
178
+ - **Issue 4 — no CLI search for smoke/CI.** Previously retrieval could only be exercised through the MCP protocol (stdio JSON-RPC). `query` gives a direct CLI path.
179
+ - **Issue 8 — no GC for accumulated per-vault indexes.** The cache dir grew one index set per vault path/config hash with no cleanup command for OTHER vaults. `prune` is that command. (The root cause for the maintainer's own clutter was the test suite — fixed in rc.11; `prune` is the user-facing GC.)
180
+
181
+ ### Tests (1104)
182
+
183
+ `tests/cache-prune.test.ts` +4 (planCachePrune: selects other vaults, never the kept one, **NEGATIVE control** ignores non-enquire files, empty cases); `tests/cli.test.ts` +2 (`query` prints results, `prune` previews + deletes nothing without `--yes`). 1098 → 1104; claims synced (README ×4, package.json, llms.txt, AGENTS, COMPARISON, ROADMAP).
184
+
185
+ ### Files changed
186
+
187
+ - `src/fts5.ts` (`planCachePrune` + `ENQUIRE_CACHE_ARTIFACT` regex), `src/cli.ts` (`query` + `prune` commands + `searchHybrid`/`planCachePrune` imports), `docs/api.md` (2 subcommand rows), `tests/cache-prune.test.ts` (new, +4), `tests/cli.test.ts` (+2), test-count claims → 1104.
188
+ - version bump 3.10.0-rc.13 → 3.10.0-rc.14.
189
+
190
+ ### Known / next
191
+
192
+ - **A flaky watcher test (`tests/watcher.test.ts:505`, chokidar FSEvents timing) failed the rc.13 release run** (a re-run published it). Same class as rc.7 #36 / rc.9 W-FLAKE-2, but now blocking *releases*, not just PRs — more severe. **rc.15 will stabilize it** (wait on the watcher's `ready` signal instead of a fixed `setTimeout` warmup) per the "a transient blip must never fail a release" rule (rc.20).
193
+
194
+ ---
195
+
196
+ ## [3.10.0-rc.13] — 2026-06-03
197
+
198
+ > **TL;DR:** **Reranker observability + pre-cache (bug-report Issues 9, 3, 5).** The cross-encoder reranker was a black box: enabling it triggered a SILENT ~110 MB download on the first query (which could exceed a client's tool-call timeout → unexplained RRF fallback), there was no way to pre-cache it, and the response gave no positive signal it ran. Now: **(Issue 9)** `obsidian_search` emits stderr lifecycle logs (`reranker '<alias>' loading (~110 MB…)` BEFORE the blocking load, `loaded; reranked N pairs` after — failures were already logged) and returns a `reranked: { applied, pairs?, reason? }` field; **(Issue 3)** `install-model` resolves the reranker catalog too, so `enquire-mcp install-model rerank-bge` pre-downloads the cross-encoder (the ~110 MB no longer blocks the first query); **(Issue 5, docs)** `docs/api.md` now states the default reranker is English-tuned and RU/multilingual vaults can leave it off (RRF hybrid already handles them), plus which aliases are verified-working. **1094 → 1098 tests.**
199
+
200
+ **Minor (pre-release) — v3.10 line; bug-report response batch 2/3 (reranker cluster).**
201
+
202
+ ### Added
203
+
204
+ - **`SearchHybridResponse.reranked`** — `{ applied: boolean; pairs?: number; reason?: string }`, present ONLY when a reranker was requested. `{applied:true, pairs:N}` after a successful re-score; `{applied:false, reason}` when requested but it didn't run (reason mirrors `signal_errors.reranker` on a load failure, or notes "no candidates"). Closes Issue 9's "silent no-op" — a caller can now tell applied-vs-fell-back without guessing.
205
+ - **`install-model` accepts reranker aliases** (Issue 3). `alias in RERANKER_MODELS` routes to `loadReranker` + a one-pair smoke; `enquire-mcp install-model rerank-bge` pre-caches the ~110 MB cross-encoder so `serve --enable-reranker` doesn't block on the download at first query. Unknown aliases now fail with BOTH catalogs listed (resolves the `bge` embedding vs `rerank-bge` cross-encoder naming confusion).
206
+
207
+ ### Fixed
208
+
209
+ - **Issue 9 — reranker silently no-op'd with no diagnostics (Medium).** `loadReranker`'s download was silent and `search.ts` logged ONLY failures, so a first-run download that exceeded the client timeout looked identical to a silent failure. Added stderr lifecycle logging (loading… with size / loaded; reranked N pairs) — three distinguishable states — plus the `reranked` response field above. (`signal_errors.reranker` already carried the failure reason; this adds the in-progress + success signals.)
210
+
211
+ ### Docs
212
+
213
+ - **Issue 5 — reranker language guidance.** `docs/api.md` `--enable-reranker` / `--reranker-model` rows now state the default cross-encoder is English-tuned (RRF hybrid handles RU/multilingual well → leave it off with no quality loss), name `rerank-bge` as the only verified-working reranker (the multilingual aliases still fail at `AutoTokenizer`), and point at `install-model` for pre-caching. The empirical RU conclusion from the bug report (RRF-hybrid already correct without reranker) is now documented guidance.
214
+
215
+ ### Tests (1098)
216
+
217
+ `tests/reranker.test.ts` +3 (reranked applied / failed-with-reason / NEGATIVE-control absent-when-not-requested); `tests/cli.test.ts` +1 (install-model unknown-alias lists both catalogs). 1094 → 1098; claims synced (README ×4, package.json, llms.txt, AGENTS, COMPARISON, ROADMAP).
218
+
219
+ ### Files changed
220
+
221
+ - `src/tools/search.ts` (`reranked` field + stderr lifecycle logs), `src/cli.ts` (install-model reranker routing + combined-catalog error + imports), `docs/api.md` (reranker guidance), `tests/reranker.test.ts` (+3), `tests/cli.test.ts` (+1), test-count claims → 1098.
222
+ - version bump 3.10.0-rc.12 → 3.10.0-rc.13.
223
+
224
+ ---
225
+
226
+ ## [3.10.0-rc.12] — 2026-06-03
227
+
228
+ > **TL;DR:** **Model-cache path: one resolver, no more lying paths (bug-report Issues 1 + 2).** A fresh-install bug report on a real 236-note vault found `enquire-mcp doctor` printing **"NOT READY — no Xenova model weights found"** on a fully-working **global** install: the cache probe only looked under `process.cwd()/node_modules/…`, but a global `npm i -g` loads the model from the package's OWN nested `node_modules`, resolved relative to the module — never relative to cwd (Issue 1). Relatedly, `install-model` / `setup` printed `cached under ~/.cache/huggingface/` — a path that stays empty — while the help text and two TSDocs named other (also wrong) paths (Issue 2). Both now flow through ONE source of truth, `resolveTransformersCacheDir()` (resolves `@huggingface/transformers` via `createRequire(import.meta.url)` → its package `.cache`, correct for hoisted AND nested layouts), so the diagnostic and the success message can never again disagree with reality. **Verified:** on this machine `doctor` now reports `✓ Embedding model cache — 2 model(s) cached under …/node_modules/@huggingface/transformers/.cache/Xenova/`. **1088 → 1094 tests.**
229
+
230
+ **Minor (pre-release) — v3.10 line; bug-report response batch 1/3 (model-path correctness).**
231
+
232
+ ### Fixed
233
+
234
+ - **Issue 1 — `doctor` false-negative on a global install (Medium).** `candidateModelCacheRoots()` (`src/doctor.ts`) probed only a cwd-based path; the model on a global install lives in the package's nested `node_modules/@huggingface/transformers/.cache`, which cwd never reaches → false `NOT READY` on a fully-working setup (panic / needless reinstalls). Now the FIRST candidate is `resolveTransformersCacheDir()` — the module-relative path transformers.js actually loads from — with the cwd probe kept as a local-dev/npx fallback and the HF-Hub conventions kept after it.
235
+ - **Issue 2 — `install-model` / `setup` printed a wrong, empty cache path (Low).** The success message (`cached under ~/.cache/huggingface/`), the `install-model` description (`~/.cache/huggingface/transformers.js/`), and two `src/embeddings.ts` TSDocs each named a different wrong path. All now print / point to the resolved truth (`resolveTransformersCacheDir()`); the `docs/api.md` `install-model` row was corrected. Root-cause sweep fixed **5 instances** of the wrong-path claim (install-model msg, setup msg, 2 TSDocs, api.md), not just the reported one.
236
+
237
+ ### Added
238
+
239
+ - **`resolveTransformersCacheDir()` + `deriveTransformersCacheDir()`** (`src/embeddings.ts`, exported): single resolver for the transformers.js model-cache dir. Pure derivation slices at the INNERMOST `node_modules/@huggingface/transformers` segment so it's correct for hoisted (`<root>/node_modules/…`) AND nested global-install (`…/enquire-mcp/node_modules/@huggingface/transformers/.cache`) layouts. Resolution-only (no ONNX load) → keeps `doctor`'s fast-read-only promise.
240
+ - **`tests/transformers-cache-path.test.ts`** (+6): pins the pure derivation incl. the nested global-install layout (the exact Issue-1 shape), with a discriminating NEGATIVE control (no marker → `null`); asserts the live resolver returns the package `.cache` and that `doctor` ranks it first.
241
+
242
+ ### Method note
243
+
244
+ Classic change-driven-vs-state-driven gap: my home gates never run a global install, so the cwd-only probe survived every internal sweep. A real fresh install found it immediately. Fix is the standard transform — collapse N drifting path strings into ONE resolver + a structural test on the derivation, so the diagnostic and the messages are provably consistent. The α-class (TSDoc-drift) rule applied: the wrong-path claims in the `embeddings.ts` TSDocs + the module header comment were corrected in the same commit as the code.
245
+
246
+ ### Tests (1094)
247
+
248
+ `tests/transformers-cache-path.test.ts` +6 source `it()`. 1088 → 1094; claims synced (README ×4, package.json, llms.txt, AGENTS, COMPARISON, ROADMAP).
249
+
250
+ ### Files changed
251
+
252
+ - `src/embeddings.ts` (resolver + helpers + 2 TSDoc path fixes + header comment), `src/doctor.ts` (candidate #0 = resolved cache + stale-comment fix), `src/cli.ts` (install-model + setup messages → resolved path), `docs/api.md` (install-model row), `tests/transformers-cache-path.test.ts` (new), test-count claims → 1094.
253
+ - version bump 3.10.0-rc.11 → 3.10.0-rc.12.
254
+
255
+ ---
256
+
257
+ ## [3.10.0-rc.11] — 2026-06-03
258
+
259
+ > **TL;DR:** **Hermetic test cache — found by live-testing the installed server on a real machine.** Driving the installed `enquire-mcp@3.9.1` over JSON-RPC against a real 237-note vault confirmed it works end-to-end (boot, 33 tools, `obsidian_stats`, semantic `obsidian_search` with a built embed-db, path-escape guard) — but also surfaced **~27,000 orphaned files / ~699 MB** sitting in the real user cache (`~/Library/Caches/enquire/`). Root cause: `defaultIndexFile()` (and the embed-db / HNSW sidecars) resolve their dir from `XDG_CACHE_HOME`, and any test that spawns `serve`/`setup`/`build-embeddings`/`index` **without** an explicit `--index-file` fell back to that REAL cache and never cleaned up — weeks of `npm test` accumulated there (mtimes May 8 → Jun 3). Fixed at the root: `tests/setup.ts` now redirects `XDG_CACHE_HOME` to a throwaway temp dir before any test (and every inheriting child spawn) touches the cache. **Verified: real-cache file delta from the suite is now 0.** Structural guard added so it can't regress. **1085 → 1088 tests.**
260
+
261
+ **Minor (pre-release) — v3.10 line; test-hygiene fix from live-testing (no `src/` runtime change).**
262
+
263
+ ### Fixed
264
+
265
+ - **Test suite no longer pollutes the real user cache.** `tests/setup.ts` sets `process.env.XDG_CACHE_HOME = mkdtempSync(tmpdir()/enquire-test-cache-…)` (guarded by `if (!XDG_CACHE_HOME)` so CI/devs can override). Because `defaultIndexFile()` keys on `XDG_CACHE_HOME` on every platform and every test child-spawn inherits `process.env` (verified: no test overrides `env` without spreading `process.env`), this redirects ALL fts5 / embed-db / HNSW cache writes — in-process and spawned — to a throwaway dir the OS reclaims. Previously these landed in `~/Library/Caches/enquire/` (macOS) / `~/.cache/enquire/` (Linux) and were never cleaned, so a dev machine that ran the suite over weeks accumulated tens of thousands of orphaned index files. **Verified end-to-end:** real-cache file count is unchanged (Δ0) after a full `npm test`; the only real-cache writes observed during testing came from a separate *real* `serve` run on the actual vault (correct behavior), confirmed by matching the file hash to `sha1("/…/Obsidian Vault")`.
266
+
267
+ ### Added
268
+
269
+ - **`tests/cache-isolation-invariant.test.ts`** (+3): asserts `XDG_CACHE_HOME` is a temp dir during tests and that `defaultIndexFile()` resolves UNDER it, NOT under the real `~/Library/Caches/enquire` (or `~/.cache/enquire`) — so removing the `setup.ts` redirect fails CI. Includes a NEGATIVE control proving the real-cache classifier discriminates (a constant `() => false` would make it vacuous). Meta-invariant-enrolled.
270
+
271
+ ### Method note
272
+
273
+ This is the textbook value of **testing on a real machine** (the project's home-grown gates are drift/claim-driven and structurally blind to filesystem-side-effects like this): the unit suite *caused* the accumulation but never *observed* it, because no gate inspects the real cache dir. The fix is the project's standard transform — root-cause + a permanent inventory/structural invariant (`cache-isolation-invariant`) so the class can't silently return. Severity is **dev-hygiene** (not a product/CI/end-user correctness bug — a real user with one vault gets one index file; CI runners are ephemeral), but it's a real papercut (699 MB on the maintainer's machine) with a clean, verified fix. **The pre-existing local cruft is maintainer-gated to clear** (deleting files is out of scope for the agent) — see the session hand-off for the exact one-liner that keeps the live vault's index and removes the orphans.
274
+
275
+ ### Tests (1088)
276
+
277
+ `tests/cache-isolation-invariant.test.ts` +3 source `it()`. 1085 → 1088; claims synced (README ×4, package.json, llms.txt, AGENTS, COMPARISON, ROADMAP).
278
+
279
+ ### Files changed
280
+
281
+ - `tests/setup.ts` (XDG_CACHE_HOME redirect), `tests/cache-isolation-invariant.test.ts` (new), test-count claims → 1088.
282
+ - version bump 3.10.0-rc.10 → 3.10.0-rc.11.
283
+
284
+ ---
285
+
286
+ ## [3.10.0-rc.10] — 2026-06-02
287
+
288
+ > **TL;DR:** **New capability — frontmatter-aware retrieval.** `obsidian_search` gains an optional `filter_frontmatter` map so an agent can scope hybrid search by YAML frontmatter: `{ status: "active", type: ["meeting","decision"] }` → only notes whose frontmatter matches **every** key (strings case-insensitive; an array frontmatter value matches by membership; an array filter value is OR). It's the first genuinely-new *feature* (not polish) since the v3.10 staleness line — Obsidian users live in frontmatter (`status`/`type`/`project`), and **no other Obsidian-MCP can scope semantic search by it**. Opt-in + additive: **absent ⇒ byte-identical** to before (same safe pattern as recency re-ranking). Matching is filter-on-the-fused-candidate-pool, which is already excluded-pruned (rc.8), so no excluded note's frontmatter is read; PDFs (no frontmatter) are excluded without a binary read. **1076 → 1085 tests.**
289
+
290
+ **Minor (pre-release) — v3.10 line; new feature: frontmatter-aware retrieval (increment 1/N).**
291
+
292
+ ### Added
293
+
294
+ - **`obsidian_search` `filter_frontmatter?: Record<string, scalar | scalar[]>`** — post-filters fused hits by the note's parsed YAML frontmatter. AND across keys; per key, scalar-equality (strings case-insensitive, numbers/booleans strict, no cross-type coercion) or array-membership; a filter value may be an array for OR. Notes with no frontmatter, or missing a filtered key, are excluded (a filter is a positive assertion). Runs only when the param is passed; filters the candidate pool (so a strict filter can legitimately return < `limit`). Reads candidate frontmatter via the cached `vault.readNote` (graph-boost usually warms it); fail-soft (an unreadable candidate is excluded, honoring the filter).
295
+ - **Pure exported `frontmatterMatches(frontmatter, filter)`** (+ `FrontmatterFilterValue` / `FrontmatterFilterScalar` types) — the matching semantics, unit-testable in isolation. zod schema added to the `obsidian_search` registration (`z.record` of string→scalar|scalar[]).
296
+ - **`tests/search-hybrid.test.ts`** (+9): 6 `frontmatterMatches` unit tests (scalar/case-insensitive, array-membership, array-OR + multi-key-AND, number/boolean strictness, missing-key/empty/absent → no match, a NEGATIVE control that discriminates) + 3 integration tests through `searchHybrid` (filter narrows to the matching note; **NEGATIVE control** — no filter returns all three, proving the filter is what narrowed it; array-value OR + multi-key AND).
297
+
298
+ ### Method note
299
+
300
+ This is the deliberate answer to "is the project at a dead-end?" — the *refinement* track had hit diminishing returns, so the next real value is a **new capability**, not another micro-RC. Frontmatter-aware retrieval was chosen because it (1) expands what the product can *do* (not polish), (2) plays to the retrieval core — the project's strength, (3) is deeply Obsidian-native (frontmatter is a first-class Obsidian primitive) with **no competitor parity**, and (4) ships **additively/opt-in** so the critical search path is byte-identical when unused. Rejected alternatives, with reasons: conversation write-back (it's `basic-memory`'s grain and muddies the "grounded, not extracted" differentiator), multi-vault (explicit non-goal in CLAUDE.md), and answer-synthesis (we're a retriever, not a QA generator — would be the kind of overclaim `docs/benchmarks.md` explicitly avoids). The integration test is non-vacuous by construction (the NEGATIVE control returns all three notes when the filter is absent — so a no-op filter implementation fails it), unlike the rc.8 case the revert-verify caught. **Next increments:** rc.11 — `tag` filter parity (FTS has it; hybrid doesn't) + optional boost-by-frontmatter; rc.12 — positioning for the capability.
301
+
302
+ ### Tests (1085)
303
+
304
+ `tests/search-hybrid.test.ts` +9 source `it()`. 1076 → 1085; claims synced (README ×4, package.json, llms.txt, AGENTS, COMPARISON, ROADMAP). Tool count unchanged (45 — this adds a *parameter*, not a tool).
305
+
306
+ ### Files changed
307
+
308
+ - `src/tools/search.ts` (`filter_frontmatter` arg + `frontmatterMatches`/`frontmatterValueMatches`/`frontmatterScalarEq` helpers + matches-loop integration + `fmFilter` hoist), `src/tool-registry.ts` (zod schema), `tests/search-hybrid.test.ts` (+9), `docs/api.md` (args-table row), test-count claims → 1085.
309
+ - version bump 3.10.0-rc.9 → 3.10.0-rc.10.
310
+
311
+ ---
312
+
313
+ ## [3.10.0-rc.9] — 2026-06-02
314
+
315
+ > **TL;DR:** **Positioning — a verified, fair head-to-head vs `basic-memory`** (the closest local-markdown-MCP rival), added to the COMPARISON "when to pick something other than enquire-mcp" section. Grounded in a fresh web-research pass (Track B of the promotion plan): `basic-memory` solves the **inverse** problem — it *writes* a knowledge-base **from your AI conversations** (readable markdown, viewable in Obsidian as a GUI), whereas enquire-mcp *recalls the notes you authored*. The entry is intentionally fair-not-sales (calls out exactly when basic-memory is the better pick, and that the two **compose**) and makes the "grounded, not extracted" line concrete with a real, citable example. Docs-only; no overclaim about the competitor (every claim verified against its public repo). **1076 tests unchanged.**
316
+
317
+ **Minor (pre-release) — v3.10 line; promotion/positioning increment (no code change).**
318
+
319
+ ### Changed
320
+
321
+ - **`docs/COMPARISON.md`** — "when to pick something other than enquire-mcp" expanded from four cases to **five**: added **`basic-memory` (basicmachines-co)**. It's the closest project in spirit (local-first, markdown, MCP-native, semantic search over a wikilinked knowledge graph, Obsidian as a GUI) but solves the inverse problem — write-memory-from-chat vs recall-what-you-authored — which makes the choice clean and sharpens enquire's "grounded, not extracted" differentiator with a concrete example. Notes that the two compose (basic-memory writes conversation-derived notes; enquire retrieves across the whole authored vault). Kept OUT of the Obsidian-MCP feature matrix (different category) to avoid a misleading row.
322
+
323
+ ### Method note
324
+
325
+ This is the first increment of the **promotion track**. It's grounded in a Firecrawl research pass (PROMO-1), not authored from memory, specifically to avoid competitor-claim overclaim: every `basic-memory` capability stated here was verified against its public GitHub/docs (knowledge-graph, semantic search, wikilinks, MCP-native, Obsidian GUI, conversation-capture). The research also surfaced **discoverability gaps that are maintainer-gated** (not shippable as repo docs) — handed off separately: enquire is absent from the high-intent "best Obsidian MCP server" results (needs stars + listicle presence), the brand search surfaces a stale OpenClaw-directory listing rather than the canonical repo, and the highest-leverage lever remains the published LongMemEval/retrieval score (reference hardware). Glama listing confirmed live (auto-synced from the MCP registry); the "claim" is OAuth-gated. **Deliberately did NOT** churn the README use-cases for marginal on-page SEO — the real high-intent-query gap is off-page (stars/listicles), and quality > keyword-stuffing.
326
+
327
+ ### Tests (1076)
328
+
329
+ No `it()` change (docs-only). 1076 unchanged.
330
+
331
+ ### Files changed
332
+
333
+ - `docs/COMPARISON.md` (basic-memory "when to pick else" entry; four→five), version bump 3.10.0-rc.8 → 3.10.0-rc.9.
334
+
335
+ ---
336
+
337
+ ## [3.10.0-rc.8] — 2026-06-02
338
+
339
+ > **TL;DR:** **Post-rc.7 audit response — fusion-stage privacy parity (defense-in-depth) + a self-caught vacuous-test correction.** A state-driven audit of the rc.3→rc.7 line (behavioral/threat lens, per the rc.36 meta-audit) found that the two fusion-stage consumers of the RRF `fused` list — pre-existing **graph-boost** (calls `vault.readNote` to parse a candidate's wikilinks → reads its **content**) and the rc.5 **recency re-rank** (stats a candidate's **mtime**) — both run BEFORE the rc.18 L-HYB-1 response-build `isExcluded` guard and don't replicate it. Not exploitable today (every ranker arm already drops excluded paths before `fused`, and the response-build guard drops them from output), so this is a **third, defense-in-depth layer** for a hypothetical future ranker-arm regression — exactly the "RRF fusion trusts ranker inputs; don't" rationale L-HYB-1 was shipped on. Fixed by pruning excluded paths from `fused` once at the source via a new pure `pruneExcludedHits`. **The audit also caught itself overclaiming:** the first test written for this was an *integration* test that **passed with the fix disabled** (vacuous — the per-arm filters prevent an excluded path from ever reaching `fused` through the public API). The revert-verify exposed it; it was replaced with a **pure-helper unit test** that actually fails when the guard is removed. **1072 → 1076 tests.** `src/` change is one fusion-stage filter line + the extracted helper.
340
+
341
+ **Minor (pre-release) — v3.10 line; post-sprint audit hardening.**
342
+
343
+ ### Added
344
+
345
+ - **`pruneExcludedHits(hits, isExcluded, granularity)`** in `src/tools/search.ts` — pure, granularity-aware (`block` ids strip the `#chunk` suffix before the membership test, matching the response-build guard's `lastIndexOf("#")` logic exactly). `searchHybrid` now calls it on `fused` immediately after RRF, so graph-boost + recency + matches-build are all excluded-free by construction.
346
+ - **`tests/search-hybrid.test.ts`** (+4): `pruneExcludedHits` note-granularity removal + order preservation, `#chunk`-suffix stripping (block), the `C# Notes.md` literal-`#` case (regression guard for the v3.7.16 P2-16 class), and a **NEGATIVE control** (predicate-driven, not unconditional — a `return hits` no-op fails it).
347
+
348
+ ### Method note
349
+
350
+ This is the project's signature **incomplete-class-sweep** closure: rc.18 L-HYB-1 added the *response-build* `isExcluded` guard but left graph-boost's fusion-stage content-read unguarded; rc.5 then added a second unguarded fusion-stage consumer (recency mtime-stat). The class fix prunes at the source so any future consumer of `fused` inherits the guard. **Honest self-correction (worth recording):** the integration test first written to "prove" the fix was **vacuous** — it asserted graph-boost never read an excluded note, but the per-arm ranker filters (BM25 `~line 1373`, embeddings `~1100`, TF-IDF via `listMarkdown`) already prevent an excluded path from reaching `fused` through the public `searchHybrid` API, so the assertion held with OR without the prune. The mandated **revert-verify** (disable the fix, confirm the test fails) caught it red-handed. Lesson: a guard that sits *behind* an existing filter can't be exercised through the front door — test it as a pure unit with the dependency injected, or the "test" is theater. Severity of the underlying finding is **LOW** (triple-guarded; no live leak), but the defense-in-depth + the class closure + the testing lesson justify the patch. **No new behavior on any real vault** — `fused` is already excluded-free in every current code path (the prune is a no-op until a ranker arm regresses).
351
+
352
+ ### Tests (1076)
353
+
354
+ `tests/search-hybrid.test.ts` +4 source `it()` (the vacuous integration test added during the audit was removed before ship — net 0 in `security.test.ts`). 1072 → 1076; claims synced (README ×4, package.json, llms.txt, AGENTS, COMPARISON, ROADMAP).
355
+
356
+ ### Files changed
357
+
358
+ - `src/tools/search.ts` (`pruneExcludedHits` helper + the `fused = pruneExcludedHits(...)` call), `tests/search-hybrid.test.ts` (+4), test-count claims → 1076.
359
+ - version bump 3.10.0-rc.7 → 3.10.0-rc.8.
360
+
361
+ ---
362
+
363
+ ## [3.10.0-rc.7] — 2026-06-02
364
+
365
+ > **TL;DR:** **v3.10 increment 6 — TDQS (tool-description quality): make the freshness signal discoverable to agents.** rc.4/rc.5 added `age_days` + `stale` to `obsidian_search` / `obsidian_find_similar` / `obsidian_semantic_search` results, but the **tool descriptions an agent actually reads** never mentioned them — so an agent had no way to know the freshness signal exists, let alone reason over it. This RC adds a concise freshness note to all three descriptions (what the fields are + that `--recency-weight` can blend fresher notes upward) — closing the "shipped-but-undiscoverable" gap. **The benchmark-methodology half of the original rc.7 plan needs no work** — `docs/benchmarks.md` already carries the full methodology (dataset, ground-truth, metric definitions, ablations, reproducibility) AND the precise "what we measure and what we don't" framing (retrieval quality, NOT end-to-end QA accuracy — "a QA-accuracy number for a retriever would be an overclaim"), shipped across the v3.7.x cascade + rc.19. **Src/description-only — zero behavior change, 1072 tests unchanged.**
366
+
367
+ **Minor (pre-release) — v3.10 forgetting-aware staleness, increment 6/N (TDQS).**
368
+
369
+ ### Changed
370
+
371
+ - **`src/tool-registry.ts`** — added a forgetting-aware freshness note to three tool descriptions:
372
+ - `obsidian_search`: "every hit also carries `age_days` … and a `stale` boolean … use these to flag a recalled fact as possibly out-of-date … if the server was started with `--recency-weight`, fresher notes are blended upward."
373
+ - `obsidian_find_similar`: "each result also carries `age_days` + a `stale` flag … so you can prefer fresher related notes or flag aged ones."
374
+ - `obsidian_semantic_search`: "each hit also carries `age_days` + a `stale` flag … a freshness signal you can reason over."
375
+ These are the agent-facing strings returned by `tools/list`, so the capability is now self-describing — an agent discovers the freshness signal from the tool contract, not just the docs.
376
+
377
+ ### Method note
378
+
379
+ A TDQS (tool-description quality) pass is most valuable where a *shipped capability is invisible in the contract the consumer reads* — not as cosmetic rewording of already-audited prose. The scan found exactly that gap (freshness fields shipped rc.4/rc.5, undocumented in `tools/list`) and fixed only it; the rest of the 45 descriptions were already high-quality from prior audit rounds, so they're left untouched (no churn). No structural wording-invariant was added: tying a test to exact description prose is brittle, and the descriptions are already protected by `smoke` (they load) + the K-3 readOnlyHint invariant. **Dependabot triage (separate housekeeping, not in this commit):** PR #91 (better-sqlite3 12.9.0→12.10.0, patch, native optionalDep) and PR #90 (dev-dependencies group) are low-risk with green CI → safe to merge; PR #178 (commander 14→15, **major** — CLI option-parsing behavior) and PR #177 (pdfjs-dist 5→6, **major** — PDF-extraction behavior) need a dedicated test pass + maintainer review before merge; community PR #113 (docs) is maintainer-review-gated. **This concludes the autonomously-shippable v3.10 forgetting-aware line (rc.1→rc.7).** Maintainer-gated next: dependabot major bumps, the published LongMemEval reference-hardware score, and v3.10.0 → `@latest` (fresh external audit per the v3.6.1 ≥2-auditor rule).
380
+
381
+ ### Tests (1072)
382
+
383
+ No `it()` added (description-only). 1072 unchanged; version-bearing surfaces synced to 3.10.0-rc.7.
384
+
385
+ ### Files changed
386
+
387
+ - `src/tool-registry.ts` (3 tool descriptions), `package.json` / `package-lock.json` / `src/index.ts` / `server.json` (version bump 3.10.0-rc.6 → 3.10.0-rc.7).
388
+
389
+ ---
390
+
391
+ ## [3.10.0-rc.6] — 2026-06-02
392
+
393
+ > **TL;DR:** **v3.10 messaging — the positioning catches up to the shipped forgetting-aware capability.** rc.1–rc.5 built freshness fields + recency re-ranking; rc.6 is the docs-only RC that makes that *discoverable* and *positioned*. Adds a "**Grounded — and freshness-aware**" narrative to the README (the Memora stale-fact-reuse frontier, arXiv:2604.20006, which conversation-memory stores ignore), a 4th top-line differentiator (**Freshness-aware recall**), a freshness row in the COMPARISON feature matrix, and the same framing in llms.txt + ROADMAP. Also **sharpens the "grounded, not extracted" claim**: names the chat-memory cohort precisely (mem0 / Zep / Supermemory / **Memobase**) and explicitly scopes the "extracted" critique to *that* cohort — NOT to knowledge-graph/ETL tools (cognee) or personal-search peers (Khoj), so the comparison stays fair-not-sales. **Docs-only — zero `src/` change, 1072 tests unchanged.**
394
+
395
+ **Minor (pre-release) — v3.10 forgetting-aware staleness, increment 5/N (messaging).**
396
+
397
+ ### Changed
398
+
399
+ - **README** — extended the "Grounded, not extracted" block with a "**Grounded — and freshness-aware**" paragraph (cites the Memora benchmark) + a 4th differentiator bullet ("Freshness-aware recall"); the "Three things" header → "What makes enquire-mcp different". Added the cohort-precision parenthetical (the "extracted" critique is specific to chat-memory tools, not cognee / Khoj).
400
+ - **llms.txt** — added Memobase to the conversation-memory cohort + a FRESHNESS-AWARE sentence (age_days/stale + `--recency-weight` + the Memora citation) for AI-agent discovery.
401
+ - **docs/COMPARISON.md** — added Memobase + a freshness-aware sentence to the grounded intro; added a **"Forgetting-aware freshness (`age_days` / recency re-rank)"** row to the feature matrix (enquire Yes (v3.10), all four alternatives No). Row "Yes" deliberately left un-bolded to respect the matrix's stated "bold only in the four audit-priority rows" convention.
402
+ - **ROADMAP.md** — added a "Forgetting-aware freshness (v3.10)" bullet to the "Already shipped and differentiating" list.
403
+
404
+ ### Method note
405
+
406
+ This is the "messaging catches up to capability" RC the project runs after a feature line lands (cf. v3.6.3 marketing pivot, v3.9.0-rc.27 "grounded, not extracted"). All competitor claims are kept to the **verifiable cohort already named in the docs** + one addition (Memobase, a chat-memory backend the "extract" critique accurately describes); the deliberately-scoped parenthetical (NOT cognee / Khoj) is the anti-overclaim move — it's easy to over-broaden "every memory tool extracts" into an unfair-comparison overclaim, so the critique is explicitly bounded. **Deferred (documented):** a head-to-head vs `basic-memory` (a non-Obsidian markdown-memory MCP) — out of scope for the *Obsidian-MCP* COMPARISON matrix and would carry an unverified-license/feature-claim burden; revisit if a dedicated AI-memory-framework comparison page is added. **Deferred to rc.7:** TDQS (tool-description quality) pass on the 45 tool descriptions + a benchmark-methodology doc + dependabot triage.
407
+
408
+ ### Tests (1072)
409
+
410
+ No `it()` added (docs-only). 1072 unchanged; version-bearing surfaces synced to 3.10.0-rc.6.
411
+
412
+ ### Files changed
413
+
414
+ - `README.md`, `llms.txt`, `docs/COMPARISON.md`, `ROADMAP.md` (messaging), `package.json` / `package-lock.json` / `src/index.ts` / `server.json` (version bump 3.10.0-rc.5 → 3.10.0-rc.6).
415
+
416
+ ---
417
+
418
+ ## [3.10.0-rc.5] — 2026-06-02
419
+
420
+ > **TL;DR:** **v3.10 staleness increment 4 — OPT-IN recency re-ranking (the forgetting-aware knob).** Two new shared serve/serve-http flags: **`--recency-weight <w>`** (0–1, **default 0 = OFF**) and **`--stale-days <n>`** (recency half-life, default 365). When `weight > 0`, `obsidian_search` re-sorts the fused result set by `(1 − w)·relevanceRank + w·recency`, where recency decays hyperbolically with the note's **live** on-disk mtime (`recencyScore` = `staleDays / (staleDays + age_days)`). The relevance term is **rank-based** (`1/(1+pos)`), so the blend composes cleanly on top of RRF + graph-boost + the cross-encoder reranker without any score-scale mismatch — and `weight = 0` makes the blend key a strictly-decreasing function of position, i.e. a **provable no-op** (the default keeps ranking purely relevance-driven; nobody is surprised by recency silently reordering relevance). Bounded (stats ≤ candidate-pool unique paths, only when enabled) and fail-soft. This is the Memora stale-reuse-frontier knob: your knowledge, now freshness-*weightable*. **1062 → 1072 tests.**
421
+
422
+ **Minor (pre-release) — v3.10 forgetting-aware staleness, increment 4/N.**
423
+
424
+ ### Added
425
+
426
+ - **`--recency-weight <w>` + `--stale-days <n>`** on both `serve` and `serve-http` (via the shared `addAdvancedRetrievalOptions` helper → inherently cli-parity-safe; helper flag count 11 → 13). `--recency-weight` is validated to `[0, 1]` (`server.ts` throws on out-of-range, matching the rc.9 input-validation posture); `--stale-days` parses as a positive integer. Both default to OFF behavior (`weight 0` → no re-rank; `staleDays` only matters when weight > 0).
427
+ - **`recencyScore(ageDays, staleDays)`** in `src/staleness.ts` — a pure, monotonically-decreasing recency curve in `(0, 1]`: `1` at age 0, `0.5` at the half-life, → `0` as age → ∞. Smooth hyperbolic decay (not a hard stale cliff) so a highly-relevant year-old note still competes. Clamps negative/non-finite age → 0 and sub-1 half-life → 1 (no divide-by-zero).
428
+ - **`searchHybrid` ctx gains `recency?: { weight; staleDays }`** — applied after RRF + graph-boost + reranker, before truncation. Re-stats the candidate pool for live mtimes (dedup by path, `Promise.all`, fail-soft per path), blends, re-sorts.
429
+ - **Tests (+10):** `tests/staleness.test.ts` +6 (`recencyScore` curve: anchor points, strict monotonicity, half-life sensitivity, default, clamps, + a NEGATIVE control that fresh strictly outscores old); `tests/search-hybrid.test.ts` +4 (baseline relevance-first; weight 1.0 floats the fresh note above a more-relevant old one; **NEGATIVE control** weight 0 == baseline order; small-half-life still fresh-first).
430
+
431
+ ### Changed
432
+
433
+ - **`tests/cli-parity.test.ts`** — helper flag count 11 → 13; `--recency-weight` / `--stale-days` added to `REQUIRED_RETRIEVAL_FLAGS` (asserts both serve + serve-http carry them).
434
+ - **`docs/api.md`** — two flag-table rows + an "opt-in recency re-ranking" note in the `obsidian_search` freshness paragraph.
435
+ - **`src/staleness.ts` header** — updated the forward-looking deferral comment (rc.1 said recency re-ranking + `--stale-days` were "v3.10 follow-ups"; now documents the incremental rc.1→rc.5 buildout) per the overclaim-#13 rule (update deferral claims in the same commit that ships them).
436
+
437
+ ### Method note
438
+
439
+ The design choice that makes this safe to ship on the **critical search path**: blend the relevance **rank** (`1/(1+pos)`), not the raw fused score. Rank is scale-free, so the blend is agnostic to whether the order came from RRF, graph-boost, or the cross-encoder — and `weight = 0` is a *provable* no-op (the key reduces to a strictly-decreasing function of position, reproducing the input order exactly), which is why the entire feature is gated behind `weight > 0` and the default behavior is byte-identical to rc.4. Recency uses a smooth `staleDays/(staleDays+age)` decay rather than a hard cliff at the stale threshold, so the knob is a nudge, not a guillotine. Per the project's "surface before reorder" caution, rc.4 surfaced the freshness signal read-only; rc.5 only *now* lets it influence ranking, and only when the operator explicitly opts in. **Deferred to rc.6:** the FAMA/forgetting-aware narrative + "grounded, not extracted" sharpening in README/COMPARISON (docs-only).
440
+
441
+ ### Tests (1072)
442
+
443
+ `tests/staleness.test.ts` +6, `tests/search-hybrid.test.ts` +4. 1062 → 1072; claims synced (README ×4 incl. badge, package.json, llms.txt, AGENTS, COMPARISON, ROADMAP).
444
+
445
+ ### Files changed
446
+
447
+ - `src/staleness.ts` (`recencyScore` + header), `src/tools/search.ts` (ctx `recency` + post-rerank blend), `src/cli.ts` (2 flags), `src/server.ts` (parse + validate + plumb), `src/tool-registry.ts` (`registerReadTools` param + ctx), `tests/staleness.test.ts` (+6), `tests/search-hybrid.test.ts` (+4), `tests/cli-parity.test.ts` (11→13 + flags), `docs/api.md` (flag rows + note), test-count claims → 1072.
448
+ - version bump 3.10.0-rc.4 → 3.10.0-rc.5.
449
+
450
+ ---
451
+
452
+ ## [3.10.0-rc.4] — 2026-06-02
453
+
454
+ > **TL;DR:** **v3.10 staleness increment 3 — freshness fields on the PRIMARY search surface.** The hybrid `obsidian_search` tool (the recommended default, the one agents actually call) now carries the same forgetting-aware freshness signal that rc.1 added to `obsidian_find_similar` / `obsidian_semantic_search`: every hit gains `age_days` (whole days since the note's **current on-disk** mtime) and an over-one-year `stale` boolean. Computed by statting the final ≤`limit` hit paths — so it reflects the **live** file mtime, not the possibly-lagging indexed mtime in FTS5/embed-db `source_state`. **Read-only signal — does NOT reorder results** (opt-in recency re-ranking is the next increment); it just lets an agent flag a recalled fact as potentially out-of-date instead of presenting it as current (the Memora stale-memory-reuse frontier). Bounded (O(unique paths) ≤ `limit` concurrent stats) and **fail-soft** (a file deleted between fusion and response simply omits the two fields — never throws). **1059 → 1062 tests.**
455
+
456
+ **Minor (pre-release) — v3.10 forgetting-aware staleness, increment 3/N.**
457
+
458
+ ### Added
459
+
460
+ - **`SearchHybridHit.age_days` + `SearchHybridHit.stale`** (both optional, additive — no API break). Populated after RRF fusion by deduping the final hit paths, statting each concurrently (`node:fs/promises` `stat` via `Promise.all`), and attaching `computeStaleness(mtimeMs, now)` (the same rc.1 helper, threshold `DEFAULT_STALE_DAYS` = 365). PDFs are statted too (they're files with an mtime). The whole enrichment is wrapped in a `try/catch` and each per-path stat is individually guarded, so any failure degrades to "fields omitted for that hit" rather than breaking search.
461
+ - **`tests/search-hybrid.test.ts`** (+3): a positive test (a 400-day-old note → `stale:true` + `age_days` ≥ 399; a 10-day-old note → `stale:false` + `age_days` in [9,30)), a **NEGATIVE control** (all-fresh vault → `stale:false` on every hit + `age_days` < 2), and a fail-soft / all-enriched assertion on the live-vault path. Uses `fs.utimes` for deterministic mtimes (mirrors `tests/stale-notes.test.ts`).
462
+
463
+ ### Changed
464
+
465
+ - **`docs/api.md`** — the `obsidian_search` Returns shape gains `age_days?, stale?`; a new paragraph explains the freshness fields (live-mtime basis, read-only / non-reordering, fail-soft omission). The channels banner now lists `obsidian_search` alongside `find_similar` / `semantic_search` as carrying freshness fields.
466
+
467
+ ### Method note
468
+
469
+ Why stat the final hits instead of reusing the indexed mtime already in `source_state`? Because the indexed mtime can lag a live edit (the watcher debounce window, or an index that hasn't been refreshed) — and a *forgetting* signal that reports a just-edited note as a year stale is worse than no signal. The final hit set is ≤ `limit` (default 10), so O(limit) concurrent stats is cheap and is NOT a whole-vault scan (the rc.36 resource-bound invariant correctly does not classify it as a scanner). This deliberately stops at *surfacing* the signal; **reordering by recency is a separate opt-in flag** (rc.5) so the default ranking stays purely relevance-driven and nobody is surprised by recency silently outranking relevance. **Deferred to rc.5:** opt-in recency re-ranking (`--stale-days` / recency-weight via `addAdvancedRetrievalOptions`, default OFF, cli-parity-guarded).
470
+
471
+ ### Tests (1062)
472
+
473
+ `tests/search-hybrid.test.ts` +3 source `it()`. 1059 → 1062; claims synced (README ×4 incl. badge, package.json, llms.txt, AGENTS, COMPARISON, ROADMAP).
474
+
475
+ ### Files changed
476
+
477
+ - `src/tools/search.ts` (`SearchHybridHit` +2 fields + post-fusion stat-enrichment), `tests/search-hybrid.test.ts` (+3), `docs/api.md` (Returns shape + freshness paragraph + banner), test-count claims → 1062.
478
+ - version bump 3.10.0-rc.3 → 3.10.0-rc.4.
479
+
480
+ ---
481
+
482
+ ## [3.10.0-rc.3] — 2026-06-02
483
+
484
+ > **TL;DR:** **Gate-gap closure — two structural defenses, zero `src/` runtime change.** A post-rc.2 self-audit caught two places where the apparatus was weaker than CLAUDE.md implies. **(1) smoke-from-manifest:** `scripts/smoke.mjs` hardcoded the expected read-tool set + count, so every new tool (rc.2's `obsidian_stale_notes` among them) silently broke smoke until hand-patched — a hidden coupling the `smoke` CI gate masked as a real failure. It now **derives the expected set from `TOOL_MANIFEST`** (single source of truth), so adding a tool never requires editing smoke again. **(2) enforcement-guard taxonomy:** the META-audit's #3 uncovered behavioral dimension (the **#15/#16 "claimed-guarantee vs code-guard" overclaim class**) had only two surface-specific verifiers (OIA 4d for SLSA, 4e for OCR-offline). New `tests/enforcement-guard-invariant.test.ts` is the **generalized** defense: a curated 10-entry inventory mapping each `SECURITY.md` enforcement claim → the exact code-guard symbol that backs it, failing CI if either the marker or the guard goes missing. **1056 → 1059 tests.**
485
+
486
+ **Minor (pre-release) — v3.10 line; gate-gap / structural-defense increment.**
487
+
488
+ ### Added
489
+
490
+ - **`tests/enforcement-guard-invariant.test.ts`** (+3 source `it()`) — the generalized enforcement-verb→code-guard taxonomy (closes the last open Tier-0 item: "a GENERALIZED enforcement-verb grep beyond the SLSA/OCR specifics"). A curated `GUARANTEES` manifest pins 10 `SECURITY.md` claims to their backing symbols (`resolveSafePath`, `assertOcrLangsInstalled`, `cacheMethod`, `MAX_OCR_CANVAS_DIM`, `DEFAULT_OCR_MAX_PAGES`, `0o600`, `0o700`, `SAFE_SCHEMA`, `sweepIdle`, `Allow-Credentials`); `checkGuarantee()` fails if the claim's marker is absent from SECURITY.md **or** the guard symbol is absent from `src/`. 1 positive + 2 NEGATIVE controls (missing guard symbol; missing SECURITY.md marker). This is the inventory-based form of the #15/#16 class — the META-audit's prescription: convert "did we remember to back claim X with a guard?" into a self-checking gate.
491
+
492
+ ### Changed
493
+
494
+ - **`scripts/smoke.mjs`** — the expected read-tool set + count are now **derived from `TOOL_MANIFEST`** (`gating === "always" || "--diagnostic-search-tools" || (withFts && includes("--persistent-index"))`) instead of a hardcoded `baseTools` array + `expectedCount`. Closes the rc.2 gate-gap where a new tool broke `smoke` until the spec was hand-edited; the smoke gate now self-updates with the manifest. Two checks: derived-count match + exact name-set match (`JSON.stringify` equality against the sorted derived set).
495
+
496
+ ### Method note
497
+
498
+ Both fixes are the same shape as the rc.36 meta-audit conclusion — **the apparatus is drift/claim-driven and was blind to two structural gaps**: (1) a test-fixture that duplicated a single source of truth (smoke's tool list vs `TOOL_MANIFEST`) and silently required manual sync, and (2) a claim-class (#15/#16 enforced-guarantee) that had only point defenses, not an inventory invariant. Per the rule "when an external lens finds a behavioral bug, internalize THAT LENS as an inventory invariant," the enforcement-guard test generalizes the two surface-specific OIA checks into one extensible manifest. **Zero `src/` runtime change** — scripts + tests only. **Deferred to rc.4:** plumb `age_days`/`stale` into the hybrid `SearchHybridHit` (the primary search surface — needs a path→mtime map across the multi-stage RRF fusion incl. `path#chunk-N` rows).
499
+
500
+ ### Tests (1059)
501
+
502
+ `tests/enforcement-guard-invariant.test.ts` +3 source `it()`. 1056 → 1059; claims synced (README ×4 incl. tests badge, package.json, llms.txt, AGENTS, COMPARISON, ROADMAP). `scripts/smoke.mjs` is a script (no `it()`), so the smoke refactor does not change the count.
503
+
504
+ ### Files changed
505
+
506
+ - `scripts/smoke.mjs` (derive from `TOOL_MANIFEST`), `tests/enforcement-guard-invariant.test.ts` (new), test-count claims → 1059.
507
+ - version bump 3.10.0-rc.2 → 3.10.0-rc.3.
508
+
509
+ ---
510
+
5
511
  ## [3.10.0-rc.2] — 2026-06-01
6
512
 
7
513
  > **TL;DR:** **v3.10 staleness increment 2 — the `obsidian_stale_notes` tool (the flagship forgetting-aware capability).** A new always-on read tool (the **45th** tool): "what's gone stale in my vault?" — lists notes not edited in N days (default 365), oldest first, so an agent can proactively flag or refresh aged facts instead of recalling them as if current (the Memora frontier). Cheap **mtime-only** scan (`vault.listMarkdown()` + rc.1's `computeStaleness` — NO `readNote`, so it's not a whole-vault content scan and isn't a resource-bound scanner). Self-contained — zero change to the critical search path. The tool-count cascade (44 → 45, always-on read 33 → 34) is fully gate-verified by `docs-consistency` against `TOOL_MANIFEST`. **1050 → 1056 tests.**