From 1a34363059a841d554eb3c852b6d6b9a77f95f20 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 4 Sep 2026 20:02:05 -0400 Subject: [PATCH] feat(rules): the rule-creation tools ask for approval, and ask what would enforce it (#3557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every gate on create_rule and create_project_rule was about SHAPE — rule vs process vs snippet, one-thing-you-could-violate, general-enough, not-a-dupe. All of them improve a rule someone has already decided to write. None asked the prior question: has the person this will bind agreed to be bound by it? Both docstrings now open with the gate, and with the four things a proposal carries: what it would require in the words it would carry, its intent, why now, and how it would be enforced. The fourth is the one that decides it. "A test, a CI check, a hook, a schema constraint... or nothing" is a question that sometimes dissolves the rule: what a test can assert should BE that test, and a rule is what is left when nothing mechanical can hold the thing. A rulebook grows by default and shrinks only on purpose. create_project_rule needs the gate more, not less, and says so: a project rule is absent from an unfiltered list_rules(), and a conditional one is absent from session start too, so one written there can bind for months without ever having been in front of the person it binds. test_rule_creation_asks_first pins structure, never wording — each element matches a family of synonyms, and the gate must precede the Args: block, because a caller who has decided to make the call reads the parameters and not the prose under them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ --- src/scribe/mcp/tools/rulebooks.py | 44 ++++++++++ tests/test_rule_creation_asks_first.py | 109 +++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 tests/test_rule_creation_asks_first.py diff --git a/src/scribe/mcp/tools/rulebooks.py b/src/scribe/mcp/tools/rulebooks.py index e21fdb2..b2fc1db 100644 --- a/src/scribe/mcp/tools/rulebooks.py +++ b/src/scribe/mcp/tools/rulebooks.py @@ -315,6 +315,41 @@ async def create_rule( ) -> dict: """Create a new rule in a rulebook (a SHARED rule — keep it general). + NOT YOURS TO CALL UNPROMPTED. A rule binds every future session, and the + operator is the one it binds — so a rule YOU thought of is a PROPOSAL, not + a record you write and mention afterwards. Put it to them and wait for an + answer. Writing first does not arrive at the same place by a shorter road: + it converts a question they could have answered in a sentence into a + standing instruction they must first discover and then argue with, and + this system makes that discovery unreliable on purpose — a conditional + rule is never read aloud at session start, and a project-scoped one does + not appear in an unfiltered list_rules() at all. + + An operator who asks for a rule in so many words has already approved it; + do not make them say it twice. This gate is about the rule nobody asked + for — the one that occurred to you while doing something else. + + A PROPOSAL CARRIES FOUR THINGS, and the fourth is the one that decides it: + + 1. WHAT it would require — the statement, in the words it would carry, + not a gloss of them. The operator is agreeing to text. + 2. INTENT — what it changes about how work gets done, and what goes + wrong today without it. "Be careful about X" is not an intent; the + behaviour that would differ tomorrow is. + 3. WHY NOW — the incident, observation or decision behind it. Pass that + record as arose_from_id, and say it in the conversation too: the + field is for the reader six months out, the sentence is for the + person deciding. + 4. HOW IT WOULD BE ENFORCED — a test, a CI check, a hook, a schema + constraint, a duplicate gate, a review step... or nothing, in which + case say so plainly: "nothing — this is prose a session has to + remember." Answer this one honestly and it will sometimes dissolve + the rule, which is the point rather than a side effect. What a test + can assert should BE that test; a rule is what remains when nothing + mechanical can hold the thing. A rulebook grows by default and + shrinks only on purpose, so a question that prevents a rule is worth + more than any question that improves one's wording. + A rulebook rule is shared by every project that gets the rulebook: an always_on rulebook binds ALL your projects; a subscribed rulebook binds the projects that opt in. So a rulebook rule must read as a general standard — @@ -433,6 +468,15 @@ async def create_project_rule( the rule is returned in get_project's applicable_rules (under project_rules) and in list_rules(project_id=...). + NOT YOURS TO CALL UNPROMPTED EITHER. create_rule's opening states the + approval gate and the four things a proposal carries — what it would + require, its intent, why now, and how it would be enforced — and none of + that relaxes because the rule is scoped to one project. This surface + needs it MORE, not less: a project rule is absent from an unfiltered + list_rules(), and a conditional one is absent from session start too, so + a rule written here can bind for months without ever having been in front + of the person it binds. + Check first whether a rule is the right shape at all — create_rule's opening asks that question and it applies identically here. A visual standard is a design system; a procedure is a process (create_process); diff --git a/tests/test_rule_creation_asks_first.py b/tests/test_rule_creation_asks_first.py new file mode 100644 index 0000000..a55743c --- /dev/null +++ b/tests/test_rule_creation_asks_first.py @@ -0,0 +1,109 @@ +"""Both rule-creation tools tell the caller to ask before writing (#3557). + +WHY THIS EXISTS + +Every other gate on `create_rule` and `create_project_rule` is about SHAPE: +is this a rule or a process, is it one thing you could violate, is it general +enough for a rulebook, is it a near-duplicate. All of those improve a rule +someone has already decided to write. None of them asks the prior question — +whether the person the rule will bind has agreed to be bound by it. + +That question has to be asked at the tool, because the tool is the last +surface a caller reads before the write, and because the write is not +reversible in the way it looks. A rule that exists is not a proposal that +happens to be recorded: it is a standing instruction the operator must first +DISCOVER and then argue with, and the tiering makes discovery unreliable by +design — a conditional rule is never read aloud at session start, and a +project-scoped rule does not appear in an unfiltered `list_rules()` at all. +So the cheapest moment to ask is the only good one. + +The fourth element of a proposal — how the rule would be ENFORCED — is not +ceremony. It is the gate that sometimes dissolves the rule: a thing a test +can assert should be that test, and a rule is what is left over when nothing +mechanical can hold it. A rulebook grows by default and shrinks only on +purpose, so the question that prevents a rule earns more than any question +that improves one's wording. + +WHAT THIS PINS, AND WHAT IT DOES NOT + +STRUCTURE, never wording — the same bargain the disambiguator guard (#3123) +strikes next door. Each concept is matched against a family of synonyms, so +the paragraphs stay free to be rewritten, reordered or sharpened; only +DELETING one fails. Pinning phrasing would make every improvement a red +build, and a test that punishes editing is a test someone deletes. + +It cannot tell whether an agent actually asks. Nothing in a docstring can. +It catches the regression that really happens: guidance tidied away in a +later pass by someone who read it as throat-clearing in front of the Args. +""" +import pytest + +from tests.helpers import tool_doc as _doc + +# Both surfaces, because the one that needs it most is the one that looks +# minor. A project rule is the least visible record the system can hold — +# absent from an unfiltered list_rules(), and absent from session start too +# whenever it is conditional — so the surface that writes one carries the +# larger risk while reading as the smaller act. +_SURFACES = [ + ("scribe.mcp.tools.rulebooks", "create_rule"), + ("scribe.mcp.tools.rulebooks", "create_project_rule"), +] + +# The four things a proposal carries, each as a family of ways to say it. +# A docstring satisfies an element by containing ANY member — that is the +# room left for rewriting. The families deliberately exclude bare words a +# docstring would contain by accident ("why", "how", "reason"), which would +# make the assertion pass on prose that says nothing of the kind. +_ELEMENTS = { + "the approval gate itself": ( + "proposal", "propose", "approv", "unprompted", "wait for an answer", + "not yours to call", + ), + "the rule's intent": ("intent", "what it changes about how work"), + "why it is being proposed now": ( + "why now", "arose_from_id", "the incident", "prompted it", + ), + "how it would be enforced": ("enforc",), +} + + +@pytest.mark.parametrize(("module", "name"), _SURFACES) +@pytest.mark.parametrize("element", sorted(_ELEMENTS)) +def test_a_rule_surface_asks_for_approval_and_its_four_parts( + module, name, element +): + """Each of the four proposal elements survives in each docstring.""" + doc = _doc(module, name).lower() + assert any(token in doc for token in _ELEMENTS[element]), ( + f"{name}'s docstring no longer mentions {element}. A caller reads " + f"this immediately before writing a rule that will bind every future " + f"session — it is the last place the question can be raised cheaply. " + f"Say it in whatever words you like; this guard only checks it is " + f"still said. See create_rule's opening for the shape." + ) + + +@pytest.mark.parametrize(("module", "name"), _SURFACES) +def test_the_approval_gate_comes_before_the_parameter_contract(module, name): + """It has to be read to work, and the Args: block is where reading stops. + + A caller who has decided to make the call skims down to the parameters. + Guidance parked below them — or folded into one argument's description — + is guidance that arrives after the decision it was meant to inform, which + is the same as not being there. + """ + doc = _doc(module, name).lower() + args_at = doc.find("args:") + assert args_at > 0, f"{name}'s docstring has no Args: block" + gate_at = min( + (doc.find(t) for t in _ELEMENTS["the approval gate itself"] + if doc.find(t) >= 0), + default=-1, + ) + assert 0 <= gate_at < args_at, ( + f"{name} states the approval gate at or after its Args: block " + f"(gate {gate_at}, args {args_at}). Move it to the opening — a " + f"caller who has already decided to write the rule reads the " + f"parameters, not the prose under them." + )