From 92b03919034f2ff266f11eee3ad59dffcd5f59a5 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Sun, 30 Aug 2026 20:16:06 +0200 Subject: [PATCH 01/16] Replace docs-mcp-server and Ollama with qmd for memory search - Ships a single 610 MB embedding model and no daemon; reranking and query expansion are disabled by pointing their model slots at the embedder, so a search with default arguments cannot trigger a mid-query model download. - Uses a named qmd index under .claude/.kb-index/ instead of a project-local .qmd/, which is trust-gated and silently substitutes a weaker embedding model for non-interactive callers. - Records KB searches sent as typed searches[] rather than only a bare query field, so the delegation gate still sees them. Claude-Session: https://claude.ai/code/session_01M2kgbNZQeStEs1E8PjBWLs --- .gitignore | 7 +- CLAUDE.md | 15 +- README.md | 40 ++++- SYNC-BLOCKS.md | 2 +- hooks/continuous-learning-activator.sh | 2 +- hooks/kb-gate.sh | 38 +++-- hooks/sync-memories.sh | 126 +++++++++++---- skills/continuous-learning/SKILL.md | 12 +- skills/memory-audit/SKILL.md | 4 +- techpack.yaml | 203 +++++++++++++++---------- templates/continuous-learning.md | 10 +- tests/kb-gate-test.sh | 37 +++++ 12 files changed, 347 insertions(+), 149 deletions(-) diff --git a/.gitignore b/.gitignore index 0fbf0b2..5a9d5d2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ # Thumbnails ._* +.maestri + # Custom folder icons Icon @@ -15,4 +17,7 @@ Icon .TemporaryItems .Trashes .VolumeIcon.icns -.com.apple.timemachine.donotpresent \ No newline at end of file +.com.apple.timemachine.donotpresent + +# Memory pack: local search index +.claude/.kb-index/ diff --git a/CLAUDE.md b/CLAUDE.md index a768b46..f762181 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,8 @@ The consequence that matters most: **nothing here executes from the repo.** Edit |---|---| | Run the hook test suite | `bash tests/kb-gate-test.sh` | | Verify the SYNC blocks agree | the snippet below (full version at the bottom of `SYNC-BLOCKS.md`) | -| Check the manifest parses | `python3 -c "import yaml;yaml.safe_load(open('techpack.yaml'))"` | +| Check the manifest parses | `/usr/bin/python3 -c "import yaml;yaml.safe_load(open('techpack.yaml'))"` — the system Python; Homebrew's has no PyYAML | +| Measure retrieval quality | `bash tests/retrieval-bench.sh` (untracked; needs a built index) | | Install a change locally | `mcs sync --global`, or `mcs sync` inside a project | | Check installed health | `mcs doctor` | @@ -44,16 +45,22 @@ Two harness details are load-bearing rather than incidental: **One dispatcher, four hook events.** `hooks/kb-gate.sh` is registered four times in `techpack.yaml` (UserPromptSubmit, PostToolUse, PreToolUse, SubagentStart) and branches on `hook_event_name`. Matchers are broad on purpose; which agent types count as "discovery" is decided in exactly one place, `GATED_AGENTS`. `hooks/sync-memories.sh` is likewise registered twice, on SessionStart and UserPromptSubmit. -**That dispatcher deliberately omits `set -e` and `set -u`**, unlike `sync-memories.sh` which uses `set -uo pipefail`. Its file header explains why and lists rules that are load-bearing: fail open, never `exit 2`, never call `docs-mcp-server` (too slow for `PreToolUse`), log every evaluation. Read that header before editing it. +**That dispatcher deliberately omits `set -e` and `set -u`**, unlike `sync-memories.sh` which uses `set -uo pipefail`. Its file header explains why and lists rules that are load-bearing: fail open, never `exit 2`, never call `qmd` (it loads an embedding model; far too slow for `PreToolUse`), log every evaluation. Read that header before editing it. -**Project-root and library derivation must match across two hooks.** `sync-memories.sh` and `resolve_paths()` in `kb-gate.sh` both resolve git toplevel → `CLAUDE_PROJECT_DIR` → `$PWD`, and derive the library name as the root directory's basename. kb-gate quotes that name back to Claude, and it has to be the one sync-memories indexed. Both files carry "keep in sync" comments. +**Project-root derivation must match across two hooks and the manifest.** `sync-memories.sh`, `resolve_paths()` in `kb-gate.sh`, and the `memory-loop` MCP launcher in `techpack.yaml` all resolve git toplevel → `CLAUDE_PROJECT_DIR` → `$PWD`. The first two must agree on which project they are looking at; the launcher must additionally agree with `sync-memories.sh` on `.claude/.kb-index/`, because one writes the index the other opens. All three carry "keep in sync" comments. Non-git projects and launches from a subdirectory both go through the same ladder — do not "simplify" it to `$PWD`. + +**The index is reached by `--index`, never by a project-local `.qmd/`.** Two reasons, and the second is the dangerous one. A user may keep their own `.qmd/` at the project root for their own code, which this pack must not touch. And a project-local `.qmd/index.yml` falls under qmd's trust gate, which covers a non-default `models.embed` — for a non-interactive caller the gate does not prompt or fail, it *skips*, silently substituting a much weaker default model. Named indexes are never gated. `QMD_CONFIG_DIR` and `INDEX_PATH` are what move a named index back under the project directory. + +**Reranking and query expansion are disabled by pointing their model slots at the embedding model.** The MCP `query` tool hard-defaults `rerank: true` with no server-side way to turn it off, and a *missing* model is downloaded mid-query with no progress output. An embedding model has no ranking head, so qmd fails to build a ranking context, warns, and falls back to RRF — measured at MRR 0.792 against 0.800 for an explicit `rerank: false`, and it buys back zero R@5 versus a real reranker. This depends on qmd's graceful-degradation path rather than a documented switch, which is why `@tobilu/qmd` is pinned to an exact version and why one doctor check issues a *default-argument* query: that check is what would catch the behaviour changing under an upgrade. + +**The search call shape is stated in four places, deliberately.** "Typed `lex`+`vec` lines, `rerank: false`" appears in the index's `global_context` (written by `hooks/sync-memories.sh`, and the only text qmd injects into the model's system prompt), `templates/continuous-learning.md` (the only thing that reaches a user's `CLAUDE.md`), `skills/continuous-learning/SKILL.md`, and the `SubagentStart` briefing in `hooks/kb-gate.sh`. No single mechanism reaches all four consumers, so this is four copies rather than one source — change one and check the other three. It matters because the unguided path is measurably worse, not just slower. **Three text blocks must stay byte-identical across three files.** `capture-rules`, `strip-the-anchors`, and `applies-to` appear in both `SKILL.md`s and in `SYNC-BLOCKS.md`, enforced by `.github/workflows/sync-blocks.yml`. Two rules when touching them: - Blocks are verdict-neutral. Each skill adds its own verb *outside* the fence — capture says "skip", audit says "DROP". Never move an action verb inside the locked block. - Never write a real tag name in prose. The drift check's `awk` range grabs the first matching opener, so a literal mention would shadow the canonical block and make it invisible to the verifier. `SYNC-BLOCKS.md` uses a placeholder form for exactly this reason. -**Templates are installed as marked sections inside someone's `CLAUDE.md`, not as files.** The `templates:` block in `techpack.yaml` maps `templates/continuous-learning.md` to a section fenced by ``. On a global sync it lands in `~/.claude/CLAUDE.md`; on a project sync, in that project's `CLAUDE.local.md`. Edit the template here and re-sync, because editing inside the markers drifts and is overwritten. `__PROJECT_DIR_NAME__` is substituted at install time, like the hook's mode. +**Templates are installed as marked sections inside someone's `CLAUDE.md`, not as files.** The `templates:` block in `techpack.yaml` maps `templates/continuous-learning.md` to a section fenced by ``. On a global sync it lands in `~/.claude/CLAUDE.md`; on a project sync, in that project's `CLAUDE.local.md`. Edit the template here and re-sync, because editing inside the markers drifts and is overwritten. The template has no placeholders of its own; only `hooks/kb-gate.sh` carries one, `KB_GATE_MODE`. **Installed artifacts are content-hash verified.** `mcs doctor` compares hashes of installed files, so hand-editing an installed copy registers as drift and the next `mcs sync` restores the packaged version. This is why a skill can never write to its own files: anything saved that way is destroyed on the next sync. diff --git a/README.md b/README.md index ea1f929..2a35556 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ mcs sync --global # 3. install globally (~/.claude) mcs doctor # 4. verify everything is healthy ``` -**Prerequisites:** macOS, [Claude Code](https://docs.anthropic.com/en/docs/claude-code), and [Ollama](https://ollama.com) (local embeddings runtime). `mcs` installs the rest (Node, `gh`, `jq`) automatically. +**Prerequisites:** macOS, [Claude Code](https://docs.anthropic.com/en/docs/claude-code), and Node 22 or newer. `mcs` installs the rest (Node, `gh`, `jq`, [qmd](https://github.com/tobi/qmd)) automatically, and downloads a ~610 MB embedding model once on first sync — shared by every project, with no daemon left running afterwards. Global is the recommended scope — this pack has no per-project config, so installing once makes memory available in every project automatically. To scope it to a single repo instead, run `mcs sync` from inside that repo. @@ -32,11 +32,11 @@ flowchart LR B --> C[Work session] C --> D[Capture learnings & decisions] D --> E[(.claude/memories/)] - E --> F["Ollama embeddings
semantic index"] + E --> F["local embeddings
semantic index"] F -. re-index on session start / change .-> B ``` -1. **Session starts** — a hook re-indexes `.claude/memories/` into a local vector store (Ollama `nomic-embed-text`), in the background. +1. **Session starts** — a hook re-indexes `.claude/memories/` into a local vector store in the background. Embeddings are computed in-process by `qmd`, so there is no service to start and nothing left running between sessions. 2. **Before any task** — Claude is instructed to search the knowledge base first, surfacing relevant past learnings and decisions. 3. **Before delegating** — sub-agents can't see the parent's KB results, so they'd rediscover everything from scratch. A gate hook closes that gap from both ends: it requires the findings to be pasted into the sub-agent's prompt, and tells any discovery agent to search the KB itself if they weren't. Configurable per project, from a reminder up to a hard block. 4. **During work** — a prompt-submit hook reminds Claude to notice when the current interaction produces knowledge worth saving. @@ -66,7 +66,7 @@ To change the answer later, re-run `mcs sync` — the mode is baked into the ins | Component | What it does | |---|---| -| **docs-mcp-server** (MCP) | Read-only semantic search over `.claude/memories/`, backed by local Ollama embeddings | +| **memory-loop** (MCP) | Semantic search over `.claude/memories/`, backed by a local embedding model | | **continuous-learning** (skill) | Extracts learnings and decisions from a session into structured memory files | | **memory-audit** (skill) | Reviews existing memories and flags stale or duplicate entries to keep the KB lean | | **sync-memories.sh** (hook) | Indexes/re-indexes memories on session start and when they change mid-session | @@ -81,6 +81,36 @@ Memories come in two flavors, both stored as version-controlled, human-readable --- +## Upgrading from the Ollama version + +Earlier releases indexed memories through `docs-mcp-server` backed by an Ollama daemon. `mcs sync` +converges on its own: the old MCP server is deregistered for you, and the new index is built on the +next session start. Nothing below is required. + +What `mcs` cannot clean up is what the old release installed through plain shell commands. If you +want the disk space back, and **only if nothing else on your machine uses these**: + +```bash +npm uninstall -g @arabold/docs-mcp-server +rm -rf ~/Library/Application\ Support/docs-mcp-server # see the warning below +ollama rm nomic-embed-text +``` + +Three things to check before running any of them: + +- **`docs-mcp-server` indexes external documentation too.** If you ever pointed it at a library's + docs, that is what you would be uninstalling. +- **Its store is shared across every library you indexed with it**, not just your memories. Deleting + the directory deletes all of them. Run `docs-mcp-server list` first and see what is in there. +- **Ollama may be serving something else.** `ollama list` shows what it holds; if + `nomic-embed-text` is the only entry and nothing else here needs the runtime, you can also remove + the app and its data — `/Applications/Ollama.app` and `~/.ollama`. Removing the app clears the + macOS Login Item it registers. + +This pack no longer installs or manages any of them. + +--- + ## Directory structure ``` @@ -88,7 +118,7 @@ memory/ ├── techpack.yaml # Manifest — defines all components ├── config/settings.json # Disables built-in auto-memory ├── hooks/ -│ ├── sync-memories.sh # Ollama health + memory indexing/reindexing +│ ├── sync-memories.sh # Memory indexing/reindexing │ ├── continuous-learning-activator.sh # Knowledge extraction reminder │ └── kb-gate.sh # Keeps KB lookups ahead of delegated discovery ├── skills/ diff --git a/SYNC-BLOCKS.md b/SYNC-BLOCKS.md index f014594..3c3166d 100644 --- a/SYNC-BLOCKS.md +++ b/SYNC-BLOCKS.md @@ -51,7 +51,7 @@ Locations: ```markdown -**The `Applies to:` field.** Place `**Applies to:**` on the line immediately after the `# Title` heading of every memory; it declares which project(s) the memory targets. Use the **git repo name** — the last path segment of `git remote get-url origin`, with `.git` stripped (e.g. `git@github.com:org/repo.git` → `repo`; `https://github.com/owner/my-app.git` → `my-app`). Fall back to the working directory's basename only when the repo has no remote configured. Use the repo name — not the directory basename — because folder names vary across clones while the repo name is stable. This is also why `Applies to:` may differ from the `library:` parameter used for `search_docs`, which is folder-based and set automatically by the indexing hook. +**The `Applies to:` field.** Place `**Applies to:**` on the line immediately after the `# Title` heading of every memory; it declares which project(s) the memory targets. Use the **git repo name** — the last path segment of `git remote get-url origin`, with `.git` stripped (e.g. `git@github.com:org/repo.git` → `repo`; `https://github.com/owner/my-app.git` → `my-app`). Fall back to the working directory's basename only when the repo has no remote configured. Use the repo name — not the directory basename — because folder names vary across clones while the repo name is stable. This is also why `Applies to:` may differ from the set of memories the search index actually covers, which is folder-based and set automatically by the indexing hook. When a memory genuinely applies to multiple projects, list them comma-separated (e.g. `**Applies to:** web-dashboard, ios-app, api-backend`); the content must stay true in every listed project. When a memory is only partially relevant to one listed project, split it into separate memories instead of mixing. diff --git a/hooks/continuous-learning-activator.sh b/hooks/continuous-learning-activator.sh index 65c2ecd..35e6a7b 100755 --- a/hooks/continuous-learning-activator.sh +++ b/hooks/continuous-learning-activator.sh @@ -4,7 +4,7 @@ cat << 'EOF' MANDATORY MEMORY PROTOCOL If this starts a new sub-task or phase (tests, refactor, deploy, etc.) -→ search the KB via search_docs for relevant patterns first. +→ search the KB via mcp__memory-loop__query for relevant patterns first. If you hit an unexpected error or are about to debug/diagnose → search the KB FIRST, before reasoning from scratch. diff --git a/hooks/kb-gate.sh b/hooks/kb-gate.sh index 68e46ec..d8ffbbf 100644 --- a/hooks/kb-gate.sh +++ b/hooks/kb-gate.sh @@ -4,7 +4,7 @@ # # One dispatcher registered on four events, branching on `hook_event_name`: # UserPromptSubmit → stamp the turn boundary, and run housekeeping -# PostToolUse → record a successful search_docs query +# PostToolUse → record a successful KB search # PreToolUse → judge a sub-agent spawn, and warn or block # SubagentStart → tell a discovery sub-agent the KB exists # @@ -14,7 +14,7 @@ # - NEVER exit 2. That blocks unconditionally, turning a script bug into a hard # gate with no escape. This is also why `set -u`/`set -e` are omitted: an # unset variable should fall through to a silent allow, not abort mid-branch. -# - NEVER call docs-mcp-server here. That CLI takes seconds; PreToolUse runs +# - NEVER call qmd here. That CLI loads an embedding model; PreToolUse runs # before every sub-agent spawn. File stats only. # - Log every evaluation. Comparing log line count against the spawn count in a # session transcript is the only way to catch a matcher that matches nothing. @@ -102,11 +102,10 @@ EOF # worth more than the fork. resolve_paths() { [ -n "$project_root" ] && return 0 + # keep in sync with hooks/sync-memories.sh — that hook indexes the memories + # directory this one gates on, and both must land on the same project. project_root=$(git rev-parse --show-toplevel 2>/dev/null) [ -n "$project_root" ] || project_root="${CLAUDE_PROJECT_DIR:-$PWD}" - # keep in sync with hooks/sync-memories.sh project root + library derivation — - # the library name quoted back to Claude has to be the one that was indexed. - library="${project_root##*/}" MEMORIES_DIR="$project_root/.claude/memories" LOG_FILE="$project_root/.claude/.kb-gate.log" STATE_DIR="$project_root/.claude/.kb-gate" @@ -219,7 +218,17 @@ PostToolUse) # A KB search landed. Store the query text (not just the turn) so the barrier # can become topical later without a state migration. The number of lines # matching the current turn is also what stamps a denial's progress marker. - query=$(jq -r '.tool_input.query // ""' <<<"$payload" 2>/dev/null) + # The search tool takes *either* a single `query` or a list of typed + # `searches`, never both. Reading only `query` would miss every hybrid + # lex+vec call — the form this pack steers Claude toward — and the barrier + # would then deny forever with nothing to show why. + # Built as a list rather than a `//` chain: `//` only falls through on null, + # so an empty join would stop it, and `null | map` raises — either way the + # whole extraction yields nothing and the search goes unrecorded. + query=$(jq -r '[.tool_input.query, + ((.tool_input.searches // []) | map(.query) | join(" ")), + .tool_input.intent] + | map(select(. != null and . != "")) | first // ""' <<<"$payload" 2>/dev/null) [ -n "$query" ] || exit 0 ensure_state || exit 0 printf '%s\t%s\n' "$(current_turn)" "$query" >>"$QUERIES_FILE" 2>/dev/null || true @@ -238,20 +247,21 @@ SubagentStart) # Cost is bounded on purpose: skip the search entirely when the prompt already # carries findings, and spend at most one query otherwise. SubagentStart input # does not include the prompt, so the agent has to make that call itself. - jq -nc --arg e "$event" --arg m "$KB_MARKER" --arg lib "$library" '{ + jq -nc --arg e "$event" --arg m "$KB_MARKER" '{ hookSpecificOutput: { hookEventName: $e, additionalContext: ( "This project keeps a knowledge base of past learnings, decisions, and\n" + "debugging discoveries in .claude/memories/, searchable with\n" + - "mcp__docs-mcp-server__search_docs using library=\"" + $lib + "\".\n\n" + + "mcp__memory-loop__query.\n\n" + "- If your prompt contains a \"" + $m + "\" block, treat it as established\n" + " ground truth. Verify it against the code, but do NOT search the KB and\n" + " do NOT re-derive it.\n" + "- Otherwise, if you are about to read or grep more than a couple of files,\n" + - " issue ONE search_docs query for the topic of your task first — one search\n" + - " is far cheaper than a blind file sweep. Unlike the main thread, do not try\n" + - " keyword variations: if nothing relevant comes back, move on to the code.\n" + + " issue ONE mcp__memory-loop__query for the topic of your task first — one\n" + + " search is far cheaper than a blind file sweep. Unlike the main thread, do\n" + + " not try keyword variations: if nothing relevant comes back, move on to\n" + + " the code.\n" + "- Report back anything the KB got wrong or left out." ) } @@ -317,7 +327,7 @@ PreToolUse) # Name whichever half failed, so the message is actionable. missing="" - [ "$fresh_query" = false ] && missing="no search_docs call has run since this turn began" + [ "$fresh_query" = false ] && missing="no KB search has run since this turn began" [ "$had_kb_block" = false ] && missing="${missing:+$missing, and }the prompt has no \"$KB_MARKER\" block" @@ -377,7 +387,7 @@ EOF # Phrased as a missing prerequisite plus an explicit retry instruction. A # bare denial reads as "the user declined" and makes Claude abandon the # path instead of satisfying the requirement. - jq -nc --arg e "$event" --arg r "Prerequisite missing: $missing. Call search_docs(library=\"$library\", query=\"\") first, then re-issue this exact call with a \"$KB_MARKER\" block (1-5 bullets of findings, or \"$KB_MARKER none relevant.\") at the top of the prompt. Spawning several agents at once: write the findings to a scratchpad file and open each prompt with \"$KB_MARKER see \" instead of repeating them. Sub-agents cannot see your KB results — unpasted context is rediscovered from scratch." \ + jq -nc --arg e "$event" --arg r "Prerequisite missing: $missing. Search the KB with mcp__memory-loop__query first, then re-issue this exact call with a \"$KB_MARKER\" block (1-5 bullets of findings, or \"$KB_MARKER none relevant.\") at the top of the prompt. Spawning several agents at once: write the findings to a scratchpad file and open each prompt with \"$KB_MARKER see \" instead of repeating them. Sub-agents cannot see your KB results — unpasted context is rediscovered from scratch." \ '{hookSpecificOutput:{hookEventName:$e,permissionDecision:"deny",permissionDecisionReason:$r}}' exit 0 fi @@ -385,7 +395,7 @@ EOF # warn: advise without blocking. No permissionDecision field — returning # "allow" would bypass the normal permission flow. log_decision false warn - jq -nc --arg e "$event" --arg m "KB protocol: $missing. Sub-agents cannot see your KB results, so this agent will rediscover from scratch. Before the next spawn, search the KB (library=\"$library\") and open the prompt with a \"$KB_MARKER\" block." \ + jq -nc --arg e "$event" --arg m "KB protocol: $missing. Sub-agents cannot see your KB results, so this agent will rediscover from scratch. Before the next spawn, search the KB and open the prompt with a \"$KB_MARKER\" block." \ '{hookSpecificOutput:{hookEventName:$e,additionalContext:$m}}' ;; esac diff --git a/hooks/sync-memories.sh b/hooks/sync-memories.sh index a406be4..f299719 100755 --- a/hooks/sync-memories.sh +++ b/hooks/sync-memories.sh @@ -1,6 +1,6 @@ #!/bin/bash -# Hook: index .claude/memories/ into docs-mcp-server for semantic search. +# Hook: index .claude/memories/ into this project's search index. # Runs on SessionStart and UserPromptSubmit (async). Never fails the hook. set -uo pipefail @@ -9,28 +9,104 @@ set -uo pipefail cat >/dev/null 2>&1 || true # Resolve the project root. Prefer the git repo toplevel — it's the stable -# anchor so launching from any subdirectory of a repo maps to the same library. +# anchor so launching from any subdirectory of a repo maps to the same index. # In a non-git folder git exits non-zero (empty output); fall back to where # Claude was launched (CLAUDE_PROJECT_DIR, else $PWD). # -# keep in sync with hooks/kb-gate.sh resolve_paths() — that hook quotes the -# library name back to Claude, and it has to be the one indexed here. +# keep in sync with the memory-loop MCP launcher in techpack.yaml — that command +# opens the index this hook writes, so both have to agree on where it lives. project_root=$(git rev-parse --show-toplevel 2>/dev/null) [ -n "$project_root" ] || project_root="${CLAUDE_PROJECT_DIR:-$PWD}" MEMORIES_DIR="$project_root/.claude/memories" +INDEX_DIR="$project_root/.claude/.kb-index" +CONFIG="$INDEX_DIR/memory-loop.yml" TIMESTAMP_FILE="$project_root/.claude/.memories-last-indexed" +# One model fills all three of qmd's slots. Embed is the only one that does real +# work; rerank and generate are pointed here deliberately. An embedding model has +# no ranking head, so qmd's reranker fails to build a context, warns, and falls +# back to RRF scores. That matters because the MCP `query` tool defaults +# `rerank: true` and a *missing* model would instead be downloaded mid-query — +# hundreds of MB, inside a tool call, with no progress output. Measured cost of +# the fallback: MRR 0.792 against 0.800 for an explicit rerank:false. +EMBED_MODEL="hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf" + # Exit early if no memories directory [ -d "$MEMORIES_DIR" ] || exit 0 -# Exit early if Ollama is not running -curl -s --max-time 2 http://localhost:11434/api/tags >/dev/null 2>&1 || exit 0 +# Exit early if qmd is not installed +command -v qmd >/dev/null 2>&1 || exit 0 + +# --- Index configuration --- +# A named index (`--index`) rather than a project-local .qmd/ directory, for two +# reasons. It leaves any .qmd/ the user keeps for their own code alone; and a +# checked-in .qmd/index.yml falls under qmd's trust gate, which skips a +# non-default embedding model for non-interactive callers *without an error* — +# retrieval would silently fall back to a weaker model. Named indexes are never +# gated. QMD_CONFIG_DIR and INDEX_PATH move that named index back under the +# project so it stays inspectable and deletable. +export QMD_CONFIG_DIR="$INDEX_DIR" +export INDEX_PATH="$INDEX_DIR/memory-loop.sqlite" + +mkdir -p "$INDEX_DIR" 2>/dev/null || exit 0 + +# Written wholesale rather than patched: the file has exactly one collection and +# one model, so there is nothing to preserve and no YAML parser to depend on. +# Rewritten whenever it drifts, which also repairs a config edited by hand. +# +# The collection path is written as-is, unresolved. .claude/memories may be a +# plain directory or a symlink into a shared checkout (the shared-memories pack +# makes it one), and both index correctly — qmd opens the collection root +# directly and resolves both sides of its containment check. Storing the literal +# path is what keeps the two interchangeable: `qmd collection add` would record +# the symlink's *target*, which goes stale the moment the link is re-pointed or +# the pack is added to a project that already had a real directory. +cat >"$CONFIG.new" </dev/null; then + mv -f "$CONFIG.new" "$CONFIG" || exit 0 + config_changed=yes +else + rm -f "$CONFIG.new" +fi # --- Staleness check --- -# If timestamp file exists and nothing changed, nothing to do. -# If timestamp file doesn't exist, this is the first run — do a full index. -if [ -f "$TIMESTAMP_FILE" ]; then +# Runs after the config sync, not before: a config that changed (a new model, new +# guidance) has to reach the index even when no memory file moved. Skipping here +# is only safe when nothing at all has changed. +# +# The index has to be present for the timestamp to mean anything. It outlived the +# previous retrieval backend: on an upgrade the file says "indexed recently" while +# no index exists at all, and without this the hook would skip until some memory +# happened to change — leaving the KB silently unsearchable in between. +if [ "$config_changed" = no ] && [ -f "$TIMESTAMP_FILE" ] && [ -f "$INDEX_PATH" ]; then newest=$(find "$MEMORIES_DIR" -name "*.md" -newer "$TIMESTAMP_FILE" -print -quit 2>/dev/null) # Also check if the directory itself was modified (file added/removed) dir_changed="" @@ -38,28 +114,14 @@ if [ -f "$TIMESTAMP_FILE" ]; then [ -n "$newest" ] || [ -n "$dir_changed" ] || exit 0 fi -# --- Index --- -repo_name=$(basename "$project_root") - -export OPENAI_API_KEY=ollama -export OPENAI_API_BASE=http://localhost:11434/v1 -export DOCS_MCP_SCRAPER_SECURITY_FILE_ACCESS_MODE=unrestricted -export DOCS_MCP_SCRAPER_SECURITY_FILE_ACCESS_INCLUDE_HIDDEN=true -export DOCS_MCP_SCRAPER_SECURITY_FILE_ACCESS_FOLLOW_SYMLINKS=true -export DOCS_MCP_EMBEDDING_MODEL="openai:nomic-embed-text" - -# Check if library already indexed with the same source URL -existing_url=$(docs-mcp-server list 2>/dev/null \ - | jq -r --arg name "$repo_name" '.[] | select(.name == $name) | .versions[0].sourceUrl // empty') - -if [ "$existing_url" = "file://$MEMORIES_DIR" ]; then - docs-mcp-server refresh "$repo_name" \ - --silent >/dev/null 2>&1 -else - docs-mcp-server scrape "$repo_name" \ - "file://$MEMORIES_DIR" \ - --silent >/dev/null 2>&1 -fi +# --- Reindex --- +# `update` rescans the collection for added, changed and removed files; `embed` +# vectorises whatever came back without one. Both are incremental — a no-op pass +# is under a tenth of a second. +qmd --index memory-loop update >/dev/null 2>&1 || exit 0 +qmd --index memory-loop embed >/dev/null 2>&1 || exit 0 -# Mark indexing time for subsequent staleness checks +# Mark indexing time for subsequent staleness checks. Gated on the two commands +# above succeeding: an unconditional touch marks a *failed* index as fresh, and +# the staleness check then skips it forever with nothing reporting the gap. touch "$TIMESTAMP_FILE" diff --git a/skills/continuous-learning/SKILL.md b/skills/continuous-learning/SKILL.md index 24deebb..f7112e6 100644 --- a/skills/continuous-learning/SKILL.md +++ b/skills/continuous-learning/SKILL.md @@ -6,7 +6,7 @@ description: > .claude/memories/ for codebase knowledge, CLAUDE.local.md for environment/tool/instance config, or skip for public documentation. Also use when the user asks to "run a retrospective", "extract learnings", or "save what we learned" from the current session. -allowed-tools: Write, Read, Glob, Edit, Bash, WebSearch, mcp__docs-mcp-server__search_docs, mcp__docs-mcp-server__list_libraries +allowed-tools: Write, Read, Glob, Edit, Bash, WebSearch, mcp__memory-loop__query, mcp__memory-loop__get --- # Continuous Learning Skill @@ -96,13 +96,15 @@ If any rule fails, rewrite the memory to satisfy it (e.g. anonymize an actor, re ### Step 2: Search Existing Knowledge -**Always search docs-mcp-server first** (semantic search across documentation and project memories): +**Always search the memory index first** (semantic search across this project's memories): ``` -mcp__docs-mcp-server__search_docs(library: "", query: "") +mcp__memory-loop__query(searches: [{type: "lex", query: ""}, + {type: "vec", query: ""}], + intent: "", rerank: false) ``` -**Fall back to file listing** if search_docs returns no results or the project library is not yet indexed: +**Fall back to file listing** if the search returns no results or the index is not yet built: ``` Glob(pattern: ".claude/memories/*.md") @@ -142,7 +144,7 @@ Read [references/templates.md](references/templates.md) for template structures. Fill in `Applies to:` directly under the title heading of every memory. -**The `Applies to:` field.** Place `**Applies to:**` on the line immediately after the `# Title` heading of every memory; it declares which project(s) the memory targets. Use the **git repo name** — the last path segment of `git remote get-url origin`, with `.git` stripped (e.g. `git@github.com:org/repo.git` → `repo`; `https://github.com/owner/my-app.git` → `my-app`). Fall back to the working directory's basename only when the repo has no remote configured. Use the repo name — not the directory basename — because folder names vary across clones while the repo name is stable. This is also why `Applies to:` may differ from the `library:` parameter used for `search_docs`, which is folder-based and set automatically by the indexing hook. +**The `Applies to:` field.** Place `**Applies to:**` on the line immediately after the `# Title` heading of every memory; it declares which project(s) the memory targets. Use the **git repo name** — the last path segment of `git remote get-url origin`, with `.git` stripped (e.g. `git@github.com:org/repo.git` → `repo`; `https://github.com/owner/my-app.git` → `my-app`). Fall back to the working directory's basename only when the repo has no remote configured. Use the repo name — not the directory basename — because folder names vary across clones while the repo name is stable. This is also why `Applies to:` may differ from the set of memories the search index actually covers, which is folder-based and set automatically by the indexing hook. When a memory genuinely applies to multiple projects, list them comma-separated (e.g. `**Applies to:** web-dashboard, ios-app, api-backend`); the content must stay true in every listed project. When a memory is only partially relevant to one listed project, split it into separate memories instead of mixing. diff --git a/skills/memory-audit/SKILL.md b/skills/memory-audit/SKILL.md index e43f7f8..24eeda4 100644 --- a/skills/memory-audit/SKILL.md +++ b/skills/memory-audit/SKILL.md @@ -5,7 +5,7 @@ description: > Use this skill when the user says "audit memories", "review memories", "clean up memories", "memory audit", "check my memories", or wants to prune, deduplicate, or assess the quality of their stored learnings and decisions. This is a manual-only skill — never trigger automatically. -allowed-tools: Read, Glob, Grep, Edit, Bash, Write, mcp__docs-mcp-server__search_docs, mcp__docs-mcp-server__list_libraries, AskUserQuestion +allowed-tools: Read, Glob, Grep, Edit, Bash, Write, mcp__memory-loop__query, mcp__memory-loop__get, AskUserQuestion --- # Memory Audit Skill @@ -36,7 +36,7 @@ The audit enforces these rules through the criteria below — see Group A. ## The `Applies to:` field -**The `Applies to:` field.** Place `**Applies to:**` on the line immediately after the `# Title` heading of every memory; it declares which project(s) the memory targets. Use the **git repo name** — the last path segment of `git remote get-url origin`, with `.git` stripped (e.g. `git@github.com:org/repo.git` → `repo`; `https://github.com/owner/my-app.git` → `my-app`). Fall back to the working directory's basename only when the repo has no remote configured. Use the repo name — not the directory basename — because folder names vary across clones while the repo name is stable. This is also why `Applies to:` may differ from the `library:` parameter used for `search_docs`, which is folder-based and set automatically by the indexing hook. +**The `Applies to:` field.** Place `**Applies to:**` on the line immediately after the `# Title` heading of every memory; it declares which project(s) the memory targets. Use the **git repo name** — the last path segment of `git remote get-url origin`, with `.git` stripped (e.g. `git@github.com:org/repo.git` → `repo`; `https://github.com/owner/my-app.git` → `my-app`). Fall back to the working directory's basename only when the repo has no remote configured. Use the repo name — not the directory basename — because folder names vary across clones while the repo name is stable. This is also why `Applies to:` may differ from the set of memories the search index actually covers, which is folder-based and set automatically by the indexing hook. When a memory genuinely applies to multiple projects, list them comma-separated (e.g. `**Applies to:** web-dashboard, ios-app, api-backend`); the content must stay true in every listed project. When a memory is only partially relevant to one listed project, split it into separate memories instead of mixing. diff --git a/techpack.yaml b/techpack.yaml index 17d4502..67c916f 100644 --- a/techpack.yaml +++ b/techpack.yaml @@ -43,96 +43,136 @@ components: description: Lightweight JSON processor brew: jq - - id: ollama - displayName: Ollama - description: Local LLM runtime (compatible with all Apple Silicon) + # ── Retrieval engine ──────────────────────────────────────────────────── + # Install the binary once (pinned) so Claude launches a prebuilt binary and no + # package manager ever runs inside a hook. + # + # The exact pin is load-bearing beyond reproducibility. This pack disables + # reranking and query expansion by pointing those two model slots at the + # embedding model: an embedding model has no ranking head, so qmd catches the + # failure and falls back to RRF scores. That fallback is behaviour, not a + # documented switch, so the version it was verified against is pinned here and + # a doctor check exercises it. + - id: qmd-install + displayName: qmd + description: Local search engine over markdown — no daemon, bundled SQLite + sqlite-vec + dependencies: [node] type: configuration - shell: "curl -fsSL https://ollama.com/install.sh | sh" - shellInteractive: true + shell: "npm install -g @tobilu/qmd@2.8.3" doctorChecks: + # `status` opens the store and loads better-sqlite3 + sqlite-vec. `--version` + # loads neither, so a broken or ABI-mismatched native build would pass it. - type: commandExists - name: "Ollama installed" - section: AI Models - command: ollama - args: ["--version"] + name: "qmd binary loads" + section: MCP Servers + command: qmd + args: ["status"] + fixCommand: "npm install -g @tobilu/qmd@2.8.3" + - type: commandExists + name: "Node.js 22 or newer (qmd requires it)" + section: MCP Servers + command: node + args: ["-e", "process.exit(parseInt(process.versions.node) >= 22 ? 0 : 1)"] + fixCommand: "brew upgrade node" - - id: ollama-service - displayName: Ollama service - description: Ensure Ollama is running + # One model serves all three of qmd's slots, so this downloads a single file. + # It runs at sync time on purpose: a first-run download inside the async + # indexing hook would blow its timeout and leave the index silently empty. + - id: qmd-model + displayName: Embedding model + description: Downloads Qwen3-Embedding-0.6B once (~610 MB, shared across all projects) + dependencies: [qmd-install] type: configuration - dependencies: [ollama] - shell: "open /Applications/Ollama.app --args hidden" + shell: | + d=$(mktemp -d) + m="hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf" + QMD_CONFIG_DIR="$d" INDEX_PATH="$d/pull.sqlite" \ + QMD_EMBED_MODEL="$m" QMD_GENERATE_MODEL="$m" QMD_RERANK_MODEL="$m" \ + qmd --index pull pull --progress + s=$?; rm -rf "$d"; exit $s doctorChecks: - type: commandExists - name: "Ollama service running" + name: "Qwen3-Embedding-0.6B downloaded" section: AI Models - command: curl - args: ["-sf", "http://localhost:11434/api/tags"] - fixCommand: "open /Applications/Ollama.app --args hidden" + command: /bin/sh + args: ["-c", 'test -f "${XDG_CACHE_HOME:-$HOME/.cache}/qmd/models/hf_Qwen_Qwen3-Embedding-0.6B-Q8_0.gguf"'] - - id: ollama-nomic-embed - displayName: nomic-embed-text model - description: Embedding model for docs-mcp-server - type: configuration - dependencies: [ollama-service] - shell: "ollama pull nomic-embed-text" + # ── MCP Server ────────────────────────────────────────────────────────── + # One static command line serves every project, and env values are literal + # strings, so the per-project index path has to be computed when the server is + # launched. Claude spawns MCP servers with the project as their working + # directory, which is what makes that resolution possible at all. + # + # `--index` is deliberate, not cosmetic. Reaching an index through a + # project-local .qmd/ directory puts it under qmd's trust gate, which skips a + # non-default embedding model for non-interactive callers without an error — + # retrieval would quietly fall back to a weaker model. A named index is never + # gated. It also leaves any .qmd/ the user keeps for their own code untouched. + # + # keep in sync with hooks/sync-memories.sh — the hook writes the index this + # reads, and they have to agree on where it is. + - id: memory-loop + description: Semantic search over this project's memories, backed by local embeddings + isRequired: true + dependencies: [node, qmd-install, qmd-model] + mcp: + command: /bin/sh + args: + - "-c" + - | + r=$(git rev-parse --show-toplevel 2>/dev/null) + [ -n "$r" ] || r="${CLAUDE_PROJECT_DIR:-$PWD}" + d="$r/.claude/.kb-index" + PATH="$PATH:/opt/homebrew/bin:/usr/local/bin" + export QMD_CONFIG_DIR="$d" INDEX_PATH="$d/memory-loop.sqlite" + exec qmd --index memory-loop mcp + # Both checks pass quietly in a project that has not been indexed yet — the + # session-start hook builds the index, and a doctor run before that should + # not read as broken. + # + # Each repeats the project-root ladder above verbatim. `commandExists` runs + # every check as its own process with no way to share a value, so the + # duplication is forced; keeping the three copies textually identical is what + # makes a future edit obviously propagatable. Do not abbreviate one of them. doctorChecks: + # Tier 2, and the only thing that would catch a silent model downgrade: + # a weaker embedding model produces no error, just worse results. - type: commandExists - name: "nomic-embed-text model" - section: AI Models - command: curl - args: ["-sf", "http://localhost:11434/api/show", "-d", "{\"name\":\"nomic-embed-text\"}"] - # Probes the exact endpoint docs-mcp-server calls. /api/show only reads the - # manifest — it does not load the model. This check forces a full load+embed - # round-trip, so a runner crash (e.g. Ollama/ggml incompatibility) surfaces - # as HTTP 500 and fails the check. - - type: commandExists - name: "nomic-embed-text embedding endpoint" + name: "Memory index uses the expected embedding model" section: AI Models - command: curl + command: /bin/sh args: - - "-sf" - - "-X" - - "POST" - - "-H" - - "Content-Type: application/json" - - "-d" - - '{"model":"nomic-embed-text","input":"ping"}' - - "http://localhost:11434/v1/embeddings" - - # ── MCP Servers ───────────────────────────────────────────────────────── - # Install the binary once (pinned) so Claude launches a prebuilt binary. - # `npx @latest` re-downloaded on every cache eviction, blowing Claude's 30s - # MCP startup limit → "MCP error -32000: Connection closed". - - id: docs-mcp-server-install - displayName: docs-mcp-server binary - description: Installs the docs-mcp-server MCP binary once so Claude launches a prebuilt binary - dependencies: [node] - type: configuration - shell: "npm install -g @arabold/docs-mcp-server@2.4.3" - doctorChecks: - # `list` loads the better-sqlite3 native addon (a broken/ABI-mismatched - # build fails here); `--version` wouldn't — it never loads it. + - "-c" + - | + r=$(git rev-parse --show-toplevel 2>/dev/null) + [ -n "$r" ] || r="${CLAUDE_PROJECT_DIR:-$PWD}" + d="$r/.claude/.kb-index" + [ -f "$d/memory-loop.yml" ] || exit 0 + export QMD_CONFIG_DIR="$d" INDEX_PATH="$d/memory-loop.sqlite" + qmd --index memory-loop status 2>/dev/null | grep -q Qwen3-Embedding-0.6B + # Deliberately issues a query with *default* arguments — meaning reranking + # on — because that is the path this pack neutralises by pointing the + # rerank slot at an embedding model. If an upgrade ever made that fatal + # instead of a warning, this is where it surfaces. `commandExists` has no + # framework-side timeout, hence the alarm. - type: commandExists - name: "docs-mcp-server binary loads" + name: "Memory search returns results" section: MCP Servers - command: docs-mcp-server - args: ["list"] - fixCommand: "npm install -g @arabold/docs-mcp-server@2.4.3" - - - id: docs-mcp-server - description: Semantic search over memories using local Ollama embeddings - isRequired: true - dependencies: [node, ollama, docs-mcp-server-install] - mcp: - command: docs-mcp-server - args: - - "--read-only" - - "--telemetry=false" - env: - OPENAI_API_KEY: "ollama" - OPENAI_API_BASE: "http://localhost:11434/v1" - DOCS_MCP_EMBEDDING_MODEL: "openai:nomic-embed-text" + command: /bin/sh + args: + - "-c" + - | + r=$(git rev-parse --show-toplevel 2>/dev/null) + [ -n "$r" ] || r="${CLAUDE_PROJECT_DIR:-$PWD}" + d="$r/.claude/.kb-index" + [ -f "$d/memory-loop.sqlite" ] || exit 0 + export QMD_CONFIG_DIR="$d" INDEX_PATH="$d/memory-loop.sqlite" + v=$(qmd --index memory-loop status 2>/dev/null | + sed -n 's/.*Vectors: *\([0-9][0-9]*\).*/\1/p' | head -1) + [ "${v:-0}" -gt 0 ] || exit 0 + perl -e 'alarm 30; exec @ARGV' \ + qmd --index memory-loop query "vec: past decisions and learnings" \ + -n 1 --format json 2>/dev/null | grep -q docid # ── Skills ────────────────────────────────────────────────────────────── - id: skill-continuous-learning @@ -162,8 +202,8 @@ components: - id: hook-sync-memories displayName: Sync memories hook - description: Checks Ollama health and syncs docs-mcp-server library on session start - dependencies: [ollama, docs-mcp-server, jq] + description: Indexes memories into the project's search index on session start + dependencies: [qmd-install, jq] hookEvent: SessionStart hookAsync: true hookTimeout: 120 @@ -174,8 +214,8 @@ components: - id: hook-reindex-memories displayName: Reindex memories hook - description: Reindexes docs-mcp-server library when memories have changed mid-session - dependencies: [ollama, docs-mcp-server, jq] + description: Reindexes memories when they have changed mid-session + dependencies: [qmd-install, jq] hookEvent: UserPromptSubmit hookAsync: true hookTimeout: 120 @@ -213,7 +253,7 @@ components: description: Records each KB search so delegated discovery can be checked against it dependencies: [jq] hookEvent: PostToolUse - hookMatcher: "mcp__docs-mcp-server__search_docs" + hookMatcher: "mcp__memory-loop__query" hook: source: hooks/kb-gate.sh destination: kb-gate.sh @@ -267,6 +307,7 @@ components: description: Ignores memory files from version control gitignore: - ".claude/.memories-last-indexed" + - ".claude/.kb-index/" - ".claude/.kb-gate/" - ".claude/.kb-gate.log" @@ -284,6 +325,4 @@ ignore: # --------------------------------------------------------------------------- templates: - sectionIdentifier: continuous-learning - placeholders: - - __PROJECT_DIR_NAME__ contentFile: templates/continuous-learning.md diff --git a/templates/continuous-learning.md b/templates/continuous-learning.md index ce3e592..c4a6c46 100644 --- a/templates/continuous-learning.md +++ b/templates/continuous-learning.md @@ -2,7 +2,13 @@ Before writing code, planning, or exploring — **always search the knowledge base first**: -1. **Search the KB** — use the `docs-mcp-server` tools (`search_docs`) and set the `library` parameter to the name of the current project folder. The library name always matches the root directory name of this project. This server indexes `.claude/memories/` — it contains past learnings, debugging discoveries, and architectural decisions from previous sessions, not external documentation. Try multiple keyword variations if needed. +1. **Search the KB** — use `mcp__memory-loop__query`. It searches this project's own `.claude/memories/` — past learnings, debugging discoveries, and architectural decisions from previous sessions, not external documentation. Pair a `lex` line with a `vec` line on the same topic and set `intent`; keyword-only search performs badly on this corpus. Try multiple phrasings if the first returns nothing. + + ``` + mcp__memory-loop__query(searches: [{type: "lex", query: ""}, + {type: "vec", query: ""}], + intent: "", rerank: false) + ``` 2. **Read matching memories** — review any relevant results for full context (architecture decisions, gotchas, patterns from past sessions). Only after completing these steps should you proceed with discovery and implementation. @@ -21,7 +27,7 @@ Past sessions often contain decisions and patterns that prevent unnecessary iter ### Delegation barrier -`search_docs` and any sub-agent spawn (the `Agent` / `Task` tool) are **not** independent calls — +`mcp__memory-loop__query` and any sub-agent spawn (the `Agent` / `Task` tool) are **not** independent calls — the KB result is an *input* to the sub-agent's prompt. Never place them in the same message, and never spawn a sub-agent before you have read the KB results. diff --git a/tests/kb-gate-test.sh b/tests/kb-gate-test.sh index 6fb7b0a..45c4df8 100755 --- a/tests/kb-gate-test.sh +++ b/tests/kb-gate-test.sh @@ -91,6 +91,20 @@ j_search() { '{hook_event_name:"PostToolUse",session_id:$s,tool_input:{query:$q}}' } +# The search tool accepts three input shapes and only ever sends one of them. +# A hybrid call carries `searches`, not `query`, so a recorder that reads only +# `query` records nothing and the barrier denies forever. +j_search_typed() { + jq -nc --arg s "$sid" --arg a "${1:-lex terms}" --arg b "${2:-vec phrasing}" \ + '{hook_event_name:"PostToolUse",session_id:$s, + tool_input:{searches:[{type:"lex",query:$a},{type:"vec",query:$b}]}}' +} + +j_search_intent() { + jq -nc --arg s "$sid" --arg i "${1:-intent only}" \ + '{hook_event_name:"PostToolUse",session_id:$s,tool_input:{intent:$i}}' +} + # j_spawn [prompt] [agent_type] [agent_id] j_spawn() { jq -nc --arg s "$sid" --arg p "${1:-find the thing}" \ @@ -290,6 +304,29 @@ assert_silent "no stray temp files" "$(find "$state" -name '*.tmp' 2>/dev/null)" assert_num "retained tail stays well clear of the cap" \ "$((LOG_KEEP_LINES * 200))" -lt "$((LOG_MAX_BYTES / 2))" +group "every shape the search tool can send is recorded" + +# Without this the gate fails silently rather than loudly: nothing is written, +# no error is raised, and every subsequent spawn is denied for a search that +# did happen. +queries() { tail -1 "$state/$sid.queries" 2>/dev/null; } + +new_session +run "$mode" "$proj" "$(j_search "plain query field")" >/dev/null +assert_contains "a single-string query" "$(queries)" "plain query field" + +new_session +run "$mode" "$proj" "$(j_search_typed "counter state" "why not wall clock")" >/dev/null +assert_contains "typed lex+vec searches are joined" "$(queries)" "counter state why not wall clock" + +new_session +run "$mode" "$proj" "$(j_search_intent "narrowing the topic")" >/dev/null +assert_contains "intent alone still counts as a search" "$(queries)" "narrowing the topic" + +new_session +run "$mode" "$proj" "$(j_search_typed "hybrid" "hybrid")" >/dev/null +assert_allow "a typed search satisfies the barrier" "$(spawn "$KB — from a typed search")" + # ========================================================================== printf '\n%s\n' "-----------------------------------------" From beeec2ceeb24dd0b47d28f4d725128c7a3c3cb43 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Sun, 30 Aug 2026 21:00:48 +0200 Subject: [PATCH 02/16] Enforce the qmd version pin and shrink the per-result search context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - qmd-install now carries a single doctor check that asserts version 2.8.3. A second, broader check made isAlreadyInstalled short-circuit, so the pinned install was skipped on any machine that already had a different qmd — which then ignored QMD_CONFIG_DIR and read the wrong index config. - Both memory-loop doctor checks harden PATH the way the MCP launcher already did, and stay quiet when qmd is missing rather than reporting a second failure. - global_context is now three lines: qmd echoes it into every search result, so the long version was over half of a ten-result response. Claude-Session: https://claude.ai/code/session_01M2kgbNZQeStEs1E8PjBWLs --- hooks/sync-memories.sh | 20 +++++++++----------- techpack.yaml | 42 +++++++++++++++++++++++++++++++----------- 2 files changed, 40 insertions(+), 22 deletions(-) diff --git a/hooks/sync-memories.sh b/hooks/sync-memories.sh index f299719..53d2e18 100755 --- a/hooks/sync-memories.sh +++ b/hooks/sync-memories.sh @@ -72,19 +72,17 @@ models: generate: $EMBED_MODEL rerank: $EMBED_MODEL global_context: | - This index holds the project's memory KB: learnings, architectural decisions - and debugging discoveries recorded in earlier sessions. It is not external - documentation. - - Always search it with typed lines and reranking off: - searches: [{type: "lex", query: "..."}, {type: "vec", query: "..."}] - rerank: false - Query expansion and reranking are not installed for this index. A bare query - string is auto-expanded and is measurably worse here as well as several - seconds slower — two typed lines score MRR 0.80 against 0.75 for one bare - string. Pass both lines and an intent. + Project memory KB: prior learnings, decisions and debugging discoveries. Not + external documentation. Search with searches:[{type:"lex"},{type:"vec"}] plus + an intent, and rerank:false — a bare query string is slower and scores worse. EOF +# Kept short on purpose: qmd serves global_context two ways — once as the MCP +# server's `instructions` (the reason it is here at all) and again as the +# `context` field of *every* search result. At 591 characters it was 53% of a +# ten-result response. The fuller explanation lives in the CLAUDE.md template, +# both skills, and the sub-agent briefing, none of which are echoed per result. +# # Replaced only when the content actually differs, so a no-op run leaves the # mtime alone. Comparing whole files rather than probing for one line is what # lets any later edit here — the context text especially — reach an install that diff --git a/techpack.yaml b/techpack.yaml index 67c916f..e1b96f6 100644 --- a/techpack.yaml +++ b/techpack.yaml @@ -60,20 +60,29 @@ components: type: configuration shell: "npm install -g @tobilu/qmd@2.8.3" doctorChecks: - # `status` opens the store and loads better-sqlite3 + sqlite-vec. `--version` - # loads neither, so a broken or ABI-mismatched native build would pass it. + # Exactly ONE check here, deliberately. `isAlreadyInstalled` treats the first + # *passing* supplementary check as proof the whole component is installed, so + # a second, broader check — a Node version probe, say — passes on a machine + # that already has some other qmd and skips the pinned install entirely. That + # is how a machine ends up running a qmd old enough to ignore QMD_CONFIG_DIR, + # silently reading a different index config and reporting the wrong embedding + # model. Any check added here must fail whenever the pin is not satisfied. + # + # Asserts the pin *and* that the binary loads: `status` opens the store and + # loads better-sqlite3 + sqlite-vec, which `--version` alone never does. A + # qmd that runs at all also proves Node is new enough, so no separate Node + # probe is needed. Keep the version literal in step with `shell:` above. - type: commandExists - name: "qmd binary loads" + name: "qmd 2.8.3 installed and loading" section: MCP Servers - command: qmd - args: ["status"] + command: /bin/sh + args: + - "-c" + - | + PATH="$PATH:/opt/homebrew/bin:/usr/local/bin" + qmd --version 2>/dev/null | grep -q "2\.8\.3" || exit 1 + qmd status >/dev/null 2>&1 fixCommand: "npm install -g @tobilu/qmd@2.8.3" - - type: commandExists - name: "Node.js 22 or newer (qmd requires it)" - section: MCP Servers - command: node - args: ["-e", "process.exit(parseInt(process.versions.node) >= 22 ? 0 : 1)"] - fixCommand: "brew upgrade node" # One model serves all three of qmd's slots, so this downloads a single file. # It runs at sync time on purpose: a first-run download inside the async @@ -130,6 +139,13 @@ components: # session-start hook builds the index, and a doctor run before that should # not read as broken. # + # Each hardens PATH the same way the launcher does. `mcs doctor` reaches these + # through Process, which does not inherit a login shell, so a bare `qmd` can be + # unresolvable here while working fine in a terminal — and the check would then + # report "not found", which is what mcs prints for *any* non-zero exit. The + # `command -v` guard keeps that from double-reporting a missing qmd, which the + # qmd-install check above already owns. + # # Each repeats the project-root ladder above verbatim. `commandExists` runs # every check as its own process with no way to share a value, so the # duplication is forced; keeping the three copies textually identical is what @@ -147,6 +163,8 @@ components: r=$(git rev-parse --show-toplevel 2>/dev/null) [ -n "$r" ] || r="${CLAUDE_PROJECT_DIR:-$PWD}" d="$r/.claude/.kb-index" + PATH="$PATH:/opt/homebrew/bin:/usr/local/bin" + command -v qmd >/dev/null 2>&1 || exit 0 [ -f "$d/memory-loop.yml" ] || exit 0 export QMD_CONFIG_DIR="$d" INDEX_PATH="$d/memory-loop.sqlite" qmd --index memory-loop status 2>/dev/null | grep -q Qwen3-Embedding-0.6B @@ -165,6 +183,8 @@ components: r=$(git rev-parse --show-toplevel 2>/dev/null) [ -n "$r" ] || r="${CLAUDE_PROJECT_DIR:-$PWD}" d="$r/.claude/.kb-index" + PATH="$PATH:/opt/homebrew/bin:/usr/local/bin" + command -v qmd >/dev/null 2>&1 || exit 0 [ -f "$d/memory-loop.sqlite" ] || exit 0 export QMD_CONFIG_DIR="$d" INDEX_PATH="$d/memory-loop.sqlite" v=$(qmd --index memory-loop status 2>/dev/null | From 6af476868d09d0273864580e95c2a7a4e0f55d39 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Sun, 30 Aug 2026 21:14:04 +0200 Subject: [PATCH 03/16] Tell Claude how to phrase a KB search and cap it at 5 results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Searches now ask for limit:5. Ranks past 5 never contributed a hit on the retrieval fixture, and the default of 10 nearly doubled the response size. - The lex and vec lines are described by what they take — keywords versus prose — since each is the only one that finds a whole class of memory. - States that result scores are 1/rank rather than confidence, so a poor match still scores 1.00 and the snippets have to be judged on their own. Claude-Session: https://claude.ai/code/session_01M2kgbNZQeStEs1E8PjBWLs --- hooks/kb-gate.sh | 9 +++++---- hooks/sync-memories.sh | 7 +++++-- skills/continuous-learning/SKILL.md | 2 +- templates/continuous-learning.md | 11 +++++++++-- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/hooks/kb-gate.sh b/hooks/kb-gate.sh index d8ffbbf..bf48497 100644 --- a/hooks/kb-gate.sh +++ b/hooks/kb-gate.sh @@ -258,10 +258,11 @@ SubagentStart) " ground truth. Verify it against the code, but do NOT search the KB and\n" + " do NOT re-derive it.\n" + "- Otherwise, if you are about to read or grep more than a couple of files,\n" + - " issue ONE mcp__memory-loop__query for the topic of your task first — one\n" + - " search is far cheaper than a blind file sweep. Unlike the main thread, do\n" + - " not try keyword variations: if nothing relevant comes back, move on to\n" + - " the code.\n" + + " issue ONE mcp__memory-loop__query for the topic of your task first — a\n" + + " lex line of distinctive terms plus a vec line phrased as a question,\n" + + " with rerank:false and limit:5. One search is far cheaper than a blind\n" + + " file sweep. Unlike the main thread, do not try keyword variations: if\n" + + " nothing relevant comes back, move on to the code.\n" + "- Report back anything the KB got wrong or left out." ) } diff --git a/hooks/sync-memories.sh b/hooks/sync-memories.sh index 53d2e18..1ed638f 100755 --- a/hooks/sync-memories.sh +++ b/hooks/sync-memories.sh @@ -73,8 +73,11 @@ models: rerank: $EMBED_MODEL global_context: | Project memory KB: prior learnings, decisions and debugging discoveries. Not - external documentation. Search with searches:[{type:"lex"},{type:"vec"}] plus - an intent, and rerank:false — a bare query string is slower and scores worse. + external documentation. Search with searches:[{type:"lex", query:"distinctive + terms"},{type:"vec", query:"the question in prose"}] plus an intent, with + rerank:false and limit:5. Ranks past 5 never add a hit here, and a bare query + string is slower and scores worse. Scores are 1/rank, not confidence — judge + the snippets. EOF # Kept short on purpose: qmd serves global_context two ways — once as the MCP diff --git a/skills/continuous-learning/SKILL.md b/skills/continuous-learning/SKILL.md index f7112e6..96ece48 100644 --- a/skills/continuous-learning/SKILL.md +++ b/skills/continuous-learning/SKILL.md @@ -101,7 +101,7 @@ If any rule fails, rewrite the memory to satisfy it (e.g. anonymize an actor, re ``` mcp__memory-loop__query(searches: [{type: "lex", query: ""}, {type: "vec", query: ""}], - intent: "", rerank: false) + intent: "", rerank: false, limit: 5) ``` **Fall back to file listing** if the search returns no results or the index is not yet built: diff --git a/templates/continuous-learning.md b/templates/continuous-learning.md index c4a6c46..2f89149 100644 --- a/templates/continuous-learning.md +++ b/templates/continuous-learning.md @@ -2,13 +2,20 @@ Before writing code, planning, or exploring — **always search the knowledge base first**: -1. **Search the KB** — use `mcp__memory-loop__query`. It searches this project's own `.claude/memories/` — past learnings, debugging discoveries, and architectural decisions from previous sessions, not external documentation. Pair a `lex` line with a `vec` line on the same topic and set `intent`; keyword-only search performs badly on this corpus. Try multiple phrasings if the first returns nothing. +1. **Search the KB** — use `mcp__memory-loop__query`. It searches this project's own `.claude/memories/` — past learnings, debugging discoveries, and architectural decisions from previous sessions, not external documentation. Always pair the two line types — they answer different questions and neither is sufficient alone: + + - `lex` takes **keywords**: distinctive identifiers, error names, `"quoted phrases"`, `-negation`. It is the only thing that finds a rare exact token. + - `vec` takes **prose**: the question as you would ask a colleague. It is what finds a memory that describes your symptom in different words. ``` mcp__memory-loop__query(searches: [{type: "lex", query: ""}, {type: "vec", query: ""}], - intent: "", rerank: false) + intent: "", + rerank: false, limit: 5) ``` + + Results carry a score of `1/rank`, not a confidence — a poor match still scores 1.00 at the top. Judge the snippets, and try a different phrasing if nothing fits. + 2. **Read matching memories** — review any relevant results for full context (architecture decisions, gotchas, patterns from past sessions). Only after completing these steps should you proceed with discovery and implementation. From 29abcbf827e630340f6120d1aee32c52333191c7 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Sun, 30 Aug 2026 21:26:26 +0200 Subject: [PATCH 04/16] Stop the maintainer notes pointing at a benchmark that is not in the repo - Drops the retrieval-bench row from the command table; the harness is kept outside the repo, so the command was unrunnable from a clean checkout. - Says where the quoted retrieval numbers came from, since removing the row left them with no visible source and nothing re-checks them. - Records limit:5 as part of the search call shape the four copies must share. Claude-Session: https://claude.ai/code/session_01M2kgbNZQeStEs1E8PjBWLs --- CLAUDE.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f762181..ed2fa37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,6 @@ The consequence that matters most: **nothing here executes from the repo.** Edit | Run the hook test suite | `bash tests/kb-gate-test.sh` | | Verify the SYNC blocks agree | the snippet below (full version at the bottom of `SYNC-BLOCKS.md`) | | Check the manifest parses | `/usr/bin/python3 -c "import yaml;yaml.safe_load(open('techpack.yaml'))"` — the system Python; Homebrew's has no PyYAML | -| Measure retrieval quality | `bash tests/retrieval-bench.sh` (untracked; needs a built index) | | Install a change locally | `mcs sync --global`, or `mcs sync` inside a project | | Check installed health | `mcs doctor` | @@ -51,9 +50,9 @@ Two harness details are load-bearing rather than incidental: **The index is reached by `--index`, never by a project-local `.qmd/`.** Two reasons, and the second is the dangerous one. A user may keep their own `.qmd/` at the project root for their own code, which this pack must not touch. And a project-local `.qmd/index.yml` falls under qmd's trust gate, which covers a non-default `models.embed` — for a non-interactive caller the gate does not prompt or fail, it *skips*, silently substituting a much weaker default model. Named indexes are never gated. `QMD_CONFIG_DIR` and `INDEX_PATH` are what move a named index back under the project directory. -**Reranking and query expansion are disabled by pointing their model slots at the embedding model.** The MCP `query` tool hard-defaults `rerank: true` with no server-side way to turn it off, and a *missing* model is downloaded mid-query with no progress output. An embedding model has no ranking head, so qmd fails to build a ranking context, warns, and falls back to RRF — measured at MRR 0.792 against 0.800 for an explicit `rerank: false`, and it buys back zero R@5 versus a real reranker. This depends on qmd's graceful-degradation path rather than a documented switch, which is why `@tobilu/qmd` is pinned to an exact version and why one doctor check issues a *default-argument* query: that check is what would catch the behaviour changing under an upgrade. +**Reranking and query expansion are disabled by pointing their model slots at the embedding model.** The MCP `query` tool hard-defaults `rerank: true` with no server-side way to turn it off, and a *missing* model is downloaded mid-query with no progress output. An embedding model has no ranking head, so qmd fails to build a ranking context, warns, and falls back to RRF — measured at MRR 0.792 against 0.800 for an explicit `rerank: false`, and it buys back zero R@5 versus a real reranker. Those numbers come from a 20-query fixture over this project's own memories, kept outside the repo — nothing here reproduces them, so treat them as recorded measurements rather than something CI re-checks. This depends on qmd's graceful-degradation path rather than a documented switch, which is why `@tobilu/qmd` is pinned to an exact version and why one doctor check issues a *default-argument* query: that check is what would catch the behaviour changing under an upgrade. -**The search call shape is stated in four places, deliberately.** "Typed `lex`+`vec` lines, `rerank: false`" appears in the index's `global_context` (written by `hooks/sync-memories.sh`, and the only text qmd injects into the model's system prompt), `templates/continuous-learning.md` (the only thing that reaches a user's `CLAUDE.md`), `skills/continuous-learning/SKILL.md`, and the `SubagentStart` briefing in `hooks/kb-gate.sh`. No single mechanism reaches all four consumers, so this is four copies rather than one source — change one and check the other three. It matters because the unguided path is measurably worse, not just slower. +**The search call shape is stated in four places, deliberately.** "Typed `lex`+`vec` lines, `rerank: false`, `limit: 5`" appears in the index's `global_context` (written by `hooks/sync-memories.sh`, and the only text qmd injects into the model's system prompt), `templates/continuous-learning.md` (the only thing that reaches a user's `CLAUDE.md`), `skills/continuous-learning/SKILL.md`, and the `SubagentStart` briefing in `hooks/kb-gate.sh`. No single mechanism reaches all four consumers, so this is four copies rather than one source — change one and check the other three. It matters because the unguided path is measurably worse, not just slower. **Three text blocks must stay byte-identical across three files.** `capture-rules`, `strip-the-anchors`, and `applies-to` appear in both `SKILL.md`s and in `SYNC-BLOCKS.md`, enforced by `.github/workflows/sync-blocks.yml`. Two rules when touching them: From 0a3a4dade2a1190a5aef71ec078d21bd2f055bb7 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Sun, 30 Aug 2026 21:34:12 +0200 Subject: [PATCH 05/16] Drop the redundant kb-index entry from the repo gitignore - The pack's gitignore component already contributes .claude/.kb-index/, so the repo-level copy only duplicated what mcs sync installs. Claude-Session: https://claude.ai/code/session_01M2kgbNZQeStEs1E8PjBWLs --- .gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitignore b/.gitignore index 5a9d5d2..afeab08 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,3 @@ Icon .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent - -# Memory pack: local search index -.claude/.kb-index/ From ca25eabd5f0f18550cc215c78d621e7ba80a502d Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Sun, 30 Aug 2026 21:44:20 +0200 Subject: [PATCH 06/16] Close four review findings and trim the manifest comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The gate no longer treats `intent` as evidence of a search. It never retrieves on its own, so a request that returned nothing could satisfy the barrier. - The indexer writes the memories path as a quoted YAML scalar, and detects deletions inside subdirectories — a nested memory could stay searchable after being removed, and a project path containing "#" indexed the wrong directory. - The version check is anchored, so 2.8.30 and 12.8.3 no longer pass as 2.8.3. Comments that restated invariants already owned by CLAUDE.md are gone; the ones explaining a specific line stayed. Claude-Session: https://claude.ai/code/session_01M2kgbNZQeStEs1E8PjBWLs --- hooks/kb-gate.sh | 11 +++--- hooks/sync-memories.sh | 15 +++++--- techpack.yaml | 78 +++++++++++++----------------------------- tests/kb-gate-test.sh | 14 ++------ 4 files changed, 44 insertions(+), 74 deletions(-) diff --git a/hooks/kb-gate.sh b/hooks/kb-gate.sh index bf48497..57e04a2 100644 --- a/hooks/kb-gate.sh +++ b/hooks/kb-gate.sh @@ -222,12 +222,13 @@ PostToolUse) # `searches`, never both. Reading only `query` would miss every hybrid # lex+vec call — the form this pack steers Claude toward — and the barrier # would then deny forever with nothing to show why. - # Built as a list rather than a `//` chain: `//` only falls through on null, - # so an empty join would stop it, and `null | map` raises — either way the - # whole extraction yields nothing and the search goes unrecorded. + # Only the two shapes that actually search: a bare `query`, or the joined + # text of typed `searches`. `intent` is context and never searches on its + # own, so accepting it would let a request that retrieved nothing satisfy + # the barrier. Built as a list rather than a `//` chain: `//` only falls + # through on null, so an empty join would stop it, and `null | map` raises. query=$(jq -r '[.tool_input.query, - ((.tool_input.searches // []) | map(.query) | join(" ")), - .tool_input.intent] + ((.tool_input.searches // []) | map(.query) | join(" "))] | map(select(. != null and . != "")) | first // ""' <<<"$payload" 2>/dev/null) [ -n "$query" ] || exit 0 ensure_state || exit 0 diff --git a/hooks/sync-memories.sh b/hooks/sync-memories.sh index 1ed638f..407bab4 100755 --- a/hooks/sync-memories.sh +++ b/hooks/sync-memories.sh @@ -62,10 +62,15 @@ mkdir -p "$INDEX_DIR" 2>/dev/null || exit 0 # path is what keeps the two interchangeable: `qmd collection add` would record # the symlink's *target*, which goes stale the moment the link is re-pointed or # the pack is added to a project that already had a real directory. +# Single-quoted YAML scalar with internal quotes doubled: a project path may +# legitimately contain "#" or ": ", either of which silently changes what the +# parser sees when written bare. +mem_yaml="'${MEMORIES_DIR//\'/\'\'}'" + cat >"$CONFIG.new" </dev/null) - # Also check if the directory itself was modified (file added/removed) - dir_changed="" - [ "$MEMORIES_DIR" -nt "$TIMESTAMP_FILE" ] && dir_changed="yes" + # Additions and removals move a directory's mtime rather than any file's, and + # the collection pattern is recursive — so every directory has to be checked, + # not just the root. Deleting a memory inside a subdirectory bumps only that + # subdirectory, and the dropped file would otherwise stay searchable forever. + dir_changed=$(find "$MEMORIES_DIR" -type d -newer "$TIMESTAMP_FILE" -print -quit 2>/dev/null) [ -n "$newest" ] || [ -n "$dir_changed" ] || exit 0 fi diff --git a/techpack.yaml b/techpack.yaml index e1b96f6..2692472 100644 --- a/techpack.yaml +++ b/techpack.yaml @@ -44,15 +44,9 @@ components: brew: jq # ── Retrieval engine ──────────────────────────────────────────────────── - # Install the binary once (pinned) so Claude launches a prebuilt binary and no - # package manager ever runs inside a hook. - # - # The exact pin is load-bearing beyond reproducibility. This pack disables - # reranking and query expansion by pointing those two model slots at the - # embedding model: an embedding model has no ranking head, so qmd catches the - # failure and falls back to RRF scores. That fallback is behaviour, not a - # documented switch, so the version it was verified against is pinned here and - # a doctor check exercises it. + # Pinned so no package manager runs inside a hook, and because the rerank and + # generate neutralisation relies on undocumented fallback behaviour a version + # bump could change. See CLAUDE.md. - id: qmd-install displayName: qmd description: Local search engine over markdown — no daemon, bundled SQLite + sqlite-vec @@ -60,18 +54,13 @@ components: type: configuration shell: "npm install -g @tobilu/qmd@2.8.3" doctorChecks: - # Exactly ONE check here, deliberately. `isAlreadyInstalled` treats the first - # *passing* supplementary check as proof the whole component is installed, so - # a second, broader check — a Node version probe, say — passes on a machine - # that already has some other qmd and skips the pinned install entirely. That - # is how a machine ends up running a qmd old enough to ignore QMD_CONFIG_DIR, - # silently reading a different index config and reporting the wrong embedding - # model. Any check added here must fail whenever the pin is not satisfied. - # - # Asserts the pin *and* that the binary loads: `status` opens the store and - # loads better-sqlite3 + sqlite-vec, which `--version` alone never does. A - # qmd that runs at all also proves Node is new enough, so no separate Node - # probe is needed. Keep the version literal in step with `shell:` above. + # ONE check, deliberately: `isAlreadyInstalled` takes the first *passing* + # check as proof the component is installed, so any second check that can + # pass while the pin is unsatisfied silently disables the install. + # Asserts the version and that the binary loads (`--version` alone never + # loads the native modules); a qmd that runs proves Node is new enough. + # Anchored, so 2.8.30 and 12.8.3 do not pass. Keep the literal in step + # with `shell:` above. - type: commandExists name: "qmd 2.8.3 installed and loading" section: MCP Servers @@ -80,7 +69,7 @@ components: - "-c" - | PATH="$PATH:/opt/homebrew/bin:/usr/local/bin" - qmd --version 2>/dev/null | grep -q "2\.8\.3" || exit 1 + qmd --version 2>/dev/null | grep -qE '^qmd 2\.8\.3( |$)' || exit 1 qmd status >/dev/null 2>&1 fixCommand: "npm install -g @tobilu/qmd@2.8.3" @@ -107,19 +96,12 @@ components: args: ["-c", 'test -f "${XDG_CACHE_HOME:-$HOME/.cache}/qmd/models/hf_Qwen_Qwen3-Embedding-0.6B-Q8_0.gguf"'] # ── MCP Server ────────────────────────────────────────────────────────── - # One static command line serves every project, and env values are literal - # strings, so the per-project index path has to be computed when the server is - # launched. Claude spawns MCP servers with the project as their working - # directory, which is what makes that resolution possible at all. - # - # `--index` is deliberate, not cosmetic. Reaching an index through a - # project-local .qmd/ directory puts it under qmd's trust gate, which skips a - # non-default embedding model for non-interactive callers without an error — - # retrieval would quietly fall back to a weaker model. A named index is never - # gated. It also leaves any .qmd/ the user keeps for their own code untouched. + # One static command line serves every project, so the index path is computed + # at launch — Claude spawns MCP servers with the project as their working + # directory. Using `--index` rather than a project-local .qmd/ is an invariant; + # see CLAUDE.md. # - # keep in sync with hooks/sync-memories.sh — the hook writes the index this - # reads, and they have to agree on where it is. + # keep in sync with hooks/sync-memories.sh — it writes the index this opens. - id: memory-loop description: Semantic search over this project's memories, backed by local embeddings isRequired: true @@ -135,21 +117,10 @@ components: PATH="$PATH:/opt/homebrew/bin:/usr/local/bin" export QMD_CONFIG_DIR="$d" INDEX_PATH="$d/memory-loop.sqlite" exec qmd --index memory-loop mcp - # Both checks pass quietly in a project that has not been indexed yet — the - # session-start hook builds the index, and a doctor run before that should - # not read as broken. - # - # Each hardens PATH the same way the launcher does. `mcs doctor` reaches these - # through Process, which does not inherit a login shell, so a bare `qmd` can be - # unresolvable here while working fine in a terminal — and the check would then - # report "not found", which is what mcs prints for *any* non-zero exit. The - # `command -v` guard keeps that from double-reporting a missing qmd, which the - # qmd-install check above already owns. - # - # Each repeats the project-root ladder above verbatim. `commandExists` runs - # every check as its own process with no way to share a value, so the - # duplication is forced; keeping the three copies textually identical is what - # makes a future edit obviously propagatable. Do not abbreviate one of them. + # Both pass quietly when nothing is indexed yet. Each hardens PATH because + # `mcs doctor` runs these through Process, which has no login shell, and each + # repeats the launcher's project-root ladder verbatim — every check is its own + # process, so the duplication is forced. Keep the copies identical. doctorChecks: # Tier 2, and the only thing that would catch a silent model downgrade: # a weaker embedding model produces no error, just worse results. @@ -168,11 +139,10 @@ components: [ -f "$d/memory-loop.yml" ] || exit 0 export QMD_CONFIG_DIR="$d" INDEX_PATH="$d/memory-loop.sqlite" qmd --index memory-loop status 2>/dev/null | grep -q Qwen3-Embedding-0.6B - # Deliberately issues a query with *default* arguments — meaning reranking - # on — because that is the path this pack neutralises by pointing the - # rerank slot at an embedding model. If an upgrade ever made that fatal - # instead of a warning, this is where it surfaces. `commandExists` has no - # framework-side timeout, hence the alarm. + # Queries with *default* arguments — reranking on — because that is the path + # this pack neutralises. If an upgrade made that fatal rather than a warning, + # this check is where it surfaces. The alarm is the timeout `commandExists` + # does not provide. - type: commandExists name: "Memory search returns results" section: MCP Servers diff --git a/tests/kb-gate-test.sh b/tests/kb-gate-test.sh index 45c4df8..605e8f3 100755 --- a/tests/kb-gate-test.sh +++ b/tests/kb-gate-test.sh @@ -91,19 +91,15 @@ j_search() { '{hook_event_name:"PostToolUse",session_id:$s,tool_input:{query:$q}}' } -# The search tool accepts three input shapes and only ever sends one of them. # A hybrid call carries `searches`, not `query`, so a recorder that reads only -# `query` records nothing and the barrier denies forever. +# `query` records nothing and the barrier denies forever. `intent` is deliberately +# not a third shape: it never searches on its own, so it must not create a record. j_search_typed() { jq -nc --arg s "$sid" --arg a "${1:-lex terms}" --arg b "${2:-vec phrasing}" \ '{hook_event_name:"PostToolUse",session_id:$s, tool_input:{searches:[{type:"lex",query:$a},{type:"vec",query:$b}]}}' } -j_search_intent() { - jq -nc --arg s "$sid" --arg i "${1:-intent only}" \ - '{hook_event_name:"PostToolUse",session_id:$s,tool_input:{intent:$i}}' -} # j_spawn [prompt] [agent_type] [agent_id] j_spawn() { @@ -304,7 +300,7 @@ assert_silent "no stray temp files" "$(find "$state" -name '*.tmp' 2>/dev/null)" assert_num "retained tail stays well clear of the cap" \ "$((LOG_KEEP_LINES * 200))" -lt "$((LOG_MAX_BYTES / 2))" -group "every shape the search tool can send is recorded" +group "both searching shapes are recorded" # Without this the gate fails silently rather than loudly: nothing is written, # no error is raised, and every subsequent spawn is denied for a search that @@ -319,10 +315,6 @@ new_session run "$mode" "$proj" "$(j_search_typed "counter state" "why not wall clock")" >/dev/null assert_contains "typed lex+vec searches are joined" "$(queries)" "counter state why not wall clock" -new_session -run "$mode" "$proj" "$(j_search_intent "narrowing the topic")" >/dev/null -assert_contains "intent alone still counts as a search" "$(queries)" "narrowing the topic" - new_session run "$mode" "$proj" "$(j_search_typed "hybrid" "hybrid")" >/dev/null assert_allow "a typed search satisfies the barrier" "$(spawn "$KB — from a typed search")" From 56ff45e132d49f1602ef8d8c0826c5e891a2adad Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Sun, 30 Aug 2026 22:19:53 +0200 Subject: [PATCH 07/16] Stop the indexer marking work done that never happened - Serialises reindexing and confirms nothing is left pending before touching the freshness timestamp. Overlapping async runs hit qmd's embed lock, which reports contention as success, so the index could be marked fresh with zero vectors and then skipped indefinitely. - The install check probes a throwaway index. A bare `qmd status` adopts a project-local .qmd/ by walking up from the working directory, so it was editing the user's own qmd config and creating their index. - The MCP server starts in projects with no memories yet, and the collection is registered on the first hook run, so a first memory is searchable without waiting for the next session. Claude-Session: https://claude.ai/code/session_01M2kgbNZQeStEs1E8PjBWLs --- hooks/sync-memories.sh | 40 ++++++++++++++++++++++++++++++++++------ techpack.yaml | 20 +++++++++++++++++++- tests/kb-gate-test.sh | 12 ++++++++++++ 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/hooks/sync-memories.sh b/hooks/sync-memories.sh index 407bab4..0b007a1 100755 --- a/hooks/sync-memories.sh +++ b/hooks/sync-memories.sh @@ -32,9 +32,6 @@ TIMESTAMP_FILE="$project_root/.claude/.memories-last-indexed" # the fallback: MRR 0.792 against 0.800 for an explicit rerank:false. EMBED_MODEL="hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf" -# Exit early if no memories directory -[ -d "$MEMORIES_DIR" ] || exit 0 - # Exit early if qmd is not installed command -v qmd >/dev/null 2>&1 || exit 0 @@ -103,6 +100,14 @@ else rm -f "$CONFIG.new" fi +# The config is written even when no memory has been captured yet, because the +# MCP server reads its collection list once at startup: a server that booted +# against a config with no collections cannot see the first memory until the next +# session, however promptly this hook indexes it. Registering the collection up +# front costs nothing — qmd accepts a collection whose directory does not exist +# and reports zero documents. +[ -d "$MEMORIES_DIR" ] || exit 0 + # --- Staleness check --- # Runs after the config sync, not before: a config that changed (a new model, new # guidance) has to reach the index even when no memory file moved. Skipping here @@ -123,13 +128,36 @@ if [ "$config_changed" = no ] && [ -f "$TIMESTAMP_FILE" ] && [ -f "$INDEX_PATH" fi # --- Reindex --- +# Both hook registrations are async, so two runs can overlap on a slow first +# index. qmd's own embed lock reports contention as success — it prints that +# another embed is running and exits 0 — so without a mutex here the losing run +# would mark the index fresh having embedded nothing, and the staleness check +# would then skip the pending work indefinitely. +# +# mkdir is the atomic test-and-set. A run killed by the hook timeout cannot +# release the lock, so a lock older than the timeout is treated as abandoned. +LOCK="$INDEX_DIR/.reindex.lock" +if ! mkdir "$LOCK" 2>/dev/null; then + [ -n "$(find "$LOCK" -maxdepth 0 -mmin +5 2>/dev/null)" ] || exit 0 + rmdir "$LOCK" 2>/dev/null + mkdir "$LOCK" 2>/dev/null || exit 0 +fi +trap 'rmdir "$LOCK" 2>/dev/null' EXIT + # `update` rescans the collection for added, changed and removed files; `embed` # vectorises whatever came back without one. Both are incremental — a no-op pass # is under a tenth of a second. qmd --index memory-loop update >/dev/null 2>&1 || exit 0 qmd --index memory-loop embed >/dev/null 2>&1 || exit 0 -# Mark indexing time for subsequent staleness checks. Gated on the two commands -# above succeeding: an unconditional touch marks a *failed* index as fresh, and -# the staleness check then skips it forever with nothing reporting the gap. +# Confirm the work actually happened rather than trusting an exit code: anything +# that leaves documents pending must not mark the index fresh, or the staleness +# check skips them until some other memory changes. +pending=$(qmd --index memory-loop status 2>/dev/null | + sed -n 's/.*Pending: *\([0-9][0-9]*\).*/\1/p' | head -1) +[ "${pending:-0}" -eq 0 ] || exit 0 + +# Mark indexing time for subsequent staleness checks. Gated on the commands above +# succeeding: an unconditional touch marks a *failed* index as fresh, and the +# staleness check then skips it forever with nothing reporting the gap. touch "$TIMESTAMP_FILE" diff --git a/techpack.yaml b/techpack.yaml index 2692472..9d82ed9 100644 --- a/techpack.yaml +++ b/techpack.yaml @@ -61,6 +61,13 @@ components: # loads the native modules); a qmd that runs proves Node is new enough. # Anchored, so 2.8.30 and 12.8.3 do not pass. Keep the literal in step # with `shell:` above. + # + # The load probe runs against a throwaway index. A bare `qmd status` adopts + # whatever config it finds by walking up from the working directory, so in a + # project where the user keeps their own .qmd/ it writes model keys into + # their config and creates their index.sqlite — the pack must not touch it. + # `--index` is what skips that discovery; QMD_CONFIG_DIR alone does not, + # because a discovered .qmd/ outranks it. - type: commandExists name: "qmd 2.8.3 installed and loading" section: MCP Servers @@ -70,7 +77,10 @@ components: - | PATH="$PATH:/opt/homebrew/bin:/usr/local/bin" qmd --version 2>/dev/null | grep -qE '^qmd 2\.8\.3( |$)' || exit 1 - qmd status >/dev/null 2>&1 + d=$(mktemp -d) || exit 1 + QMD_CONFIG_DIR="$d" INDEX_PATH="$d/probe.sqlite" \ + qmd --index probe status >/dev/null 2>&1 + s=$?; rm -rf "$d"; exit $s fixCommand: "npm install -g @tobilu/qmd@2.8.3" # One model serves all three of qmd's slots, so this downloads a single file. @@ -101,6 +111,13 @@ components: # directory. Using `--index` rather than a project-local .qmd/ is an invariant; # see CLAUDE.md. # + # `mkdir -p` because qmd cannot open an index under a directory that does not + # exist, and the server would fail to connect in every project that has not + # captured a memory yet — which is every new project. The CLAUDE.md section is + # installed globally and tells Claude to search before each task, so the tool + # has to exist everywhere that instruction does. An empty index is honest: the + # server reports "0 markdown documents" in its own instructions. + # # keep in sync with hooks/sync-memories.sh — it writes the index this opens. - id: memory-loop description: Semantic search over this project's memories, backed by local embeddings @@ -115,6 +132,7 @@ components: [ -n "$r" ] || r="${CLAUDE_PROJECT_DIR:-$PWD}" d="$r/.claude/.kb-index" PATH="$PATH:/opt/homebrew/bin:/usr/local/bin" + mkdir -p "$d" export QMD_CONFIG_DIR="$d" INDEX_PATH="$d/memory-loop.sqlite" exec qmd --index memory-loop mcp # Both pass quietly when nothing is indexed yet. Each hardens PATH because diff --git a/tests/kb-gate-test.sh b/tests/kb-gate-test.sh index 605e8f3..be48d37 100755 --- a/tests/kb-gate-test.sh +++ b/tests/kb-gate-test.sh @@ -94,6 +94,10 @@ j_search() { # A hybrid call carries `searches`, not `query`, so a recorder that reads only # `query` records nothing and the barrier denies forever. `intent` is deliberately # not a third shape: it never searches on its own, so it must not create a record. +j_search_intent() { + jq -nc --arg s "$sid" --arg i "${1:-intent only}" \ + '{hook_event_name:"PostToolUse",session_id:$s,tool_input:{intent:$i}}' +} j_search_typed() { jq -nc --arg s "$sid" --arg a "${1:-lex terms}" --arg b "${2:-vec phrasing}" \ '{hook_event_name:"PostToolUse",session_id:$s, @@ -319,6 +323,14 @@ new_session run "$mode" "$proj" "$(j_search_typed "hybrid" "hybrid")" >/dev/null assert_allow "a typed search satisfies the barrier" "$(spawn "$KB — from a typed search")" +# `intent` is context, not a search: qmd rejects a call carrying neither `query` +# nor `searches`, so treating it as evidence would let a request that retrieved +# nothing open the barrier. +new_session +run "$mode" "$proj" "$(j_search_intent "narrowing the topic")" >/dev/null +assert_silent "an intent-only call records nothing" "$(queries)" +assert_deny "and does not satisfy the barrier" "$(spawn "$KB — but no search ran")" + # ========================================================================== printf '\n%s\n' "-----------------------------------------" From 6a3fd77f358cee84e9460595766f2fe5727cbcbe Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Sun, 30 Aug 2026 22:39:09 +0200 Subject: [PATCH 08/16] Leave the reason on disk when indexing fails - A failed or incomplete run keeps its output in .claude/.kb-index/memory-loop.log and deletes it on success, so the file's presence is the signal and it holds the reason. Gating the timestamp alone stopped failures concealing themselves, but left a retry every prompt with no way to see why. - A new doctor check reports that log, since a broken index is otherwise visible only as searches quietly returning nothing. - Records the hook/check coupling through that file, and corrects the project-root invariant, which named three copies of a ladder that now appears six times. Claude-Session: https://claude.ai/code/session_01M2kgbNZQeStEs1E8PjBWLs --- CLAUDE.md | 4 +++- hooks/sync-memories.sh | 26 +++++++++++++++++++------- techpack.yaml | 23 +++++++++++++++++++---- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ed2fa37..af9587a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,9 @@ Two harness details are load-bearing rather than incidental: **That dispatcher deliberately omits `set -e` and `set -u`**, unlike `sync-memories.sh` which uses `set -uo pipefail`. Its file header explains why and lists rules that are load-bearing: fail open, never `exit 2`, never call `qmd` (it loads an embedding model; far too slow for `PreToolUse`), log every evaluation. Read that header before editing it. -**Project-root derivation must match across two hooks and the manifest.** `sync-memories.sh`, `resolve_paths()` in `kb-gate.sh`, and the `memory-loop` MCP launcher in `techpack.yaml` all resolve git toplevel → `CLAUDE_PROJECT_DIR` → `$PWD`. The first two must agree on which project they are looking at; the launcher must additionally agree with `sync-memories.sh` on `.claude/.kb-index/`, because one writes the index the other opens. All three carry "keep in sync" comments. Non-git projects and launches from a subdirectory both go through the same ladder — do not "simplify" it to `$PWD`. +**Project-root derivation must be byte-identical everywhere it appears.** Both hooks and every shell string in `techpack.yaml` — the `memory-loop` launcher and each of its doctor checks — resolve git toplevel → `CLAUDE_PROJECT_DIR` → `$PWD`. The hooks must agree on which project they are looking at; the manifest must additionally agree with `sync-memories.sh` on `.claude/.kb-index/`, because one writes the index the others read. `commandExists` runs every check in its own process with no way to share a value, so the duplication is forced; keeping the copies textually identical is the only thing that makes a future edit obviously propagatable. Do not abbreviate one of them, and do not "simplify" the ladder to `$PWD` — a shortened copy that dropped the `CLAUDE_PROJECT_DIR` rung shipped once already, and made those checks silently pass without checking anything in non-git projects. + +**The indexing hook and a doctor check are coupled through a file.** `sync-memories.sh` writes `.claude/.kb-index/memory-loop.log` when a run fails or leaves documents unembedded, and deletes it on success; the "Memory indexing completed" check reports the file's existence. Move or rename it on one side and the check passes forever without testing anything. **The index is reached by `--index`, never by a project-local `.qmd/`.** Two reasons, and the second is the dangerous one. A user may keep their own `.qmd/` at the project root for their own code, which this pack must not touch. And a project-local `.qmd/index.yml` falls under qmd's trust gate, which covers a non-default `models.embed` — for a non-interactive caller the gate does not prompt or fail, it *skips*, silently substituting a much weaker default model. Named indexes are never gated. `QMD_CONFIG_DIR` and `INDEX_PATH` are what move a named index back under the project directory. diff --git a/hooks/sync-memories.sh b/hooks/sync-memories.sh index 0b007a1..707ed68 100755 --- a/hooks/sync-memories.sh +++ b/hooks/sync-memories.sh @@ -144,20 +144,32 @@ if ! mkdir "$LOCK" 2>/dev/null; then fi trap 'rmdir "$LOCK" 2>/dev/null' EXIT +# Keep the output of a failed run. Gating the timestamp on success stops a +# failure from concealing itself, but on its own it trades silence for a +# re-attempt every prompt with still no way to see why. The log is removed on +# success, so its presence is itself the signal that indexing is broken, and it +# holds the reason. A doctor check reports it. It lives beside the index it +# describes, so resetting the index by removing that directory clears it too. +ERROR_LOG="$INDEX_DIR/memory-loop.log" + # `update` rescans the collection for added, changed and removed files; `embed` # vectorises whatever came back without one. Both are incremental — a no-op pass # is under a tenth of a second. -qmd --index memory-loop update >/dev/null 2>&1 || exit 0 -qmd --index memory-loop embed >/dev/null 2>&1 || exit 0 +qmd --index memory-loop update >"$ERROR_LOG" 2>&1 || exit 0 +qmd --index memory-loop embed >>"$ERROR_LOG" 2>&1 || exit 0 # Confirm the work actually happened rather than trusting an exit code: anything # that leaves documents pending must not mark the index fresh, or the staleness -# check skips them until some other memory changes. +# check skips them until some other memory changes. This is the case a failing +# exit code would miss — qmd reports embed-lock contention as success. pending=$(qmd --index memory-loop status 2>/dev/null | sed -n 's/.*Pending: *\([0-9][0-9]*\).*/\1/p' | head -1) -[ "${pending:-0}" -eq 0 ] || exit 0 +if [ "${pending:-0}" -ne 0 ]; then + printf '%s %s documents still need embedding after this run.\n' \ + "$(date '+%Y-%m-%dT%H:%M:%S')" "$pending" >>"$ERROR_LOG" + exit 0 +fi -# Mark indexing time for subsequent staleness checks. Gated on the commands above -# succeeding: an unconditional touch marks a *failed* index as fresh, and the -# staleness check then skips it forever with nothing reporting the gap. +# Mark indexing time for subsequent staleness checks, and clear the failure log. touch "$TIMESTAMP_FILE" +rm -f "$ERROR_LOG" diff --git a/techpack.yaml b/techpack.yaml index 9d82ed9..5cb23eb 100644 --- a/techpack.yaml +++ b/techpack.yaml @@ -135,10 +135,11 @@ components: mkdir -p "$d" export QMD_CONFIG_DIR="$d" INDEX_PATH="$d/memory-loop.sqlite" exec qmd --index memory-loop mcp - # Both pass quietly when nothing is indexed yet. Each hardens PATH because - # `mcs doctor` runs these through Process, which has no login shell, and each - # repeats the launcher's project-root ladder verbatim — every check is its own - # process, so the duplication is forced. Keep the copies identical. + # All three pass quietly when nothing is indexed yet, and each repeats the + # launcher's project-root ladder verbatim — every check is its own process, so + # the duplication is forced. Keep the copies identical. The two that invoke qmd + # also harden PATH, because `mcs doctor` runs them through Process, which has + # no login shell. doctorChecks: # Tier 2, and the only thing that would catch a silent model downgrade: # a weaker embedding model produces no error, just worse results. @@ -157,6 +158,20 @@ components: [ -f "$d/memory-loop.yml" ] || exit 0 export QMD_CONFIG_DIR="$d" INDEX_PATH="$d/memory-loop.sqlite" qmd --index memory-loop status 2>/dev/null | grep -q Qwen3-Embedding-0.6B + # The indexing hook removes its log on success, so the file existing at all + # means the last run failed or left documents unembedded — and the reason is + # inside it. Without this, a broken index is only visible as searches quietly + # returning nothing. + - type: commandExists + name: "Memory indexing completed" + section: AI Models + command: /bin/sh + args: + - "-c" + - | + r=$(git rev-parse --show-toplevel 2>/dev/null) + [ -n "$r" ] || r="${CLAUDE_PROJECT_DIR:-$PWD}" + [ ! -f "$r/.claude/.kb-index/memory-loop.log" ] # Queries with *default* arguments — reranking on — because that is the path # this pack neutralises. If an upgrade made that fatal rather than a warning, # this check is where it surfaces. The alarm is the timeout `commandExists` From 3908daf9b6e3fa1605f270b9253bcc6d61749a1d Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Tue, 1 Sep 2026 21:32:44 +0200 Subject: [PATCH 09/16] Reindex on every run instead of behind a check that never fired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `find` does not descend a symlinked root, and .claude/memories is a symlink whenever the shared-memories pack is installed — so the staleness gate saw zero files and froze the index for days with no failure log. The stamp was also written after the work, so files created during a run were skipped too. - Dropping the gate costs 0.42s of async work per prompt against the 0.056s it saved, and makes folder-vs-symlink a non-question: qmd resolves the path. - `.claude/.memories-last-indexed` is now unused; it is inert and can be deleted. --- hooks/sync-memories.sh | 55 +++++++++++++++--------------------------- 1 file changed, 19 insertions(+), 36 deletions(-) diff --git a/hooks/sync-memories.sh b/hooks/sync-memories.sh index 707ed68..a88496d 100755 --- a/hooks/sync-memories.sh +++ b/hooks/sync-memories.sh @@ -2,6 +2,14 @@ # Hook: index .claude/memories/ into this project's search index. # Runs on SessionStart and UserPromptSubmit (async). Never fails the hook. +# +# Reindexes unconditionally; do not add a staleness check. A `find -newer` gate +# was removed because `find` does not descend a symlinked root, and +# .claude/memories is a symlink whenever the shared-memories pack is installed: +# it saw zero files, so the index froze for days with no failure log. Cost of +# dropping it, on a 527-document index: update 0.185s, embed 0.105s, status +# 0.134s, against 0.056s saved. `qmd update` resolves the collection path itself, +# so a directory and a symlink behave the same. set -uo pipefail @@ -21,7 +29,6 @@ project_root=$(git rev-parse --show-toplevel 2>/dev/null) MEMORIES_DIR="$project_root/.claude/memories" INDEX_DIR="$project_root/.claude/.kb-index" CONFIG="$INDEX_DIR/memory-loop.yml" -TIMESTAMP_FILE="$project_root/.claude/.memories-last-indexed" # One model fills all three of qmd's slots. Embed is the only one that does real # work; rerank and generate are pointed here deliberately. An embedding model has @@ -92,10 +99,8 @@ EOF # mtime alone. Comparing whole files rather than probing for one line is what # lets any later edit here — the context text especially — reach an install that # already has a config. -config_changed=no if ! cmp -s "$CONFIG.new" "$CONFIG" 2>/dev/null; then mv -f "$CONFIG.new" "$CONFIG" || exit 0 - config_changed=yes else rm -f "$CONFIG.new" fi @@ -108,31 +113,12 @@ fi # and reports zero documents. [ -d "$MEMORIES_DIR" ] || exit 0 -# --- Staleness check --- -# Runs after the config sync, not before: a config that changed (a new model, new -# guidance) has to reach the index even when no memory file moved. Skipping here -# is only safe when nothing at all has changed. -# -# The index has to be present for the timestamp to mean anything. It outlived the -# previous retrieval backend: on an upgrade the file says "indexed recently" while -# no index exists at all, and without this the hook would skip until some memory -# happened to change — leaving the KB silently unsearchable in between. -if [ "$config_changed" = no ] && [ -f "$TIMESTAMP_FILE" ] && [ -f "$INDEX_PATH" ]; then - newest=$(find "$MEMORIES_DIR" -name "*.md" -newer "$TIMESTAMP_FILE" -print -quit 2>/dev/null) - # Additions and removals move a directory's mtime rather than any file's, and - # the collection pattern is recursive — so every directory has to be checked, - # not just the root. Deleting a memory inside a subdirectory bumps only that - # subdirectory, and the dropped file would otherwise stay searchable forever. - dir_changed=$(find "$MEMORIES_DIR" -type d -newer "$TIMESTAMP_FILE" -print -quit 2>/dev/null) - [ -n "$newest" ] || [ -n "$dir_changed" ] || exit 0 -fi - # --- Reindex --- # Both hook registrations are async, so two runs can overlap on a slow first # index. qmd's own embed lock reports contention as success — it prints that # another embed is running and exits 0 — so without a mutex here the losing run -# would mark the index fresh having embedded nothing, and the staleness check -# would then skip the pending work indefinitely. +# would clear the failure log having embedded nothing, hiding the pending work +# behind a clean bill of health. # # mkdir is the atomic test-and-set. A run killed by the hook timeout cannot # release the lock, so a lock older than the timeout is treated as abandoned. @@ -144,12 +130,9 @@ if ! mkdir "$LOCK" 2>/dev/null; then fi trap 'rmdir "$LOCK" 2>/dev/null' EXIT -# Keep the output of a failed run. Gating the timestamp on success stops a -# failure from concealing itself, but on its own it trades silence for a -# re-attempt every prompt with still no way to see why. The log is removed on -# success, so its presence is itself the signal that indexing is broken, and it -# holds the reason. A doctor check reports it. It lives beside the index it -# describes, so resetting the index by removing that directory clears it too. +# Removed on success, so its presence is itself the signal that indexing is broken +# and it holds the reason. A doctor check reports it. Lives beside the index, so +# removing that directory clears it too. ERROR_LOG="$INDEX_DIR/memory-loop.log" # `update` rescans the collection for added, changed and removed files; `embed` @@ -158,10 +141,11 @@ ERROR_LOG="$INDEX_DIR/memory-loop.log" qmd --index memory-loop update >"$ERROR_LOG" 2>&1 || exit 0 qmd --index memory-loop embed >>"$ERROR_LOG" 2>&1 || exit 0 -# Confirm the work actually happened rather than trusting an exit code: anything -# that leaves documents pending must not mark the index fresh, or the staleness -# check skips them until some other memory changes. This is the case a failing -# exit code would miss — qmd reports embed-lock contention as success. +# Exit code is not enough: qmd reports embed-lock contention as success, so a run +# can leave documents unembedded and still exit 0. +# +# `status`, not `update`: `update`'s "needing vectors" count is computed without +# the embed model, so it reports every hash as pending on a fully embedded index. pending=$(qmd --index memory-loop status 2>/dev/null | sed -n 's/.*Pending: *\([0-9][0-9]*\).*/\1/p' | head -1) if [ "${pending:-0}" -ne 0 ]; then @@ -170,6 +154,5 @@ if [ "${pending:-0}" -ne 0 ]; then exit 0 fi -# Mark indexing time for subsequent staleness checks, and clear the failure log. -touch "$TIMESTAMP_FILE" +# Nothing pending: clear the failure log so its presence stays meaningful. rm -f "$ERROR_LOG" From 04dee411bdeadbc2a6551838d00994f18341e846 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Tue, 1 Sep 2026 21:37:04 +0200 Subject: [PATCH 10/16] Test the indexer against a real directory and a symlink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stubs qmd on PATH, so CI needs no model. The stub creates $INDEX_PATH on `update`, which is what lets the suite tell a skipping hook from a working one: a staleness gate guarded on that file falls through without it. - Asserts the second run, not the first — a first run reindexes under any version of the hook because the config file does not exist yet. Verified the suite fails 6 assertions against the pre-fix hook. - Bumps actions/checkout v6 to v7 across all three workflows. --- .github/workflows/kb-gate-test.yml | 2 +- .github/workflows/sync-blocks.yml | 2 +- .github/workflows/sync-memories-test.yml | 30 ++++ CLAUDE.md | 5 +- tests/sync-memories-test.sh | 182 +++++++++++++++++++++++ 5 files changed, 218 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/sync-memories-test.yml create mode 100755 tests/sync-memories-test.sh diff --git a/.github/workflows/kb-gate-test.yml b/.github/workflows/kb-gate-test.yml index 2ba201b..d365e8d 100644 --- a/.github/workflows/kb-gate-test.yml +++ b/.github/workflows/kb-gate-test.yml @@ -23,7 +23,7 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 # Twice, deliberately: the barrier is scoped by a monotonic turn counter # rather than by wall-clock time, and a regression to timestamps would show diff --git a/.github/workflows/sync-blocks.yml b/.github/workflows/sync-blocks.yml index ea9befe..077465b 100644 --- a/.github/workflows/sync-blocks.yml +++ b/.github/workflows/sync-blocks.yml @@ -25,7 +25,7 @@ jobs: check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Verify SYNC blocks exist and agree across both skills + SYNC-BLOCKS.md run: | diff --git a/.github/workflows/sync-memories-test.yml b/.github/workflows/sync-memories-test.yml new file mode 100644 index 0000000..fbf35da --- /dev/null +++ b/.github/workflows/sync-memories-test.yml @@ -0,0 +1,30 @@ +name: sync-memories-test + +on: + pull_request: + paths: + - "hooks/sync-memories.sh" + - "tests/sync-memories-test.sh" + - ".github/workflows/sync-memories-test.yml" + push: + branches: [main] + paths: + - "hooks/sync-memories.sh" + - "tests/sync-memories-test.sh" + +permissions: + contents: read + +concurrency: + group: sync-memories-test-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # qmd is stubbed, so no model or index is needed. + - name: Run the sync-memories suite + run: bash tests/sync-memories-test.sh diff --git a/CLAUDE.md b/CLAUDE.md index af9587a..66b83f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,8 @@ The consequence that matters most: **nothing here executes from the repo.** Edit | Task | Command | |---|---| -| Run the hook test suite | `bash tests/kb-gate-test.sh` | +| Run the KB gate suite | `bash tests/kb-gate-test.sh` | +| Run the indexer suite | `bash tests/sync-memories-test.sh` (stubs qmd; no model needed) | | Verify the SYNC blocks agree | the snippet below (full version at the bottom of `SYNC-BLOCKS.md`) | | Check the manifest parses | `/usr/bin/python3 -c "import yaml;yaml.safe_load(open('techpack.yaml'))"` — the system Python; Homebrew's has no PyYAML | | Install a change locally | `mcs sync --global`, or `mcs sync` inside a project | @@ -38,6 +39,8 @@ Two harness details are load-bearing rather than incidental: - It runs from a temp dir **outside any git repo**, because the hook resolves its project root with `git rev-parse --show-toplevel` first. Run from the checkout, the harness would write state into the working tree and read your real session files. - The fixture project must contain `.claude/memories/`, or every `PreToolUse` call takes the `no_memories_dir` skip and nothing is gated. The denial count asserted at the end is what turns that into a loud failure instead of a green run that asserted nothing. +`tests/sync-memories-test.sh` shares the outside-a-git-repo rule and stubs `qmd` on `PATH`, so it needs no model. Its own trap: a first run reindexes under any version of the hook, because the config file does not exist yet. The assertions that mean anything are the **second** runs, and they only discriminate because the stub creates `$INDEX_PATH` — a staleness gate guarded on that file falls through without it. + ## Invariants that span files **Placeholders are baked at sync time, not read at runtime.** `prompts:` in `techpack.yaml` declares `KB_GATE_MODE`; `hooks/kb-gate.sh` carries `MODE="__KB_GATE_MODE__"`, substituted during install. Changing the mode means re-running `mcs sync` — there is no runtime setting. The test suite injects modes the same way (`sed s/__KB_GATE_MODE__/$m/`). diff --git a/tests/sync-memories-test.sh b/tests/sync-memories-test.sh new file mode 100755 index 0000000..bc9c5c8 --- /dev/null +++ b/tests/sync-memories-test.sh @@ -0,0 +1,182 @@ +#!/bin/bash +# +# Tests for hooks/sync-memories.sh. +# +# qmd is stubbed on PATH, so the suite asserts what the hook decides to do rather +# than what qmd does with it — no model, no index, fast in CI. +# +# Three setup details are load-bearing: +# - Everything runs from a temp dir OUTSIDE any git repo. The hook resolves its +# project root with `git rev-parse --show-toplevel` first. +# - The stub creates $INDEX_PATH on `update`. The staleness gate this suite +# guards against was itself guarded by `[ -f "$INDEX_PATH" ]`, so against a +# stub that never creates the file the old hook reindexes anyway and the +# symlink case passes for the wrong reason. +# - The discriminating assertion is the SECOND run. A first run always +# reindexes, old code included, because the config file does not exist yet. + +set -uo pipefail + +repo_root=$(cd "$(dirname "$0")/.." && pwd) +src="$repo_root/hooks/sync-memories.sh" +[ -f "$src" ] || { + echo "FATAL: $src not found" + exit 1 +} + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +pass=0 +fail=0 +calls=0 + +ok() { + pass=$((pass + 1)) + printf ' ok %s\n' "$1" +} + +bad() { + fail=$((fail + 1)) + printf ' FAIL %s\n %s\n' "$1" "$2" +} + +group() { printf '\n== %s\n' "$1"; } + +# --- qmd stub ------------------------------------------------------------- + +stub="$work/bin" +mkdir -p "$stub" +cat >"$stub/qmd" <<'STUB' +#!/bin/bash +printf '%s\n' "$*" >>"$STUB_LOG" +case " $* " in +*" update "*) + [ "${STUB_MODE:-ok}" = update_fails ] && { + echo "update exploded" >&2 + exit 1 + } + [ -n "${INDEX_PATH:-}" ] && : >>"$INDEX_PATH" + echo "All collections updated." + ;; +*" embed "*) + echo "All content hashes already have embeddings." + ;; +*" status "*) + echo " Total: 2 files indexed" + [ "${STUB_MODE:-ok}" = pending ] && + echo " Pending: 3 need embedding (run 'qmd embed')" + ;; +esac +exit 0 +STUB +chmod +x "$stub/qmd" + +# --- fixtures ------------------------------------------------------------- + +# A real directory, and a symlink into a sibling checkout — the shape the +# shared-memories pack installs. +plain="$work/plain" +mkdir -p "$plain/.claude/memories" +printf '# One\n' >"$plain/.claude/memories/one.md" + +link="$work/link" +mkdir -p "$link/.claude/.memories-repo/memories" +printf '# Two\n' >"$link/.claude/.memories-repo/memories/two.md" +ln -s ".memories-repo/memories" "$link/.claude/memories" + +nomem="$work/nomem" +mkdir -p "$nomem/.claude" + +# --- invocation ----------------------------------------------------------- + +# run [stub-mode] +run() { + : >"$work/stub.log" + ( + cd "$1" || exit 1 + printf '{}' | env -u CLAUDE_PROJECT_DIR \ + PATH="$stub:$PATH" STUB_LOG="$work/stub.log" STUB_MODE="${2:-ok}" \ + bash "$src" + ) + calls=$((calls + $(grep -c . "$work/stub.log"))) +} + +stub_log() { cat "$work/stub.log"; } + +# --- assertions ----------------------------------------------------------- + +assert_contains() { #