Files
FabledScribe/tests/test_instruction_surfaces_agree.py
T
bvandeusenandClaude Opus 5 c440c49f5b
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 56s
CI & Build / Python tests (push) Successful in 1m30s
CI & Build / Build & push image (push) Successful in 15s
test(410): an exactly-one-owner guard replaces the tests that required every surface to repeat itself (#4033)
Step 6 of milestone 410 "One owner per piece of guidance". CI now keeps the
shape decision #4027 set, so the next feature cannot quietly add a copy.

tests/test_guidance_ownership.py, 33 topics, each with an owner, markers, a
statement distinctive to the owner's full wording, and optional index markers:
- test_every_topic_is_stated_by_its_owner: markers and statement on the owner
- test_no_topic_is_stated_in_full_off_its_owner: the statement appears on no
  other session surface (index, adapter static text and commands, live
  context, other skills). Tool docstrings are not scanned; a contract may
  elaborate the reflex that calls it.
- test_the_index_names_each_reflex_it_points_at: _INSTRUCTIONS keeps a
  one-line pointer for each session-start reflex
- shared_with declares the one deliberate sharing: the note-check question
  lives in create_note and in using-scribe for two different moments,
  already pinned by test_verification_guidance_survives
- test_the_ownership_guards_can_fail shows each guard turning red (rule 167)
- the process topic now keys on get_process's "follow the returned body";
  it had been passing on an unrelated "verbatim" in two other tools

tests/test_instruction_surfaces_agree.py keeps only what ownership cannot
enforce: the fold budget, rules-bind-names-preferences, and using-scribe's
pointer to reporting-back. Retired: the every-session-start-surface ask and
absence tests, the Systems and snippet owner pins (now registry topics), and
the SessionStart-without-ask test (#2497's shape). The push has carried no
rules since milestone 394, and requiring the ask beside every mention of it
would force a copy. The module docstring records where each protection went.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 13:29:34 -04:00

167 lines
7.7 KiB
Python

"""What the instruction surfaces must say that one owner per topic cannot enforce.
WHY THIS FILE IS SMALLER THAN IT WAS
Until milestone 410 this file pinned REDUNDANCY: every session-start surface —
the MCP `_INSTRUCTIONS`, the plugin's static context, the `using-scribe` skill —
had to restate how a rule reaches a session, what an empty session means, the
Systems reflex and the snippet-recording triggers. That was the design #2494
chose after #2497 (two surfaces disagreeing about who loads the rules) and
#2198 (every hook silently inert): say it everywhere, so no single failure
loses it.
Decision #4027 replaced that with ownership, and tests/test_guidance_ownership.py
now enforces it: every topic stated in full by exactly one owner, with a
one-line pointer in the server's index. The tests that required every surface
to repeat itself were retired there, because they enforced the very duplication
that drifted (#4022). What they protected is still protected:
- "state how to ask for a rule" / "say an empty session is not an empty
rulebook" → the rules topic: owned by using-scribe, indexed in
`_INSTRUCTIONS` with both phrases;
- the Systems reflex and the snippet-recording triggers → their topics'
markers, required on their owners;
- "never name the SessionStart push without stating the ask" (#2497's exact
shape) → retired outright. Since milestone 394 the push carries no rules,
so naming it can no longer imply rules were handled, and requiring every
surface that mentions it to restate the ask would force a copy.
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-scribe
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), 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("SKILL.md")]
found += [(str(p.relative_to(ROOT)), p.read_text())
for p in (ROOT / "plugin" / "hooks").glob("*.md")]
server = ROOT / "src" / "scribe" / "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" / "scribe" / "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 (#2562: observed live —
# the previous 20k-char version delivered ~10% of itself, and none of the
# Systems tagging guidance ever reached a session). 2,000 leaves margin.
INSTRUCTIONS_BUDGET = 2000
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 (#2562). This block is an INDEX — state "
f"the topic in full on its owner (a skill or the tool's docstring, "
f"decision #4027) 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 ────────
#
# Added with the preference kind (milestone 399). Before it, "rules bind" was
# the whole truth and every surface said so flatly. It is now half of one, and
# the half that is missing 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, which is rule 167's named failure: 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."
)
# ── Reporting back (milestone 409 step 3) ──────────────────────────────
#
# 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-scribe; the
# server's index carries a REPORT line pointing at `placement`, and the in-band
# cue on update_task is the half that fires on its own in every client. The
# static context was a second plugin-side copy until milestone 410.
REPORT_REFLEX = "report back in a shape the operator can read"
REPORT_SURFACES = (
ROOT / "plugin" / "skills" / "using-scribe" / "SKILL.md",
)
def test_the_reporting_reflex_is_stated_in_using_scribe():
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():
"""The failure milestone 409 began from: 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"