CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 18s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 41s
Survey pass 3 (#2250) tabulated capabilities per record kind. Processes came out lowest on every column, and they are the kind with the most authority: build_process_manifest turns each one into a skill file on the operator's machine that auto-surfaces and is followed as written — its own docstring calls it "the most consequential passive surface Scribe has." Three gaps closed. NO PULL TELEMETRY (#2476). get_process recorded nothing, while the auto-inject menu header names get_process as the way to open that kind. Every note is embedded regardless of note_type, so a Process is surfaceable — and the getter the product points at was the one getter that recorded nothing, leaving every Process permanently at zero pulls and looking like dead weight beside kinds that merely had a counter. get_note's own comment already listed processes as a reason to record pulls. The fix for #2245 covered notes, tasks and snippets: it enumerated the kinds someone thought of rather than the kinds that exist. NO DEDUP GATE. create_process had no near-duplicate check and no force flag, while notes, tasks, snippets and rules all have both. It matters more here than elsewhere: two near-identical procedures don't just bloat the corpus, they compete to be followed, and which one wins is decided by a slug collision. NO DELETE. list/create/get/update, no delete — a kind that reads as one you cannot retire. Deletion was always possible via delete_note, since a Process is a note and the trash is kind-agnostic, so this was discoverability rather than capability. delete_process checks note_type before trashing: the tool is reached for by name, and letting it destroy an ordinary note whose id happened to resolve would be a destructive action taken on a mistyped argument. THE GUARD, which is the part that stops a fourth repeat. tests/test_mcp_pull_telemetry.py discovers every get_* MCP tool by AST and requires a record_pulled from any that loads a single note. Not a list of getters — a get_<newkind> added tomorrow is covered the moment it loads a note the way the others do. get_milestone is correctly excluded: it calls list_notes for a milestone's steps, which is a surfacing, not an opening. The loader NAMES are a list, and that residual weakness is pinned against a rename rather than papered over. An earlier draft tried to discover new loaders by return annotation and would have failed on create_note — which also returns a Note. Readers and writers aren't distinguishable by type, so the honest version is a pinned list, a non-empty assertion, and a docstring saying which hole remains. test_register_attaches_four_tools became a derived check of the module's public coroutines, so the next tool added can't be left unregistered. MCP _INSTRUCTIONS updated: product behaviour belongs in the instruction surfaces, not in a rule (rule #119). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
114 lines
4.7 KiB
Python
114 lines
4.7 KiB
Python
"""Every getter that opens ONE note-backed record must record the pull.
|
|
|
|
WHY THIS EXISTS
|
|
|
|
`note_usage_events` answers "did anyone ever actually open this?" — the
|
|
surfaced:pulled ratio is what makes dead weight visible and prunable. A getter
|
|
that opens a record without recording it leaves that kind permanently at zero
|
|
pulls, so it looks like dead weight beside kinds that merely had a counter.
|
|
|
|
That has now happened twice:
|
|
|
|
#2245 `get_task` recorded nothing while auto-inject surfaced mostly tasks.
|
|
Fixed by adding the call to notes, tasks and snippets.
|
|
#2476 `get_process` recorded nothing — and the auto-inject menu header names
|
|
`get_process` as the way to open that kind. Processes were embedded
|
|
when #2245 was fixed; the fix enumerated the kinds someone thought of
|
|
rather than the kinds that exist.
|
|
|
|
A missing call is the shape no behavioural test catches: it changes no return
|
|
value (#2278, shape 4). Source inspection is the only thing that sees it.
|
|
|
|
WHAT MAKES THIS DERIVED RATHER THAN A LIST
|
|
|
|
The getters are not enumerated here. They are discovered from the tool modules
|
|
by AST, and the ones that must record are identified by the loader they call —
|
|
so a `get_<newkind>` added tomorrow is covered the moment it loads a note the
|
|
way every other getter does.
|
|
|
|
The loader names ARE a list, and that is the residual weakness. The second test
|
|
pins them against a RENAME — the failure mode that would silently empty the
|
|
candidate set and let this pass while checking nothing.
|
|
|
|
It does not discover NEW loaders, and an earlier draft that tried to failed for
|
|
the wrong reason: `create_note` and `update_note` also return a `Note`, so an
|
|
annotation scan finds writers, not readers. Distinguishing them needs more than
|
|
a type, so the honest position is a pinned list plus a non-empty assertion,
|
|
and this paragraph saying so.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import inspect
|
|
import pathlib
|
|
import pkgutil
|
|
|
|
# Loaders that return ONE note-backed record in full. A getter calling any of
|
|
# these is opening a record, which is the act `pulled` describes.
|
|
#
|
|
# `list_notes` is deliberately absent: `get_milestone` calls it to list a
|
|
# milestone's steps, and that is a LIST — the milestone itself is not a note,
|
|
# and its steps are surfaced rather than opened.
|
|
SINGLE_NOTE_LOADERS = (
|
|
"get_note_for_user",
|
|
"resolve_process",
|
|
"get_snippet",
|
|
)
|
|
|
|
TOOLS_DIR = pathlib.Path(__file__).resolve().parents[1] / "src" / "scribe" / "mcp" / "tools"
|
|
|
|
|
|
def _getters():
|
|
"""(module name, function name, source) for every `get_*` MCP tool."""
|
|
for mod in pkgutil.iter_modules([str(TOOLS_DIR)]):
|
|
path = TOOLS_DIR / f"{mod.name}.py"
|
|
source = path.read_text()
|
|
for node in ast.parse(source).body:
|
|
if isinstance(node, ast.AsyncFunctionDef) and node.name.startswith("get_"):
|
|
yield mod.name, node.name, ast.get_source_segment(source, node) or ""
|
|
|
|
|
|
def test_every_single_record_getter_records_a_pull():
|
|
missing = []
|
|
checked = []
|
|
for module, name, body in _getters():
|
|
if not any(loader in body for loader in SINGLE_NOTE_LOADERS):
|
|
continue
|
|
checked.append(f"{module}.{name}")
|
|
if "record_pulled" not in body:
|
|
missing.append(f"{module}.{name}")
|
|
|
|
# If this ever drops to zero the test has stopped testing anything — a
|
|
# renamed loader would silently empty the candidate set and pass.
|
|
assert checked, "found no note-backed getters; the loader names must have moved"
|
|
assert not missing, (
|
|
f"these getters open a record without recording the pull: {missing}. "
|
|
f"Add record_pulled(user_id=…, note_id=…, source='mcp_<tool>') before "
|
|
f"returning — see mcp/tools/notes.py:get_note."
|
|
)
|
|
|
|
|
|
def test_every_named_loader_still_exists():
|
|
"""Pins the hand-written list against a rename.
|
|
|
|
A renamed loader is the failure that matters: the candidate set above would
|
|
quietly empty and the first test would pass while checking nothing. The
|
|
`assert checked` there catches it too; this says WHICH name moved, which is
|
|
the difference between a five-minute fix and a puzzle.
|
|
"""
|
|
from scribe.services import notes as notes_svc
|
|
from scribe.services import snippets as snippets_svc
|
|
|
|
available = {
|
|
name
|
|
for svc in (notes_svc, snippets_svc)
|
|
for name, obj in vars(svc).items()
|
|
if inspect.iscoroutinefunction(obj)
|
|
}
|
|
gone = [name for name in SINGLE_NOTE_LOADERS if name not in available]
|
|
assert not gone, (
|
|
f"SINGLE_NOTE_LOADERS names {gone} that no longer exist — they were "
|
|
f"renamed or moved. Update the list, or the pull check silently stops "
|
|
f"covering whatever used them."
|
|
)
|