feat(rules): retrieval honours a rule's home — global everywhere, a project's rules only in that project (#4074)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / Build & push image (push) Successful in 31s

semantic_search_rules searched every rule the user owned, and every hook arm
called it without a project, so each project's rules were injected into every
other project's sessions and a project rule meant nothing a session could feel.

The search now takes a scope: global rules by default (an unbound session, or a
caller that forgets to say), global plus project N when given project_id (N's
rules only if the caller can read that project, through access.can_read_project),
and every owned rule with everywhere=True. The four hook arms and the report
preference lookup pass the session's project; an explicit
search(content_type="rule") scopes to its project_id, or asks the whole rulebook
without one.

Milestone 414 step 1. Guarded by an AST walk that every hook call site passes
project_id, and an integration test on real Postgres that a rule is reached only
from its home.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-15 12:06:34 -04:00
co-authored by Claude Opus 5
parent 7f974d9749
commit 188e78bbcd
7 changed files with 255 additions and 28 deletions
+69
View File
@@ -1625,3 +1625,72 @@ async def test_an_act_arm_reports_the_bar_it_actually_searched_at():
if c.kwargs.get("source") == "pre_tool_rule"]
assert len(rows) == 1
assert rows[0].kwargs["threshold"] == search.await_args.kwargs["threshold"]
# ── scope: a session gets global rules plus its own project's (milestone 414) ──
def test_every_hook_rule_search_says_which_project_it_is_for():
"""Every call site passes `project_id`, walked rather than grepped (rule 167).
semantic_search_rules defaults to GLOBAL rules only, so an arm that forgets
the keyword does not leak another project's rules — it quietly stops
surfacing its own project's. That is the failure this pins, and it is
silent in a session: nothing errors, a project rule just never arrives.
`everywhere` is not an acceptable answer in a hook, which speaks unasked.
"""
sources = {
"src/scribe/services/plugin_context.py": 4,
"src/scribe/services/reply_preferences.py": 1,
}
for path, expected in sources.items():
calls = [
n for n in ast.walk(ast.parse(Path(path).read_text()))
if isinstance(n, ast.Call)
and getattr(n.func, "id", None) == "semantic_search_rules"
]
# The count is what lets this fail: a new arm is a new call site, and
# it must be looked at rather than slip past a guard that only checks
# the calls it already knew about.
assert len(calls) == expected, (
f"{path} has {len(calls)} rule searches, expected {expected} — a new "
f"arm must decide its scope; update this count once it passes project_id"
)
for call in calls:
keywords = {k.arg for k in call.keywords}
assert "project_id" in keywords, (
f"{path}:{call.lineno} searches rules without project_id, so it "
f"gets global rules only and never its own project's"
)
assert "everywhere" not in keywords, (
f"{path}:{call.lineno} searches every project's rules from a hook"
)
@pytest.mark.asyncio
@pytest.mark.parametrize("bound, scope", [(7, 7), (0, None)])
async def test_the_act_arms_scope_their_search_to_the_bound_project(bound, scope):
"""A bound session searches its own project; an unbound one (0) searches
global rules only, which the search spells as `project_id=None`."""
from scribe.services import plugin_context as pc
cfg = {"enabled": True, "threshold": 0.6, "top_k": 3,
"rule_threshold": 0.6, "tool_rule_threshold": 0.6}
tool_search = AsyncMock(return_value=[])
with ExitStack() as stack:
stack.enter_context(patch.object(
pc, "get_writepath_config", AsyncMock(return_value=cfg)))
stack.enter_context(patch.object(pc, "semantic_search_rules", tool_search))
stack.enter_context(patch.object(pc, "record_retrieval", MagicMock()))
stack.enter_context(patch.object(pc, "record_rule_surfaced", MagicMock()))
await pc.build_tool_rule_hint(1, "Bash", "git push origin dev", project_id=bound)
assert tool_search.await_args.kwargs["project_id"] == scope
prompt_search = AsyncMock(return_value=[])
with ExitStack() as stack:
stack.enter_context(patch.object(pc, "get_setting", AsyncMock(return_value="0.6")))
stack.enter_context(patch.object(pc, "semantic_search_rules", prompt_search))
stack.enter_context(patch.object(pc, "record_retrieval", MagicMock()))
stack.enter_context(patch.object(pc, "record_rule_surfaced", MagicMock()))
await pc.build_prompt_rule_hint(1, "please merge to main", project_id=bound)
assert prompt_search.await_args.kwargs["project_id"] == scope