feat(rules): the rule-creation tools ask for approval, and ask what would enforce it (#3557)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 32s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 41s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 32s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 41s
Every gate on create_rule and create_project_rule was about SHAPE — rule vs process vs snippet, one-thing-you-could-violate, general-enough, not-a-dupe. All of them improve a rule someone has already decided to write. None asked the prior question: has the person this will bind agreed to be bound by it? Both docstrings now open with the gate, and with the four things a proposal carries: what it would require in the words it would carry, its intent, why now, and how it would be enforced. The fourth is the one that decides it. "A test, a CI check, a hook, a schema constraint... or nothing" is a question that sometimes dissolves the rule: what a test can assert should BE that test, and a rule is what is left when nothing mechanical can hold the thing. A rulebook grows by default and shrinks only on purpose. create_project_rule needs the gate more, not less, and says so: a project rule is absent from an unfiltered list_rules(), and a conditional one is absent from session start too, so one written there can bind for months without ever having been in front of the person it binds. test_rule_creation_asks_first pins structure, never wording — each element matches a family of synonyms, and the gate must precede the Args: block, because a caller who has decided to make the call reads the parameters and not the prose under them. 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,6 +315,41 @@ 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
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
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 +468,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
|
||||||
|
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.
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Both rule-creation tools tell the caller to ask before writing (#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 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
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
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 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.
|
||||||
|
_ELEMENTS = {
|
||||||
|
"the approval gate itself": (
|
||||||
|
"proposal", "propose", "approv", "unprompted", "wait for an answer",
|
||||||
|
"not yours to call",
|
||||||
|
),
|
||||||
|
"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",),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("module", "name"), _SURFACES)
|
||||||
|
@pytest.mark.parametrize("element", sorted(_ELEMENTS))
|
||||||
|
def test_a_rule_surface_asks_for_approval_and_its_four_parts(
|
||||||
|
module, name, element
|
||||||
|
):
|
||||||
|
"""Each of the four proposal elements survives in each docstring."""
|
||||||
|
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."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("module", "name"), _SURFACES)
|
||||||
|
def test_the_approval_gate_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.
|
||||||
|
"""
|
||||||
|
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"]
|
||||||
|
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 "
|
||||||
|
f"caller who has already decided to write the rule reads the "
|
||||||
|
f"parameters, not the prose under them."
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user