diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 5600365..a266b4f 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "scribe", "description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).", - "version": "2026.09.17.0111", + "version": "2026.09.17.1618", "author": { "name": "Bryan Van Deusen" }, diff --git a/plugin/skills/using-scribe/SKILL.md b/plugin/skills/using-scribe/SKILL.md index 972ee1a..edc3a39 100644 --- a/plugin/skills/using-scribe/SKILL.md +++ b/plugin/skills/using-scribe/SKILL.md @@ -1,6 +1,6 @@ --- name: using-scribe -description: Use at the START of every session, and before answering anything about the operator's work or starting any task — establishes the Scribe-first reflex. You hold none of the operator's rules: they arrive by retrieval when your work matches one, and search(content_type="rule") is how you ask before a consequential act. Call enter_project when a repo/project is in scope. Then recall before acting, update over duplicate, plan in Scribe not in files. +description: Use at the START of every session, and before answering anything about the operator's work or starting any task — establishes the Scribe-first reflex. You hold none of the operator's rules: they arrive by retrieval when your work matches one, and what_might_apply is how you ask before a consequential act — it returns the wide net of candidates with no bar. Call enter_project when a repo/project is in scope. Then recall before acting, update over duplicate, plan in Scribe not in files. --- # Using Scribe @@ -73,9 +73,19 @@ Two constraints on *how* that's achieved: So "no rule arrived" means "nothing matched", never "no rule exists" — an empty session is not evidence of an empty rulebook. Retrieval fires when - something asks: before a consequential act, `search(content_type="rule")` on - what you are about to do, and pull a record's full statement with - `get_rule(id)` when it is about to bite. When a project is in scope, pass + something asks, so ask — and reach for the tool that fits the moment: + + - **Before a consequential act**, and before handing work back because you + are unsure you may finish it: `what_might_apply("what you are about to + do")`. It returns up to fifty ranked candidates with NO bar. The arms that + push rules at you spend a budget of three and say nothing about what sat + just underneath — right for something firing before every command, wrong + for the one moment you actually want to be sure. Expect the tail to be + noise; you are reading for the one record you would have missed. + - **When you already suspect a particular rule**: `search(content_type= + "rule")` for full statements, or `get_rule(id)` when it is about to bite. + + When a project is in scope, pass its `project_id`: the answer is then the global rules plus that project's own, never another project's. `enter_project(id)` lists the project's own rules by title. diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index 4d8bd73..c6719a6 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -44,8 +44,11 @@ client reads Agent Skills) and in each tool's description. The index: system. An `inception` key: ask what it inherits, then decide_project_inception. - RULES: nothing preloads; a rule arrives when your work matches it. Before a - consequential act, search(content_type="rule"). Silence means - nothing matched, not none. Rules bind; preferences guide. + consequential act — or before handing work back unsure you may finish it — + what_might_apply("what you are about to do"): the wide net, fifty ranked + candidates, no bar. search(content_type="rule") reads one you already + suspect. Silence means nothing matched, not none. Rules bind; preferences + guide. - RECALL: search before acting, scoped with the active project_id. - RECORD: create_task; a fix is kind="issue". add_task_log as you go; status in_progress on start, done on finish. Tag system_ids as you write. @@ -137,6 +140,11 @@ _READ_ONLY_TOOLS = frozenset({ # usual for these two: a session that cannot see the bar in force, or the # reason it was last moved, is a session that will move it again blind. "retrieval_surfaces", "retrieval_tuning_history", + # The wide net (#4103) — ranked rule candidates with no bar, for the + # moment before a consequential act. A pure read, and one a read key needs + # most: it is the surface the "ask before acting" reflex calls, and a key + # that could not reach it would be denied exactly the check it should run. + "what_might_apply", }) # Every tool that WRITES, by name. Nothing reads this set at runtime — a tool diff --git a/src/scribe/mcp/tools/__init__.py b/src/scribe/mcp/tools/__init__.py index f292d41..cdc6944 100644 --- a/src/scribe/mcp/tools/__init__.py +++ b/src/scribe/mcp/tools/__init__.py @@ -6,6 +6,7 @@ from `mcp.server.build_mcp_server`. """ from scribe.mcp.tools import ( design_systems, milestones, notes, processes, projects, recent, repos, retrieval_tuning, + wide_net, rulebooks, search, shapes, snippets, systems, tags, tasks, trash, ) @@ -14,6 +15,7 @@ def register_all(mcp) -> None: """Register every tool module's tools on the given FastMCP instance.""" search.register(mcp) retrieval_tuning.register(mcp) + wide_net.register(mcp) notes.register(mcp) tasks.register(mcp) projects.register(mcp) diff --git a/src/scribe/mcp/tools/wide_net.py b/src/scribe/mcp/tools/wide_net.py new file mode 100644 index 0000000..981db0c --- /dev/null +++ b/src/scribe/mcp/tools/wide_net.py @@ -0,0 +1,218 @@ +"""The wide net — every candidate that might govern what you are about to do (#4103). + +WHY THIS EXISTS + +Milestone 416 step 5, from the operator's compromise: + + "if you're worried about excluding potentially important data let limit it + to 50 entries or something like that" + +Fifty in the PUSH would be milestone 394 with extra steps — a wall the reader +skims, with the governing record indistinguishable from forty-nine others. +Fifty in a PULL is a different object: it arrives only when a session asks, it +crowds nothing out, and it is the honest home for "do not exclude potentially +important data". + +WHY IT IS NOT `search(content_type="rule")` + +That tool is the DEEP pull and should stay that way — `_search_rules` returns +`statement`, `why` and `how_to_apply` in full, on the reasoning that a caller +who went looking deserves the whole record rather than a summary to re-fetch. +Six results already run to thousands of tokens. + +This is the SHALLOW pull: many candidates, each just enough to decide whether +to open it. Opposite trade-off, so it is a second tool rather than a bigger +`limit` on the first. + +WHY THE TAIL IS TRUNCATED RATHER THAN FULL + +Because the step's premise needed correcting. The task said fifty "costs +nothing", and `_rule_hint_line` had already measured otherwise: a line runs +~143 tokens once its trigger is rendered, and #3855 tripled trigger lengths +across the corpus. Fifty of those is ~7,000 tokens — cheap next to an arm that +fires before every Bash call, but not free, and a tool that promises a free +wide net gets reached for casually and then regretted. + +So this reuses the graduated shape #3851 measured for the push: the top few +carry their trigger in full, the rest carry a cut of it. TRUNCATED, never +dropped — the trigger is what lets a reader judge relevance without opening +the record, and a teaser without one is just an id. +""" +from __future__ import annotations + +import textwrap +import time + +from scribe.mcp._context import current_user_id +from scribe.services.embeddings import semantic_search_rules +from scribe.services.retrieval_telemetry import record_retrieval + +# The telemetry `source`, and a PULL — never added to AMBIENT_SOURCES (those +# are deliveries nobody chose) and never confused with a push arm. Keeping it +# separate is load-bearing right now: the push arms' near-miss distributions +# are the evidence #4121 rests on, and a pull mixed into them would move the +# very numbers that step is arguing from. +SOURCE = "wide_net" + +# No bar. That is the point of the tool rather than an oversight — the caller +# asked for the wide net precisely because they do not trust a bar to decide +# for them here. Every row carries its score, so the reader sees where the +# ranking falls off and judges it themselves. +THRESHOLD = 0.0 + +MAX_LIMIT = 50 +DEFAULT_LIMIT = 25 + +# How many candidates carry their trigger in full before the rest are cut. +DEFAULT_DETAIL = 5 + +# The cut length for the tail. Long enough to carry the first clause of a +# trigger — which is where these state the act they are about — short enough +# that forty-five of them stay affordable. +_TEASER_CHARS = 140 + + +def _teaser(text: str) -> tuple[str, bool]: + """Shorten at a word break with a visible cut. Returns (text, was_cut). + + The technique is `plugin_context._goal_line`'s, and the fallback is the + part worth copying: `textwrap.shorten` returns a bare "…" when the string + is one unbroken word longer than the cap, and a raw slice ends mid-word + claiming to be the whole thing (#4036). Kept local rather than shared + because the two callers wrap it in different sentences; if a third appears, + that is the moment to extract it rather than now. + """ + flat = " ".join((text or "").split()) + if len(flat) <= _TEASER_CHARS: + return flat, False + short = textwrap.shorten(flat, width=_TEASER_CHARS, placeholder="…") + if short == "…": + short = flat[: _TEASER_CHARS - 1] + "…" + return short, True + + +async def what_might_apply( + query: str, + limit: int = DEFAULT_LIMIT, + detail: int = DEFAULT_DETAIL, + kind: str = "", + project_id: int = 0, +) -> dict: + """Every rule that might bear on what you are about to do, ranked, with no bar. + + REACH FOR THIS BEFORE A CONSEQUENTIAL OR IRREVERSIBLE ACT — a push, a + merge, a delete, a deploy, anything outward-facing — and before handing + work back because you are unsure whether you are allowed to finish it. + + It is the tool the "ask before a consequential act" reflex should call. + A rule reaches a session by retrieval, and the arms that push rules at you + have a small budget: they deliver the few highest-scoring candidates and + say nothing about what sat just underneath. That is right for an arm that + fires before every command and wrong for the one moment you actually want + to be sure. This is that moment's tool. + + WHAT IT RETURNS, AND WHY THE TAIL LOOKS LIKE NOISE + + There is no threshold. You get the `limit` nearest candidates whatever they + score, ordered, each with its score — so the tail IS expected to be + irrelevant, and that is the design. You are not reading the list for its + average quality; you are reading it for the one record you would otherwise + have missed. Scan the triggers, open what looks live with `get_rule(id)`, + and ignore the rest. + + The first few carry their trigger in full; the rest carry a cut of it, + marked `truncated`. A cut trigger is still enough to decide whether to + open the record, which is the whole job of a teaser. + + `kind` is on every row and is never something to infer: a **rule** must be + followed, a **preference** records how the operator wants work done. + Missing a rule is a mistake; missing a preference costs consistency. + + A NOTE ON WHAT THIS CANNOT DO. It is a pull, so it only helps if you ask. + The failure it was built for — a session withholding a routine action + because the rule permitting it never arrived — produces no tool call of its + own, so nothing will prompt you. Asking is the habit; this is where to put + it. + + Args: + query: what you are about to do, in the words you would use to + describe it — "push to dev after committing", "delete the staging + database", "merge dev to main". A command string works; a sentence + usually works better, because triggers are written as prose. + limit: how many candidates, default 25, capped at 50. + detail: how many carry their trigger in FULL before the rest are cut, + default 5. + kind: "rule" or "preference" to restrict; omit for both. + project_id: scope to one project — its own rules plus every global + one. Omit to ask the whole rulebook. + """ + uid = current_user_id() + limit = max(1, min(int(limit), MAX_LIMIT)) + detail = max(0, min(int(detail), limit)) + + report: dict = {} + started = time.perf_counter() + if project_id: + raw = await semantic_search_rules( + uid, query, limit=limit, threshold=THRESHOLD, kind=kind or None, + report=report, project_id=project_id, + ) + else: + raw = await semantic_search_rules( + uid, query, limit=limit, threshold=THRESHOLD, kind=kind or None, + report=report, everywhere=True, + ) + duration_ms = (time.perf_counter() - started) * 1000 + + candidates = [] + for rank, (score, rule) in enumerate(raw): + full = rank < detail + trigger, was_cut = ( + (" ".join((rule.when_to_apply or "").split()), False) + if full else _teaser(rule.when_to_apply or "") + ) + candidates.append({ + "id": rule.id, + "title": rule.title, + # Force, not topic. See the docstring — this is never inferred. + "kind": rule.kind, + # A rule in a rulebook topic is global; one on a project binds + # there alone (milestone 414). Which it is changes how far a + # reader should generalise from it. + "scope": "project" if rule.project_id else "global", + "when_to_apply": trigger, + "truncated": was_cut, + "score": round(float(score), 4), + }) + + # Logged as a pull, with `searched` honoured: a search that never ran must + # not be recorded as a ranker declining (#3765). + record_retrieval( + user_id=uid, + source=SOURCE, + query=query, + threshold=THRESHOLD, + limit=limit, + project_id=project_id or None, + is_task=None, + results=raw, + duration_ms=duration_ms, + best_available=report.get("best_available_score"), + best_available_id=report.get("best_available_id"), + searched=report.get("searched", True), + ) + + return { + "candidates": candidates, + "returned": len(candidates), + "detailed": min(detail, len(candidates)), + # Present so a caller can tell "the corpus offered nothing" from "the + # search never ran" — an empty query, an unavailable embedder and a + # failed query all return zero rows and mean different things (#3670). + "searched": bool(report.get("searched", True)), + "open_with": "get_rule(id)", + } + + +def register(mcp) -> None: + mcp.tool(name="what_might_apply")(what_might_apply) diff --git a/tests/test_retrieval_tuning.py b/tests/test_retrieval_tuning.py index 733315d..0c0ac5c 100644 --- a/tests/test_retrieval_tuning.py +++ b/tests/test_retrieval_tuning.py @@ -186,14 +186,10 @@ def test_the_tool_teaches_reading_the_records_not_the_percentile(): def test_all_three_tools_are_registered(): from scribe.mcp.tools import retrieval_tuning as tool - names = [] + from tests.helpers import FakeMCP - class _MCP: - def tool(self, name): - names.append(name) - return lambda fn: fn - - tool.register(_MCP()) - assert names == [ + mcp = FakeMCP() + tool.register(mcp) + assert mcp.names == [ "retrieval_surfaces", "tune_retrieval", "retrieval_tuning_history", ] diff --git a/tests/test_wide_net.py b/tests/test_wide_net.py new file mode 100644 index 0000000..cce7ee9 --- /dev/null +++ b/tests/test_wide_net.py @@ -0,0 +1,243 @@ +"""The wide net: many candidates, no bar, and a cost that stays bounded (#4103). + +WHY THIS EXISTS + +Milestone 416 step 5 moves "do not exclude potentially important data" off the +push, where fifty candidates would be a wall nobody reads, and onto a pull, +where they arrive only when a session asks for them. + +Three things have to hold, and each has a way of quietly failing: + + 1. **No bar.** The moment this tool serves is the one where the caller does + not trust a threshold to decide for them. A default floor creeping in + here would turn the wide net into the narrow one and nothing would look + wrong — the results would simply be fewer. + 2. **A bounded cost.** The step was filed claiming fifty "costs nothing". + `_rule_hint_line` had already measured ~143 tokens for a line with its + trigger rendered, so fifty is ~7,000. The graduated shape (#3851) is what + keeps this affordable, and a change that renders every trigger in full + would pass every other test here. + 3. **A pull, logged as one.** The push arms' near-miss distributions are the + evidence #4121 argues from. A pull mixed into them moves those numbers. +""" +from unittest.mock import AsyncMock, patch + +import pytest + +from scribe.mcp.tools import wide_net +from tests.helpers import FakeMCP, fake_rule, tool_doc + + +def _hits(n=3, trigger="Running git push", **over): + """n (score, rule) pairs, descending, the shape the service returns.""" + return [ + (0.9 - i * 0.01, + fake_rule(id=i + 1, title=f"rule {i + 1}", when_to_apply=trigger, **over)) + for i in range(n) + ] + + +def _patched(hits, report=None): + """Patch the search and the telemetry sink; hand back the search mock.""" + async def _search(*a, **kw): + if report is not None and "report" in kw and kw["report"] is not None: + kw["report"].update(report) + return hits + return patch.object(wide_net, "semantic_search_rules", AsyncMock(side_effect=_search)) + + +@pytest.mark.asyncio +async def test_no_bar_reaches_the_search(): + """THE POINT OF THE TOOL. A floor here would narrow the net silently.""" + with _patched(_hits()) as search, \ + patch.object(wide_net, "record_retrieval"), \ + patch.object(wide_net, "current_user_id", lambda: 1): + await wide_net.what_might_apply("push to dev") + + assert search.await_args.kwargs["threshold"] == 0.0 + assert wide_net.THRESHOLD == 0.0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asked, expected", [(999, 50), (0, 1), (-5, 1), (25, 25)]) +async def test_the_limit_is_clamped_to_the_cap(asked, expected): + with _patched(_hits()) as search, \ + patch.object(wide_net, "record_retrieval"), \ + patch.object(wide_net, "current_user_id", lambda: 1): + await wide_net.what_might_apply("q", limit=asked) + + assert search.await_args.kwargs["limit"] == expected + + +@pytest.mark.asyncio +async def test_the_head_carries_its_trigger_whole_and_the_tail_is_cut(): + """The graduated shape (#3851), which is what makes fifty affordable. + + A regression that rendered every trigger in full would satisfy every other + assertion in this file, so the cut is pinned on both sides: the head is + NOT marked truncated and the tail IS. + """ + long_trigger = " ".join(["running a git command before pushing anything"] * 12) + with _patched(_hits(6, trigger=long_trigger)), \ + patch.object(wide_net, "record_retrieval"), \ + patch.object(wide_net, "current_user_id", lambda: 1): + out = await wide_net.what_might_apply("q", detail=2) + + head, tail = out["candidates"][:2], out["candidates"][2:] + assert all(c["truncated"] is False for c in head) + assert all(c["when_to_apply"] == " ".join(long_trigger.split()) for c in head) + assert all(c["truncated"] is True for c in tail) + assert all(len(c["when_to_apply"]) <= wide_net._TEASER_CHARS + 1 for c in tail) + assert out["detailed"] == 2 + + +@pytest.mark.asyncio +async def test_a_short_trigger_is_never_marked_truncated(): + """`truncated` is a claim about this row, not about its rank. A tail row + whose trigger already fits must not claim a cut that did not happen.""" + with _patched(_hits(4, trigger="Running git push")), \ + patch.object(wide_net, "record_retrieval"), \ + patch.object(wide_net, "current_user_id", lambda: 1): + out = await wide_net.what_might_apply("q", detail=1) + + assert all(c["truncated"] is False for c in out["candidates"]) + + +def test_the_cut_breaks_on_a_word_and_says_it_was_cut(): + """#4036's lesson, borrowed: a raw slice ends mid-word and reads as the + whole thing.""" + text, cut = wide_net._teaser("alpha beta gamma delta " * 40) + assert cut is True + assert text.endswith("…") + # Broke on a word, so no partial token sits before the marker. + assert not text.removesuffix("…").rstrip().endswith(("alph", "bet", "gam")) + + +def test_one_unbroken_word_still_yields_text_rather_than_a_bare_marker(): + """`textwrap.shorten` returns just "…" here, which would render a teaser + carrying no information at all.""" + text, cut = wide_net._teaser("x" * 500) + assert cut is True and text != "…" + assert len(text) == wide_net._TEASER_CHARS + + +@pytest.mark.asyncio +async def test_every_row_says_its_force_and_its_scope(): + """`kind` is never inferred — a rule must be followed, a preference guides + — and `scope` says how far a reader should generalise from it.""" + hits = [ + (0.8, fake_rule(id=1, kind="rule", project_id=None)), + (0.7, fake_rule(id=2, kind="preference", project_id=44)), + ] + with _patched(hits), patch.object(wide_net, "record_retrieval"), \ + patch.object(wide_net, "current_user_id", lambda: 1): + out = await wide_net.what_might_apply("q") + + assert [c["kind"] for c in out["candidates"]] == ["rule", "preference"] + assert [c["scope"] for c in out["candidates"]] == ["global", "project"] + + +# ── telemetry: a pull, and never mistaken for a push ──────────────────────── + +@pytest.mark.asyncio +async def test_the_call_is_logged_under_its_own_pull_source(): + with _patched(_hits()), \ + patch.object(wide_net, "record_retrieval") as rec, \ + patch.object(wide_net, "current_user_id", lambda: 1): + await wide_net.what_might_apply("push to dev") + + kw = rec.call_args.kwargs + assert kw["source"] == wide_net.SOURCE == "wide_net" + assert kw["threshold"] == 0.0 + + +def test_the_wide_net_is_not_one_of_the_tunable_push_surfaces(): + """THE GUARD that keeps #4121's evidence clean (rule 167). + + The registry holds the PUSH arms — the ones with a floor and a budget the + model tunes. This source must not appear there: a pull folded into those + rows would move the near-miss distributions that step is arguing from, and + would offer a floor to tune on a tool whose whole point is not having one. + """ + from scribe.services.retrieval_surfaces import SURFACES + + assert wide_net.SOURCE not in SURFACES + + +def test_the_wide_net_is_not_ambient_either(): + """Ambient means a delivery nobody chose. This one is chosen by definition + — somebody called the tool — so counting it as ambient would make a + deliberate ask read as a bulk hand-over.""" + from scribe.services.note_usage import AMBIENT_SOURCES + + assert wide_net.SOURCE not in AMBIENT_SOURCES + + +@pytest.mark.asyncio +async def test_a_search_that_never_ran_is_not_reported_as_a_decline(): + """#3765: an empty query, a dead embedder and a failed query all return + nothing, and none of them is a ranker declining.""" + with _patched([], report={"searched": False, "best_available_score": None}), \ + patch.object(wide_net, "record_retrieval") as rec, \ + patch.object(wide_net, "current_user_id", lambda: 1): + out = await wide_net.what_might_apply("") + + assert rec.call_args.kwargs["searched"] is False + assert out["searched"] is False + + +@pytest.mark.asyncio +async def test_what_the_bar_turned_away_is_carried_through(): + """There is no bar here, but `best_available` still answers "did the corpus + have anything at all" for a call that came back empty (#3670).""" + with _patched([], report={"searched": True, "best_available_score": 0.31, + "best_available_id": 7}), \ + patch.object(wide_net, "record_retrieval") as rec, \ + patch.object(wide_net, "current_user_id", lambda: 1): + await wide_net.what_might_apply("q") + + assert rec.call_args.kwargs["best_available"] == 0.31 + assert rec.call_args.kwargs["best_available_id"] == 7 + + +# ── the contract a session actually reads ─────────────────────────────────── + +def test_the_docstring_says_when_to_reach_for_it(): + """The step's done-when, and the load-bearing half of this tool. + + A wide net nobody knows to call is worth nothing, so the docstring has to + name the MOMENT, not just the parameters — including the one that prompted + it, where a session hands work back rather than finishing it. + """ + doc = tool_doc("scribe.mcp.tools.wide_net", "what_might_apply").lower() + + assert "consequential" in doc + assert "handing work back" in doc + # Says the tail is expected to be noise — otherwise the first caller reads + # a low-scoring list as the tool being broken. + assert "noise" in doc + # And names its own limit: a pull only helps if somebody asks. + assert "only helps if you ask" in doc + + +def test_it_is_registered_and_readable_with_a_read_key(): + from scribe.mcp.server import _READ_ONLY_TOOLS + + mcp = FakeMCP() + wide_net.register(mcp) + assert mcp.names == ["what_might_apply"] + assert "what_might_apply" in _READ_ONLY_TOOLS + + +def test_the_instruction_surfaces_point_at_it(): + """Rule 119: the instruction surfaces ARE the specification for product + behaviour, so a tool the reflex never learns about is not shipped.""" + import pathlib + + from scribe.mcp import server + + assert "what_might_apply" in server._INSTRUCTIONS + + skill = (pathlib.Path(__file__).resolve().parents[1] + / "plugin" / "skills" / "using-scribe" / "SKILL.md").read_text() + assert "what_might_apply" in skill