"""Both rule-creation tools run the propose-then-approve loop (#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 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. The proposal IS the review, so there had better be one. 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 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 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 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 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 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_carries_every_part_of_the_proposal_loop( module, name, element ): """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, 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_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 — 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" loop_at = min( (doc.find(t) for t in _ELEMENTS["the invitation to propose"] if doc.find(t) >= 0), default=-1, ) 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." )