feat(rules): the rule gate becomes a practice with a question, not a prohibition (#3557)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 25s

The first cut opened "NOT YOURS TO CALL UNPROMPTED", and that is the wrong
instrument. A caller reading a prohibition stops NOTICING rule-shaped things
rather than noticing them and asking — which trades a small failure for a
larger one. The wanted behaviour is more proposals, not fewer.

So both docstrings now describe the practice: propose readily, state the
four things, and close with a question the operator answers in one word —
approve it as written / let's talk about it / no. Named options where the
interface has them, three written-out options where it does not.

"Approve it as written" is what makes element 1 load-bearing: they approved
TEXT, so that text is stored verbatim. "Let's talk about it" is framed as
the expected answer rather than a setback. "No" routes the observation to
create_note, which records without binding.

The argument for asking is also better than consent. The operator's yes is
the one moment the rule is certainly in front of them: afterwards a
conditional rule is not read aloud at session start, and a project rule is
absent from an unfiltered list_rules(). The proposal IS the review.

Guard gains the answers-offered-back element and drops the wording that
forbade; its header records why the framing changed, so the prohibition does
not get reintroduced as a tidy-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
This commit is contained in:
2026-09-04 20:11:42 -04:00
co-authored by Claude Opus 5
parent 1a34363059
commit c3ecdf0972
2 changed files with 95 additions and 63 deletions
+41 -20
View File
@@ -315,19 +315,20 @@ async def create_rule(
) -> dict: ) -> dict:
"""Create a new rule in a rulebook (a SHARED rule — keep it general). """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 PROPOSE RULES READILY, AND WRITE ONE WHEN THE OPERATOR SAYS YES. Noticing
operator is the one it binds — so a rule YOU thought of is a PROPOSAL, not that something has hardened into a standing instruction is valuable work,
a record you write and mention afterwards. Put it to them and wait for an and a session that notices it and says nothing has thrown the observation
answer. Writing first does not arrive at the same place by a shorter road: away. So raise it whenever you see one. The single step that belongs
it converts a question they could have answered in a sentence into a between noticing and writing is the operator's yes: a rule binds every
standing instruction they must first discover and then argue with, and future session, and they are the person it binds.
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; Their yes is also the only moment the rule is reliably IN FRONT of them.
do not make them say it twice. This gate is about the rule nobody asked After the write it may not be again for months — a conditional rule is not
for — the one that occurred to you while doing something else. 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: 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 shrinks only on purpose, so a question that prevents a rule is worth
more than any question that improves one's wording. 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 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 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 — 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 the rule is returned in get_project's applicable_rules (under
project_rules) and in list_rules(project_id=...). project_rules) and in list_rules(project_id=...).
NOT YOURS TO CALL UNPROMPTED EITHER. create_rule's opening states the PROPOSE, THEN WRITE ON A YES — create_rule's opening carries the whole
approval gate and the four things a proposal carries what it would loop: the four things a proposal states (what it would require, its
require, its intent, why now, and how it would be enforced and none of intent, why now, and how it would be enforced) and the one-word question
that relaxes because the rule is scoped to one project. This surface that closes it (approve as written / talk about it / no). All of it
needs it MORE, not less: a project rule is absent from an unfiltered applies here unchanged. Reach for that loop MORE readily on this surface,
list_rules(), and a conditional one is absent from session start too, so not less: a project rule stays out of an unfiltered list_rules(), and a
a rule written here can bind for months without ever having been in front conditional one stays out of session start too, so the operator's yes is
of the person it binds. 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 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 opening asks that question and it applies identically here. A visual
+54 -43
View File
@@ -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 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 — 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. 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 That question belongs at the tool, because the tool is the last surface a
surface a caller reads before the write, and because the write is not caller reads before the write, and because the write is less reversible than
reversible in the way it looks. A rule that exists is not a proposal that it looks. The operator's yes is not merely consent; it is the one moment the
happens to be recorded: it is a standing instruction the operator must first rule is certainly IN FRONT of them. Afterwards it may not be again for
DISCOVER and then argue with, and the tiering makes discovery unreliable by months: a conditional rule is not read aloud at session start, and a
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. 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 WHY IT IS PHRASED AS A PRACTICE AND NOT A PROHIBITION
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 The first cut of this guidance opened "NOT YOURS TO CALL UNPROMPTED." That is
mechanical can hold it. A rulebook grows by default and shrinks only on the wrong instrument, and the failure it invites is worse than the one it
purpose, so the question that prevents a rule earns more than any question prevents: a caller reading a prohibition stops NOTICING rule-shaped things,
that improves one's wording. 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 WHAT THIS PINS, AND WHAT IT DOES NOT
STRUCTURE, never wording — the same bargain the disambiguator guard (#3123) STRUCTURE, never wording — the same bargain the disambiguator guard (#3123)
strikes next door. Each concept is matched against a family of synonyms, so strikes next door. Each element matches a family of synonyms, so the prose
the paragraphs stay free to be rewritten, reordered or sharpened; only stays free to be rewritten, reordered or sharpened; only DELETING one fails.
DELETING one fails. Pinning phrasing would make every improvement a red Pinning phrasing would make every improvement a red build, and a test that
build, and a test that punishes editing is a test someone deletes. punishes editing is a test someone deletes.
It cannot tell whether an agent actually asks. Nothing in a docstring can. It cannot tell whether an agent actually proposes. Nothing in a docstring
It catches the regression that really happens: guidance tidied away in a can. It catches the regression that really happens: guidance tidied away in
later pass by someone who read it as throat-clearing in front of the Args. a later pass by someone who read it as throat-clearing in front of the Args.
""" """
import pytest import pytest
@@ -50,60 +62,59 @@ _SURFACES = [
("scribe.mcp.tools.rulebooks", "create_project_rule"), ("scribe.mcp.tools.rulebooks", "create_project_rule"),
] ]
# The four things a proposal carries, each as a family of ways to say it. # The loop, element by element, each as a family of ways to say it. A
# A docstring satisfies an element by containing ANY member — that is the # docstring satisfies an element by containing ANY member — that is the room
# room left for rewriting. The families deliberately exclude bare words a # left for rewriting. The families deliberately exclude bare words a
# docstring would contain by accident ("why", "how", "reason"), which would # docstring would hold by accident ("why", "how", "reason", "rule"), which
# make the assertion pass on prose that says nothing of the kind. # would let the assertion pass on prose that says nothing of the kind.
_ELEMENTS = { _ELEMENTS = {
"the approval gate itself": ( "the invitation to propose": ("propose", "proposal"),
"proposal", "propose", "approv", "unprompted", "wait for an answer", "the operator's approval": ("approve", "approval", "says yes", "a yes"),
"not yours to call",
),
"the rule's intent": ("intent", "what it changes about how work"), "the rule's intent": ("intent", "what it changes about how work"),
"why it is being proposed now": ( "why it is being proposed now": (
"why now", "arose_from_id", "the incident", "prompted it", "why now", "arose_from_id", "the incident", "prompted it",
), ),
"how it would be enforced": ("enforc",), "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(("module", "name"), _SURFACES)
@pytest.mark.parametrize("element", sorted(_ELEMENTS)) @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 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() doc = _doc(module, name).lower()
assert any(token in doc for token in _ELEMENTS[element]), ( assert any(token in doc for token in _ELEMENTS[element]), (
f"{name}'s docstring no longer mentions {element}. A caller reads " f"{name}'s docstring no longer mentions {element}. A caller reads "
f"this immediately before writing a rule that will bind every future " 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"session, and the proposal is the one moment that rule is certain to "
f"Say it in whatever words you like; this guard only checks it is " f"be seen by the operator. Say it in whatever words you like; this "
f"still said. See create_rule's opening for the shape." f"guard only checks it is still said. See create_rule's opening."
) )
@pytest.mark.parametrize(("module", "name"), _SURFACES) @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. """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. 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 — Guidance parked below them — or folded into one argument's description —
is guidance that arrives after the decision it was meant to inform, which arrives after the decision it was meant to inform, which is the same as
is the same as not being there. not being there.
""" """
doc = _doc(module, name).lower() doc = _doc(module, name).lower()
args_at = doc.find("args:") args_at = doc.find("args:")
assert args_at > 0, f"{name}'s docstring has no Args: block" assert args_at > 0, f"{name}'s docstring has no Args: block"
gate_at = min( loop_at = min(
(doc.find(t) for t in _ELEMENTS["the approval gate itself"] (doc.find(t) for t in _ELEMENTS["the invitation to propose"]
if doc.find(t) >= 0), if doc.find(t) >= 0),
default=-1, default=-1,
) )
assert 0 <= gate_at < args_at, ( assert 0 <= loop_at < args_at, (
f"{name} states the approval gate at or after its Args: block " f"{name} introduces the proposal loop at or after its Args: block "
f"(gate {gate_at}, args {args_at}). Move it to the opening — a " 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"caller who has already decided to write the rule reads the "
f"parameters, not the prose under them." f"parameters, not the prose under them."
) )