Merge pull request 'feat(rules): rule creation becomes propose-then-approve (#3557)' (#141) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / integration (push) Successful in 30s
CI & Build / Python tests (push) Successful in 1m2s
CI & Build / Build & push image (push) Successful in 19s

This commit was merged in pull request #141.
This commit is contained in:
2026-09-04 22:01:47 -04:00
2 changed files with 185 additions and 0 deletions
+65
View File
@@ -315,6 +315,61 @@ 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).
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.
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:
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.
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 —
@@ -433,6 +488,16 @@ 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=...).
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 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
standard is a design system; a procedure is a process (create_process); standard is a design system; a procedure is a process (create_process);
+120
View File
@@ -0,0 +1,120 @@
"""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."
)