diff --git a/src/scribe/mcp/tools/rulebooks.py b/src/scribe/mcp/tools/rulebooks.py index b2fc1db..a2060da 100644 --- a/src/scribe/mcp/tools/rulebooks.py +++ b/src/scribe/mcp/tools/rulebooks.py @@ -315,19 +315,20 @@ 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. + PROPOSE RULES READILY, AND WRITE ONE WHEN THE OPERATOR SAYS YES. Noticing + that something has hardened into a standing instruction is valuable work, + and a session that notices it and says nothing has thrown the observation + away. So raise it whenever you see one. The single step that belongs + between noticing and writing is the operator's yes: a rule binds every + future session, and they are the person it binds. - 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. + Their yes is also the only moment the rule is reliably IN FRONT of them. + After the write it may not be again for months — a conditional rule is not + read aloud at session start, and a project-scoped one does not appear in + an unfiltered list_rules() at all. So the proposal is the review. + + When the operator asks for a rule in so many words, that IS the yes — + write it and move on. The loop below is for the rule you thought of. A PROPOSAL CARRIES FOUR THINGS, and the fourth is the one that decides it: @@ -350,6 +351,25 @@ async def create_rule( shrinks only on purpose, so a question that prevents a rule is worth more than any question that improves one's wording. + THEN CLOSE WITH A QUESTION THEY CAN ANSWER IN ONE WORD. Offer three + answers, and make the middle one the easy one: + + * "Approve it AS WRITTEN" — you create it with the statement exactly as + shown. This is what makes element 1 load-bearing: they approved TEXT, + so that text is what gets stored, verbatim. + * "LET'S TALK ABOUT IT" — the wording, the scope, the tier, whether it + wants to be a rule at all. Most good rules arrive this way, so treat + this answer as the expected one rather than a setback. + * "NO" — let it go. If the observation is still worth keeping, it is a + note (create_note): recorded, findable, and binding on nobody. + + Where the interface offers structured choices, ask it that way — a + question with named options is answered in a click, while the same + question inside a paragraph is answered by scrolling past. Where it does + not, write the three options out as three options. Either way ask once + and let the answer stand; re-raising a declined proposal argues a rule + into existence, which is the thing this whole loop exists to prevent. + 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 — @@ -468,14 +488,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. + PROPOSE, THEN WRITE ON A YES — create_rule's opening carries the whole + loop: the four things a proposal states (what it would require, its + intent, why now, and how it would be enforced) and the one-word question + that closes it (approve as written / talk about it / no). All of it + applies here unchanged. Reach for that loop MORE readily on this surface, + not less: a project rule stays out of an unfiltered list_rules(), and a + conditional one stays out of session start too, so the operator's yes is + the one moment this rule is certain to have been seen by 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 diff --git a/tests/test_rule_creation_asks_first.py b/tests/test_rule_creation_asks_first.py index a55743c..c1cf60a 100644 --- a/tests/test_rule_creation_asks_first.py +++ b/tests/test_rule_creation_asks_first.py @@ -1,4 +1,4 @@ -"""Both rule-creation tools tell the caller to ask before writing (#3557). +"""Both rule-creation tools run the propose-then-approve loop (#3557). WHY THIS EXISTS @@ -8,33 +8,45 @@ 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 +That question belongs at the tool, because the tool is the last surface a +caller reads before the write, and because the write is less reversible than +it looks. The operator's yes is not merely consent; it is the one moment the +rule is certainly IN FRONT of them. Afterwards it may not be again for +months: a conditional rule is not 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 proposal IS the review, so there had better be 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. +WHY IT IS PHRASED AS A PRACTICE AND NOT A PROHIBITION + +The first cut of this guidance opened "NOT YOURS TO CALL UNPROMPTED." That is +the wrong instrument, and the failure it invites is worse than the one it +prevents: a caller reading a prohibition stops NOTICING rule-shaped things, +rather than noticing them and asking. The wanted behaviour is more proposals, +not fewer — spotting that something has hardened into a standing instruction +is valuable work, and the only step that was ever missing came after it. + +So the docstrings describe what to DO: propose readily, state four things, +close with a question the operator answers in one word. This test is written +the same way — it asserts the parts of the loop are present, and has nothing +to say about any wording that forbids. + +The fourth element — how the rule would be ENFORCED — is not ceremony. It is +the part that sometimes dissolves the rule: a thing a test can assert should +be that test, and a rule is what is left 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. +strikes next door. Each element matches a family of synonyms, so the prose +stays 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. +It cannot tell whether an agent actually proposes. 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 @@ -50,60 +62,59 @@ _SURFACES = [ ("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. +# The loop, element by element, 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 hold by accident ("why", "how", "reason", "rule"), which +# would let 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 invitation to propose": ("propose", "proposal"), + "the operator's approval": ("approve", "approval", "says yes", "a yes"), "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",), + "the answers offered back": ("as written", "talk about it", "discuss"), } @pytest.mark.parametrize(("module", "name"), _SURFACES) @pytest.mark.parametrize("element", sorted(_ELEMENTS)) -def test_a_rule_surface_asks_for_approval_and_its_four_parts( +def test_a_rule_surface_carries_every_part_of_the_proposal_loop( module, name, element ): - """Each of the four proposal elements survives in each docstring.""" + """Each element of propose → state four things → ask survives.""" 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." + f"session, and the proposal is the one moment that rule is certain to " + f"be seen by the operator. Say it in whatever words you like; this " + f"guard only checks it is still said. See create_rule's opening." ) @pytest.mark.parametrize(("module", "name"), _SURFACES) -def test_the_approval_gate_comes_before_the_parameter_contract(module, name): +def test_the_proposal_loop_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. + 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"] + loop_at = min( + (doc.find(t) for t in _ELEMENTS["the invitation to propose"] 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 " + assert 0 <= loop_at < args_at, ( + f"{name} introduces the proposal loop at or after its Args: block " + f"(loop {loop_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." )