diff --git a/src/scribe/mcp/tools/tasks.py b/src/scribe/mcp/tools/tasks.py index 143f670..ffbcb2a 100644 --- a/src/scribe/mcp/tools/tasks.py +++ b/src/scribe/mcp/tools/tasks.py @@ -27,6 +27,7 @@ from scribe.services import rulebooks as rulebooks_svc from scribe.services import systems as systems_svc from scribe.services import task_logs as task_logs_svc from scribe.services import trash as trash_svc +from scribe.services.note_usage import record_pulled async def list_tasks( @@ -99,6 +100,14 @@ async def get_task(task_id: int) -> dict: data["suppressed_rules"] = applicable.get("suppressed_rules", []) data["suppressed_topics"] = applicable.get("suppressed_topics", []) data.update(await access_svc.describe_provenance(uid, note)) + # Same reasoning as get_note's record_pulled, and this is the tool where it + # matters MOST: auto-inject ranks kind-blind over a corpus that is + # overwhelmingly tasks and issues, so tasks dominate what it surfaces. Without + # this the surfaced→pulled loop was open exactly where the volume is — every + # surfaced task counted as never-pulled because the tool that opens one didn't + # say so, driving auto-inject's measured pull-through toward zero for its own + # dominant kind. #1038 and #2085 are explicitly gated on that number (#2245). + record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_task") return data diff --git a/tests/test_note_usage.py b/tests/test_note_usage.py index 59d5a3f..92dd7d9 100644 --- a/tests/test_note_usage.py +++ b/tests/test_note_usage.py @@ -186,3 +186,37 @@ async def test_auto_inject_records_what_survived_the_margin_gate(): assert surfaced.call_args.kwargs["note_ids"] == [1] assert surfaced.call_args.kwargs["source"] == "auto_inject" + + +def test_every_getter_that_can_be_surfaced_also_records_a_pull(): + """Rule #33 contract check — and the one that would have caught #2245. + + `surfaced` and `pulled` only mean something as a PAIR: the rate between them + is what #1038 and #2085 gate on. That pair is only closed if the tool which + OPENS a record reports it. `get_note` did, `get_snippet` did, `get_task` did + NOT — and auto-inject ranks kind-blind over a corpus that is overwhelmingly + tasks and issues, so the gap sat exactly where the volume is: every surfaced + task counted as never-pulled, dragging measured pull-through toward zero for + the menu's own dominant kind. + + Asserted by source inspection rather than by calling the tools, because the + failure is a MISSING call — which no behavioural test of the tool's return + value can see. + """ + import inspect + + from scribe.mcp.tools import notes as notes_tools + from scribe.mcp.tools import snippets as snippet_tools + from scribe.mcp.tools import tasks as task_tools + + getters = ( + (notes_tools, "get_note"), + (task_tools, "get_task"), + (snippet_tools, "get_snippet"), + ) + for module, name in getters: + src = inspect.getsource(getattr(module, name)) + assert "record_pulled(" in src, ( + f"{name} can be surfaced in an auto-inject menu but records no pull — " + "its pull-through rate will read as zero regardless of real usage" + )