CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m2s
CI & Build / Python tests (push) Failing after 1m26s
CI & Build / Build & push image (push) Skipped
Step 3 of the Scribe -> Grimoire rename. - Plugin `grimoire` in marketplace `grimoire-plugin`, MCP server key `grimoire`, so tools reach a session as mcp__plugin_grimoire_grimoire__*. - hooks/scribe_* -> hooks/grimoire_*, their shell functions and the TMPDIR ledgers with them; SCRIBE_URL / SCRIBE_TOKEN / SCRIBE_HOOK_DIR / SCRIBE_LEDGER_DIRS -> GRIMOIRE_*; the project marker is `.grimoire`. - skills/using-scribe -> using-grimoire, /grimoire:sync, stored processes arrive as grimoire-proc-<slug>; moment_actions names them the same way. - Migration 0124 rewrites stored skill names (rule_moments, rule_moment_judgments, moment_mappings) from scribe:/scribe-proc- to grimoire:/grimoire-proc-, so a rule mounted on a skill keeps firing. - Settings' install commands and MCP-only defaults, the docs and the tests follow. - scripts/check_plugin.py drops the smoke event for a report-check hook that has no file in plugin/hooks. Kept on purpose: the secret-sealing label, and the names that let backups written as Scribe restore and be counted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
198 lines
8.9 KiB
Python
198 lines
8.9 KiB
Python
"""What the instruction surfaces must say that one owner per topic cannot enforce.
|
||
|
||
tests/test_guidance_ownership.py enforces who states each topic: every topic
|
||
stated in full by exactly one owner, with a one-line pointer in the server's
|
||
index. How to ask for a rule and what an empty session means are the rules
|
||
topic, owned by using-grimoire and indexed in `_INSTRUCTIONS`; the Systems
|
||
reflex and the snippet-recording triggers are their topics' markers, required
|
||
on their owners.
|
||
|
||
WHAT STAYS HERE
|
||
|
||
Properties of a CLAIM rather than of who owns a topic: the index fits the
|
||
fold; a surface that says rules bind also names what does not; using-grimoire
|
||
still sends a session to the reporting-back skill.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import pathlib
|
||
|
||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||
|
||
|
||
def _all_surfaces() -> list[tuple[str, str]]:
|
||
"""(label, text) for every file a SESSION loads as instructions.
|
||
|
||
Deliberately not every markdown file under plugin/: `README.md` describes
|
||
the push channel to the operator installing the plugin, and telling a human
|
||
what the hook does is not the same act as telling an agent it need not pull.
|
||
The boundary is "does a session read this", which is skills (loaded by
|
||
description match, and each reference file a skill links is read on its
|
||
own, so it is its own surface), the hook-injected static context, and the MCP server's
|
||
own instructions.
|
||
"""
|
||
found = [(str(p.relative_to(ROOT)), p.read_text())
|
||
for p in (ROOT / "plugin" / "skills").rglob("*.md")]
|
||
found += [(str(p.relative_to(ROOT)), p.read_text())
|
||
for p in (ROOT / "plugin" / "hooks").glob("*.md")]
|
||
server = ROOT / "src" / "grimoire" / "mcp" / "server.py"
|
||
found.append((str(server.relative_to(ROOT)), server.read_text()))
|
||
return found
|
||
|
||
|
||
def _instructions_text() -> str:
|
||
"""The _INSTRUCTIONS literal from server.py, as the client would see it."""
|
||
import re
|
||
src = (ROOT / "src" / "grimoire" / "mcp" / "server.py").read_text()
|
||
match = re.search(r'_INSTRUCTIONS = """(.*?)"""', src, re.S)
|
||
assert match, "server.py no longer defines _INSTRUCTIONS as a triple-quoted literal"
|
||
return match.group(1).strip()
|
||
|
||
|
||
# Claude Code injects only the first ~2,048 characters of an MCP server's
|
||
# instructions and silently cuts the rest mid-word, so guidance past the cap
|
||
# never reaches a session. The budget is set below the cap, not at it: the
|
||
# field runs ~600–1,600 characters, and
|
||
# a block pressed against 2,048 is being used for depth that belongs in a
|
||
# skill.
|
||
INSTRUCTIONS_BUDGET = 1600
|
||
|
||
|
||
def test_instructions_fit_the_fold():
|
||
text = _instructions_text()
|
||
assert len(text) <= INSTRUCTIONS_BUDGET, (
|
||
f"_INSTRUCTIONS is {len(text)} chars; Claude Code injects only ~2,048 "
|
||
f"and silently cuts the rest. This block is an INDEX — state "
|
||
f"the topic in full on its owner (a skill or the tool's docstring) "
|
||
f"and give it at most a line here; see the comment "
|
||
f"above _INSTRUCTIONS."
|
||
)
|
||
|
||
|
||
# ── force: a surface that says rules bind must say what does not ────────
|
||
#
|
||
# With a preference kind beside rules, "rules bind" is half of the truth, and
|
||
# the missing half is the dangerous half to omit: a session reading
|
||
# only "rules bind" and then receiving a preference has been told, by the most
|
||
# authoritative surface it has, to treat it as binding.
|
||
#
|
||
# That failure is silent in both directions. Treating a preference as a rule
|
||
# produces a session that refuses to proceed over something the operator only
|
||
# preferred; and it removes the reason preferences exist, which is that they
|
||
# can be brought up to date rather than obeyed.
|
||
#
|
||
# Same bargain as every test in this file: STRUCTURE, not wording. A surface
|
||
# passes by mentioning the other kind at all, so the prose stays free.
|
||
#
|
||
# PINNED ON THE CLAIM, NOT THE WORD "bind". A bare substring also matches
|
||
# `bind_repo`, `list_repo_bindings` and the DNS-rebinding comment in
|
||
# server.py — so it would one day fail a skill that mentions repo binding and
|
||
# has nothing to do with force — a guard
|
||
# raising a false alarm about the very thing it protects. These phrases are
|
||
# the ones that actually assert bindingness to a reader.
|
||
BINDING_CLAIMS = (
|
||
"rules bind",
|
||
"binding rules",
|
||
"rules are binding",
|
||
"treat every one as binding",
|
||
)
|
||
OTHER_KIND = "preference"
|
||
|
||
|
||
def test_a_surface_claiming_rules_bind_also_names_what_does_not():
|
||
offenders = [
|
||
label for label, text in _all_surfaces()
|
||
if any(c in text.lower() for c in BINDING_CLAIMS)
|
||
and OTHER_KIND not in text.lower()
|
||
]
|
||
assert not offenders, (
|
||
f"these surfaces tell a session that rules bind and never mention "
|
||
f"preferences: {offenders}. A preference arrives through the same arms "
|
||
f"and renders in the same line shape, so a surface that describes only "
|
||
f"the binding kind is read as covering both — and the session treats "
|
||
f"'how the operator likes this done' as something it may not proceed "
|
||
f"past. Name the other kind, however briefly."
|
||
)
|
||
|
||
|
||
# ── the index names every strength, and who keeps the middle one current ──
|
||
#
|
||
# Milestones 385 and 399 both end here, and for the same reason. The index is
|
||
# the one guidance surface EVERY MCP client receives — a client that reads no
|
||
# Agent Skills still gets it — so a strength the index omits does not exist for
|
||
# that session. Before the lesson kind, "rules bind; preferences guide" was the
|
||
# whole corpus; a session that meets a third line shape with no name for it
|
||
# falls back on the nearest one it has, which is the binding one.
|
||
#
|
||
# The currency half is the central claim: a preference is the
|
||
# one record a session updates ITSELF, without asking. Stated only in the
|
||
# skill, it reaches a session that loaded the skill — which is exactly the
|
||
# session least likely to need telling. In the index it reaches everyone.
|
||
#
|
||
# Words, not wording: each entry is a family, and a rewrite satisfies it by
|
||
# saying the thing in any of these ways.
|
||
INDEX_STRENGTHS = {
|
||
"that rules bind": ("rules bind",),
|
||
"that preferences guide": ("preferences guide",),
|
||
"that lessons inform": ("lessons inform", "lessons are", "a lesson informs"),
|
||
"that a preference is kept current": ("current",),
|
||
}
|
||
|
||
|
||
def test_the_index_names_all_three_strengths():
|
||
text = " ".join(_instructions_text().split()).lower()
|
||
missing = [claim for claim, family in INDEX_STRENGTHS.items()
|
||
if not any(t in text for t in family)]
|
||
assert not missing, (
|
||
f"_INSTRUCTIONS no longer says {missing}. It is the only guidance every "
|
||
f"client receives, and a record kind it does not name is one the session "
|
||
f"has to guess the force of — which it does by reaching for the kind it "
|
||
f"does know. Keep it to a clause; the full statement lives on "
|
||
f"using-grimoire."
|
||
)
|
||
|
||
|
||
def test_the_strength_guard_can_fail():
|
||
"""The guard has to be able to bite, and it bites on omission —
|
||
the failure that actually happens, when a kind is added to the product and
|
||
the index is left describing the corpus that came before it."""
|
||
text = "rules bind; preferences guide and you keep them current."
|
||
missing = [claim for claim, family in INDEX_STRENGTHS.items()
|
||
if not any(t in text for t in family)]
|
||
assert missing == ["that lessons inform"]
|
||
|
||
|
||
# ── Reporting back ─────────────────────────────────────────────────────
|
||
#
|
||
# The reporting-back skill carries the shapes, but a skill only helps if it
|
||
# fires. The reflex that sends a session to it is stated in using-grimoire, and
|
||
# the in-band cue on update_task (`placement`, `report_back`) is the half that
|
||
# fires on its own in every client. The server's index carries no REPORT line:
|
||
# reporting is a mid-work stance, not a session-start practice.
|
||
|
||
REPORT_REFLEX = "report back in a shape the operator can read"
|
||
REPORT_SURFACES = (
|
||
ROOT / "plugin" / "skills" / "using-grimoire" / "SKILL.md",
|
||
)
|
||
|
||
|
||
def test_the_reporting_reflex_is_stated_in_using_grimoire():
|
||
missing = []
|
||
for path in REPORT_SURFACES:
|
||
text = " ".join(path.read_text().split()).lower()
|
||
if REPORT_REFLEX not in text or "reporting-back" not in text:
|
||
missing.append(str(path.relative_to(ROOT)))
|
||
assert not missing, (
|
||
f"these surfaces no longer send a session to the reporting-back skill "
|
||
f"({REPORT_REFLEX!r} plus the skill's name): {missing}. Without the "
|
||
f"reflex the skill loads only when its description happens to match."
|
||
)
|
||
|
||
|
||
def test_the_reporting_reflex_takes_placement_from_the_record():
|
||
"""A placement reconstructed from memory reads exactly like a real one
|
||
when it is wrong."""
|
||
for path in REPORT_SURFACES:
|
||
text = " ".join(path.read_text().split())
|
||
assert "`placement`" in text, f"{path.relative_to(ROOT)} no longer points at the placement block"
|