CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
Step 2 of the Scribe -> Grimoire rename. - src/scribe -> src/grimoire, and every import, patch target, module path, hypercorn entry point and `python -m grimoire.restore` with it; pyproject, uv.lock and the frontend package are named grimoire. - Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service runs as grimoire / grimoire_test. - Compose files default the DB user, database and (prod) volume to grimoire, each overridable from .env, so an install created as Scribe keeps its data by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME. - Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar. Archives named scribe-* with the scribe-backup-tar manifest still restore and are still counted at the destination (folder and S3), with tests. - Exports, the MCP auth realm, the ASGI scope key and the persistent logger carry the new name. Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1", because every stored secret was derived under it. Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is step 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
151 lines
6.3 KiB
Python
151 lines
6.3 KiB
Python
"""Every rule-write surface SHOWS what a trigger looks like.
|
|
|
|
WHY THIS EXISTS
|
|
|
|
`when_to_apply` is not documentation. `rule_document()` uses it twice — as
|
|
the embedded title's second half and again above the body — so it dominates
|
|
the vector, and a rule whose trigger names a CATEGORY rather than a moment
|
|
collapses toward its title and never arrives.
|
|
|
|
Bad triggers come from an uneven contract rather than from careless authors.
|
|
Triggers written where the guidance exists name a moment; triggers written
|
|
without it — "during hard debugging", "when reading any request from the
|
|
operator" — name categories. The guidance has to exist on the `update_*`
|
|
surfaces too, which is where every
|
|
RETROFITTED trigger is written, and retrofitting is most of the work: a
|
|
trigger that already reads fine as English is the one nobody rewrites.
|
|
|
|
WHAT THIS PINS
|
|
|
|
Two properties, neither of them wording:
|
|
|
|
1. A tool accepting `when_to_apply` mentions it — a parameter the
|
|
docstring never names is one nobody fills well.
|
|
2. It carries a worked CONTRAST: one `RETRIEVES:` example and one
|
|
`COLLAPSES:` example, quoted, and different from each other.
|
|
|
|
WHY A CONVENTION RATHER THAN A PROSE HEURISTIC
|
|
|
|
The second property guards the softer regression — guidance kept but
|
|
abstracted back to "name the moment in session vocabulary", which is advice
|
|
about being concrete that is not itself concrete.
|
|
|
|
Two ways to detect that in free prose fail, and the reason generalises:
|
|
|
|
- Counting quoted multi-word phrases anywhere in the docstring measured
|
|
ambient quotation, not demonstrated triggers. It PASSES the abstracted
|
|
version by scoring unrelated prose, and text with an odd number of
|
|
quote characters produced matches spanning the gap BETWEEN two
|
|
unrelated phrases.
|
|
- Scoping that count to a window after each "trigger"/"when_to_apply"
|
|
mention FAILS `create_preference` in its correct state, because
|
|
its examples sit further from the first mention than any defensible
|
|
window reaches.
|
|
|
|
Both are proxies trying to infer demonstration from prose. The answer is to
|
|
stop inferring: a two-line labelled contrast is unambiguous to parse, free
|
|
in its wording, and a better teaching form than the sentences it replaced —
|
|
`RETRIEVES:`/`COLLAPSES:` names the mechanism, so the label does work for
|
|
the reader instead of only for the test. Where a property cannot be
|
|
measured, changing the shape of the thing is cheaper than a cleverer
|
|
measurement.
|
|
|
|
What it still cannot do is judge whether a GOOD example is good. It catches
|
|
the likely failure: a write surface shipping with the contrast absent,
|
|
half-present, or tidied back into abstract advice.
|
|
|
|
THE SURFACE LIST IS DERIVED, NEVER HAND-KEPT. It comes from what
|
|
`rulebooks.register()` actually hands the server, filtered to signatures
|
|
taking `when_to_apply`, so a rule-write tool added later is in scope on the
|
|
day it is added. A hand-kept list has to be remembered by the person least
|
|
likely to know it exists — the argument `RANKED_SOURCES` makes in
|
|
services/rule_usage.py.
|
|
"""
|
|
import inspect
|
|
import re
|
|
|
|
import pytest
|
|
|
|
from tests.helpers import tool_doc as _doc
|
|
|
|
_MODULE = "grimoire.mcp.tools.rulebooks"
|
|
|
|
# The worked contrast. Quoted so the example's own punctuation stays inside
|
|
# it, labelled so no proximity guess is needed to find it.
|
|
_RETRIEVES = re.compile(r'RETRIEVES:\s*"([^"]+)"')
|
|
_COLLAPSES = re.compile(r'COLLAPSES:\s*"([^"]+)"')
|
|
|
|
|
|
def _registered_tools() -> list:
|
|
"""Every function `rulebooks.register()` actually hands to the server.
|
|
|
|
Collected by handing `register` a stand-in that records what it is given,
|
|
rather than by reading the source or repeating the tuple here. The point
|
|
is that this cannot drift from what ships.
|
|
"""
|
|
from grimoire.mcp.tools import rulebooks
|
|
|
|
collected: list = []
|
|
|
|
class _Collector:
|
|
def tool(self, name=None):
|
|
def register_one(fn):
|
|
collected.append(fn)
|
|
return fn
|
|
return register_one
|
|
|
|
rulebooks.register(_Collector())
|
|
return collected
|
|
|
|
|
|
def _trigger_writers() -> list[str]:
|
|
"""The registered tools that can write a rule's trigger."""
|
|
names = [
|
|
fn.__name__
|
|
for fn in _registered_tools()
|
|
if "when_to_apply" in inspect.signature(fn).parameters
|
|
]
|
|
# An empty list would make every case below vacuous and the guard would
|
|
# pass by having nothing to check — absence read as non-existence. Fail
|
|
# loudly instead.
|
|
assert names, "no rule-write surface takes when_to_apply; the guard is blind"
|
|
return sorted(names)
|
|
|
|
|
|
@pytest.mark.parametrize("name", _trigger_writers())
|
|
def test_a_trigger_writing_surface_documents_the_field(name):
|
|
"""A tool that can write a trigger must say what the field is for."""
|
|
doc = _doc(_MODULE, name)
|
|
assert "when_to_apply" in doc, (
|
|
f"{name} accepts when_to_apply but never mentions it in its "
|
|
"docstring. The tool docstring is the agent-facing contract, and a "
|
|
"trigger written against no contract names a category instead of a "
|
|
"moment — and a category is not a thing any session ever types."
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("name", _trigger_writers())
|
|
def test_a_trigger_writing_surface_shows_the_contrast(name):
|
|
"""Advice about being concrete has to be concrete itself."""
|
|
doc = _doc(_MODULE, name)
|
|
retrieves, collapses = _RETRIEVES.findall(doc), _COLLAPSES.findall(doc)
|
|
|
|
assert retrieves and collapses, (
|
|
f"{name} is missing its worked contrast — found "
|
|
f"{len(retrieves)} RETRIEVES and {len(collapses)} COLLAPSES example(s), "
|
|
"and one of each is required. Telling an author to 'name the moment "
|
|
"in session vocabulary' without showing one is the guidance that was "
|
|
"already on file while the corpus filled with categories. Add two "
|
|
'quoted lines: RETRIEVES: "<a moment a session actually produces — a '
|
|
'command, an error, a half-formed ask>" and COLLAPSES: "<the same '
|
|
'thing named as a category>".'
|
|
)
|
|
|
|
same = set(retrieves) & set(collapses)
|
|
assert not same, (
|
|
f"{name} shows the same text as both RETRIEVES and COLLAPSES "
|
|
f"({next(iter(same))!r}). The pair teaches by DIFFERING — one moment "
|
|
"written the way a session would produce it, and the same moment "
|
|
"written as a category. Identical halves demonstrate nothing."
|
|
)
|