CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 1m19s
CI & Build / Python tests (push) Successful in 2m2s
CI & Build / Build & push image (push) Successful in 15s
Docstrings, section headers and assertion messages drop the record number, milestone or step that introduced the behaviour, and incident accounts are restated as the present-tense failure shape. A dead pointer to a design doc that is not in the repo is removed. Renamed to the property they check: test_the_old_shape_would_be_caught -> test_a_rank_then_scope_query_is_caught test_a_ledger_repeat_no_longer_produces_that_case_at_all -> test_a_ledger_repeat_is_counted_not_withheld test_the_header_no_longer_promises_once_per_session -> test_the_header_explains_the_seen_marker test_the_portal_case_no_longer_stamps_one_canon_onto_a_whole_file -> test_one_canon_is_not_stamped_onto_a_whole_file test_the_repos_own_prose_no_longer_defines_with -> test_the_repos_own_prose_does_not_define_with test_the_original_regression_create_note_with_content -> test_the_real_server_refuses_content_on_create_note Fixture and example ids stay: "note 5" in a supersession chain, "rule 156" in an illustrative question, `#2275` as the hex-versus-citation case, `#3244` as the example of a bare number, fixed dates in date-filter tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
135 lines
6.6 KiB
Python
135 lines
6.6 KiB
Python
"""Both rule-creation tools run the propose-then-approve loop.
|
|
|
|
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
|
|
(test_create_tools_disambiguate) 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"),
|
|
# The fourth and fifth answers. They are what makes the
|
|
# question a ROUTING question rather than a yes/no one: an observation
|
|
# that turns out to be a preference or a lesson has found its home, not
|
|
# failed to become a rule. Written in one pass, because two people each
|
|
# adding one branch to a three-way distinction produce a list that does
|
|
# not read as a set.
|
|
"the preference branch": ("create_preference", "a preference"),
|
|
"the lesson branch": ("create_lesson", "a lesson"),
|
|
# And the test that routes BEFORE the proposal is drafted, so the kind is
|
|
# a question asked out loud rather than one settled silently.
|
|
"the force question": (
|
|
"what happens if someone doesn't", "force is the axis",
|
|
"a rule binds, a preference guides",
|
|
),
|
|
}
|
|
|
|
|
|
@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."
|
|
)
|