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
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:
@@ -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
|
||||||
|
|||||||
@@ -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."
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user