Files
FabledScribe/tests/test_guidance_ownership.py
T
bvandeusenandClaude Opus 5 0ed8e86cd5
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Failing after 10s
CI & Build / integration (push) Successful in 51s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / Python tests (push) Successful in 1m31s
CI & Build / Build & push image (push) Successful in 24s
feat(instructions): a rule proposal has five answers, and three of them route (#3733, #3896)
Step 6 of both milestone 385 (lessons) and 399 (preferences). #3896 asked for
the fourth and fifth answers 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.

create_rule now opens by asking what kind of thing is being held, with one
question that sorts it — what happens if someone doesn't do this? Something
breaks, a boundary is crossed: a rule. It gets done a way the operator didn't
want: a preference. They lose time rediscovering it: a lesson. The closing
question grew the two matching answers, and they are named as first-class
outcomes rather than places a proposal lands when it fails. An observation
that turns out to be a lesson has been routed, not dropped.

Stated as a practice, not a prohibition (rule 165). #3557's first cut opened
"NOT YOURS TO CALL UNPROMPTED" and cost the noticing; the wanted behaviour
here is still more proposals, and what changes is only which door they go
through.

create_note says the same from its side, so routing does not depend on having
opened create_rule first — and its existing rule test ("a mistake, not merely
uninformed") turned out to name the lesson exactly. create_lesson names the
fifth kind so the set is complete from every door. create_project_rule's
citation of the loop names five answers, since it cites rather than repeats.

The force axis has one owner (decision #4027): using-scribe states all three
strengths, the sorting question, that updating a preference mid-work is the
normal case, and that preferences shape how work is done and never what gets
recorded. _INSTRUCTIONS carries the pointer — "Rules bind; preferences guide
and you keep them current; lessons inform." It had 14 characters of headroom,
so the clause is paid for by trimming atmosphere from three other lines; 1998
of 2000 now.

Guards: the proposal-loop test learns the preference branch, the lesson
branch and the force question, on both rule surfaces; guidance-ownership gains
the force-axis topic (shared with the docstrings, for the moment a proposal is
actually written) and the preference-scope topic; a new guard pins that the
index names all three strengths and who keeps the middle one current, with its
can-fail case being the omission that actually happens — a kind added to the
product while the index still describes the corpus that came before it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
2026-09-19 12:12:14 -04:00

396 lines
21 KiB
Python

"""Every piece of agent guidance has exactly one owner that states it.
WHY THIS EXISTS (milestone 410, decision #4027)
Scribe's guidance to agents was written up to five times over: the MCP
`_INSTRUCTIONS`, the plugin's static session context, the live session context
the server builds, the `using-scribe` skill, and tool docstrings. An earlier
design (#2494) made that deliberate — insurance against any one surface failing
silently — and the copies drifted apart instead (#2497, #4022).
Decision #4027 replaced the redundancy with ownership: the server orients with
a short index, the skills hold the depth, tool docstrings hold each tool's
contract, and each client adapter holds only its own timing and conventions.
This module is the registry that decision is enforced from.
WHAT IT PINS
1. OWNERS STATE THEIR TOPIC. Every topic's markers and its distinctive
statement appear on its owner — so moving or trimming text cannot drop a
topic without failing here.
2. NOWHERE ELSE STATES IT IN FULL. The statement — a phrase distinctive to
the owner's full wording — must not appear on any other session surface
(the index, the adapter's static text and commands, the live context, the
other skills). A topic legitimately stated in two places at two different
moments declares that in `shared_with`, with the reason beside it.
3. THE INDEX NAMES THE SESSION-START REFLEXES. `_INSTRUCTIONS` is the one
surface every MCP client receives, so each reflex it indexes keeps its
`index` markers there — a one-line pointer, not a copy.
WHAT IT CANNOT SEE
A copy reworded so it no longer contains the statement phrase passes. The
guard catches the ordinary way duplication happens — pasting a paragraph
into a second surface — not a determined paraphrase. Tool docstrings are not
scanned for copies: a tool's contract may elaborate the reflex that calls it.
MARKERS AND STATEMENTS ARE PHRASES, NOT WORDS
Reword a topic deliberately and update its markers/statement in the same
commit; each failure message names the topic, the surface and the phrase.
"""
from __future__ import annotations
import pathlib
import re
from typing import NamedTuple
ROOT = pathlib.Path(__file__).resolve().parents[1]
def _norm(text: str) -> str:
# Whitespace-flattened and lowercased: prose is hard-wrapped, so a phrase
# can straddle a line break without the guidance having changed.
return " ".join(text.split()).lower()
def _live_session_context_source() -> str:
"""The source of build_session_context — the prose it emits lives there.
Read as text rather than called: the function needs a database, and what
this module checks is what the product SAYS, which is the literal strings.
"""
src = (ROOT / "src/scribe/services/plugin_context.py").read_text()
start = src.index("async def build_session_context")
nxt = re.search(r"\n(?:async def|def) ", src[start + 1:])
return src[start:start + 1 + nxt.start()] if nxt else src[start:]
def delivered_surfaces() -> dict[str, str]:
"""Every surface a session receives as guidance, by label.
The one definition of "delivered" for this module:
- `instructions` — the MCP server's `_INSTRUCTIONS` (every MCP client)
- `docstrings` — the MCP tool modules (tool descriptions, every client)
- `skill:<name>` — each bundled Agent Skill
- `static` — the Claude Code adapter's static session context
- `commands` — the Claude Code adapter's slash commands
- `live` — the live session context the server builds
"""
server = (ROOT / "src/scribe/mcp/server.py").read_text()
match = re.search(r'_INSTRUCTIONS = """(.*?)"""', server, re.S)
assert match, "server.py no longer defines _INSTRUCTIONS as a triple-quoted literal"
surfaces = {
"instructions": match.group(1),
"docstrings": "".join(p.read_text() for p in sorted((ROOT / "src/scribe/mcp/tools").glob("*.py"))),
"static": (ROOT / "plugin/hooks/scribe_static_context.md").read_text(),
"commands": "".join(p.read_text() for p in sorted((ROOT / "plugin/commands").glob("*.md"))),
"live": _live_session_context_source(),
}
for skill in sorted((ROOT / "plugin/skills").glob("*/SKILL.md")):
surfaces[f"skill:{skill.parent.name}"] = skill.read_text()
return {label: _norm(text) for label, text in surfaces.items()}
# Not scanned for copies (see the module docstring).
NOT_COPY_SCANNED = frozenset({"docstrings"})
class Topic(NamedTuple):
key: str
owner: str # a label from delivered_surfaces()
markers: tuple[str, ...] # what the topic is about; all on the owner
statement: str # distinctive to the owner's full wording
index: tuple[str, ...] = () # required in _INSTRUCTIONS, if it indexes this
shared_with: tuple[str, ...] = () # other surfaces allowed the statement, with a reason
U = "skill:using-scribe"
TOPICS: tuple[Topic, ...] = (
# ── the working reflexes — owned by the using-scribe skill ──
Topic("scribe is the system of record; keep one copy", U, ("one copy",),
"let any existing local memory shrink", index=("one copy",)),
Topic("orient: enter the project, check repo bindings", U, ("enter_project", "list_repo_bindings"),
"the milestones and open tasks worked on most recently",
index=("enter_project",)),
Topic("rules are retrieved; ask before a consequential act", U, ('content_type="rule"', "nothing matched"),
"an empty session is not evidence of an empty rulebook",
index=('content_type="rule"', "nothing matched")),
Topic("rules bind, preferences guide and are kept current", U, ("preference", "update_preference"),
"a preference is the one record you keep current yourself", index=("preferences guide",)),
Topic("kind is force: a rule binds, a preference guides, a lesson informs", U,
("lesson", "costs time", "costs consistency"),
"what happens if someone doesn't do this",
index=("lessons inform",),
# Stated twice on purpose, at the two moments it is needed: the
# skill while deciding what a record IS, and create_rule while a
# proposal is actually being written — which is where the
# mis-routing #3733 recorded happens, and the caller there has not
# necessarily read the skill.
shared_with=("docstrings",)),
Topic("preferences shape how work is done, never what is recorded", U,
("never what gets recorded",),
"a preference never makes a task into a note"),
Topic("recall before acting", U, ("recall before acting",), "for related prior work", index=("recall",)),
Topic("stay inside the active project's scope", U, ("stay inside the active project", "cross-project"),
"stay inside the active project's scope", index=("project_id",)),
Topic("record as you go; honest status; fixes are issues", U, ("add_task_log", "in_progress", 'kind="issue"'),
"fixes are issues, not work-logs", index=("add_task_log", 'kind="issue"')),
Topic("an id exists only once a create returns it", U, ("exists only once a create", "{{ref:"),
"exists only once a create call returns it", index=("create_records", "{{ref:n}}")),
Topic("tag records to systems as you write", U, ("system_ids", "create_system"),
"would someone investigating that subsystem want this record", index=("system_ids",)),
Topic("answer the systems_hint at the moment of work", U, ("systems_hint",),
"treat it as the tagging question asked at the moment of work"),
Topic("a retrieved rule outranks a default habit", U, ("outranks a default habit",),
"a retrieved rule outranks a default habit"),
Topic("log on completion and on a problem", U, ("hit or discover a problem",), "hit or discover a problem"),
Topic("the project's design system binds ui", U, ("resolve_design_system",),
"building ui: the project's design system binds", index=("resolve_design_system",)),
Topic("name the record, never just its number", U, ("name the record",),
"a bare id reads as complete to you and as homework to them"),
Topic("project inception is a decision", U, ("decide_project_inception",),
"starting a project: decide what it inherits", index=("decide_project_inception",)),
Topic("where a new rule goes, and its trigger", U, ("create_project_rule", "when_to_apply"),
"whichever home it gets"),
Topic("a rule vs the other entities", U, ("standing instruction",), "first ask whether it's a rule at all"),
# Milestone 416 step 9: the tuning tools shipped in #4102/#4104 and were
# named on NO instruction surface — measured, `retrieval_tuning_history`
# returned zero events. Machinery with no route to it.
Topic("a missed rule is a trigger to fix before a floor to move", U,
("retrieval_telemetry", "tune_retrieval", "retrieval_surfaces"),
"take it to the record first and the dial second",
index=("retrieval_telemetry",)),
Topic("ask what already covers a moment before writing a record", U,
("what_might_apply",), "ask what already covers that moment"),
Topic("reference notes update in place; dev-logs don't", U, ("reference note",),
"state updates in place; chronicles don't"),
# ── process arcs — owned by their skills ──
Topic("plan in a milestone, steps created together", "skill:writing-plans", ("start_planning", "{{ref:"),
"a milestone earns its place when the work has an arc", index=("start_planning",)),
# Milestone 415: sessions opened a second plan beside the roadmap milestone
# that already covered the work, because nothing told them to look.
Topic("find the existing plan before making one", "skill:writing-plans",
('content_type="milestone"', "unplanned_milestones", "existing_milestone"),
"when an active milestone already covers the work, the plan is that milestone",
index=('search(content_type="milestone")',)),
Topic("reuse recorded shapes; record at first build", "skill:reusing-code",
("create_snippet", "when_to_use", "first build", "second copy"),
"prior art offered beside a write is not noise", index=("create_snippet",)),
Topic("report back where the work stands", "skill:reporting-back", ("reporting-back", "placement"),
"take the placement from the record", index=("placement",)),
Topic("the operator's own reply shapes come first", "skill:reporting-back",
("reply_preferences", 'content_type="rule"'),
"the operator's own shapes come first"),
# Milestone 409 step 7: the sections were being FILLED rather than chosen,
# so a reply could satisfy every heading and still be unreadable. These
# three are the discipline around the scaffold, not the scaffold itself.
Topic("sections are chosen, not filled, and the reply is cut twice",
"skill:reporting-back", ("not a form to complete", "what can go"),
"write the shortest reply that carries the answer"),
Topic("a needs-you item is theirs to decide and blocks work",
"skill:reporting-back", ("is this theirs to decide",),
"this section is for what blocks them, not for what you are unsure about"),
Topic("a settled decision is acted on, not re-opened",
"skill:reporting-back", ("already made",),
"reads as contradicting yourself rather than as being careful"),
# Milestone 409 step 8: `placement` rides a WRITE, so a task the reply only
# cites arrived with nothing vouching for it — which is how a step finished
# four days earlier was reported as the open one (#4154).
Topic("a record you only cite still gets read",
"skill:reporting-back", ("next_step", "only mention"),
"a record you only mention is a record to read"),
# ── per-tool contracts and in-band behaviour — owned by the server ──
Topic("closing a task cues the report", "docstrings", ("report_back",), "reporting this to the operator?"),
Topic("a note that asserts a fact carries its check", "docstrings", ("verify_with", "expires_when"),
"could this note become false without anyone editing it",
# Stated at two different moments on purpose: the tool contract is
# read when the field is about to be filled, the skill while deciding
# what to write at all (test_verification_guidance_survives pins both).
shared_with=(U,)),
Topic("supersession demotes, never hides", "docstrings", ("supersedes",),
"it simply stops competing with this one"),
Topic("deletes are recoverable from the trash", "docstrings", ("deleted_batch_id",), "deleted_batch_id"),
Topic("creates are duplicate-gated", "docstrings", ("near-duplicate",), "bypass the near-duplicate gate",
index=("duplicate-gated",)),
Topic("shared records are another user's suggestion", "docstrings", ("shared: true",),
"belongs to another user", index=("shared:true",)),
Topic("stored processes are followed as written", "docstrings", ("get_process",), "follow the returned body"),
Topic("a project is never guessed", "docstrings", ("never guessing a project",), "never guessing a project"),
Topic("an unbound repo gets a bind hint", "live", ("bind_repo",), "isn't mapped to a scribe project"),
# ── the Claude Code adapter's own conventions ──
Topic("compact at clean seams", "static", ("/compact",), "compact at clean seams"),
Topic("stored processes sync into local skills", "commands", ("scribe-proc-",), "regenerate the local skill stubs"),
Topic("say so when scribe's tools are unavailable", "static", ("tools are unavailable",),
"rather than silently falling back to local notes"),
)
def owner_gaps(topics, surfaces: dict[str, str]) -> list[str]:
"""Topics whose owner no longer carries every marker and the statement."""
gaps = []
for t in topics:
text = surfaces.get(t.owner, "")
absent = [p for p in (*t.markers, t.statement) if p.lower() not in text]
if absent:
elsewhere = [label for label, other in surfaces.items() if t.statement.lower() in other]
gaps.append(f"{t.key!r} on {t.owner}: missing {absent}; statement found on {elsewhere or 'nothing'}")
return gaps
def copies(topics, surfaces: dict[str, str]) -> list[str]:
"""Topics whose full statement also appears on a surface that doesn't own it."""
found = []
for t in topics:
allowed = {t.owner, *t.shared_with}
extra = [label for label, text in surfaces.items()
if label not in NOT_COPY_SCANNED and label not in allowed and t.statement.lower() in text]
if extra:
found.append(f"{t.key!r} (owner {t.owner}) is also stated on {extra}")
return found
def index_gaps(topics, surfaces: dict[str, str]) -> list[str]:
text = surfaces.get("instructions", "")
return [f"{t.key!r}: {[m for m in t.index if m.lower() not in text]}"
for t in topics if any(m.lower() not in text for m in t.index)]
def test_every_topic_is_stated_by_its_owner():
gaps = owner_gaps(TOPICS, delivered_surfaces())
assert not gaps, (
"these topics are no longer stated in full by their owner:\n "
+ "\n ".join(gaps) + "\nEvery guidance topic has one owner (decision "
"#4027). If the text moved on purpose, move the topic's owner here in the "
"same commit; if it was reworded, update its markers and statement."
)
def test_no_topic_is_stated_in_full_off_its_owner():
found = copies(TOPICS, delivered_surfaces())
assert not found, (
"guidance stated in full on a surface that doesn't own it:\n "
+ "\n ".join(found) + "\nOne owner per topic (decision #4027): replace "
"the copy with a one-line pointer to the owner. If both places genuinely "
"need it at different moments, declare it in `shared_with` with the reason."
)
def test_the_index_names_each_reflex_it_points_at():
gaps = index_gaps(TOPICS, delivered_surfaces())
assert not gaps, (
f"_INSTRUCTIONS no longer carries the index line for: {gaps}. It is the "
f"one guidance surface every MCP client receives; keep a one-line pointer "
f"per session-start reflex (see the comment above _INSTRUCTIONS)."
)
def test_every_owner_and_sharer_is_a_surface_that_exists():
labels = set(delivered_surfaces())
unknown = [(t.key, s) for t in TOPICS for s in (t.owner, *t.shared_with) if s not in labels]
assert not unknown, f"owners or sharers that name no delivered surface: {unknown}"
def test_topic_keys_are_unique():
keys = [t.key for t in TOPICS]
assert len(keys) == len(set(keys))
def test_the_ownership_guards_can_fail():
"""Rule 167: each guard is shown turning red once."""
surfaces = {
"instructions": "use the widget",
"docstrings": "the widget owner statement lives here",
"skill:a": "widget owner statement lives here, and widget tool",
"skill:b": "a pasted copy: widget owner statement lives here",
"static": "",
}
topic = Topic("widget", "skill:a", ("widget tool",), "widget owner statement lives here",
index=("use the widget", "not in the index"))
moved = topic._replace(owner="static")
assert owner_gaps((topic,), surfaces) == []
assert owner_gaps((moved,), surfaces) and "missing" in owner_gaps((moved,), surfaces)[0]
# skill:b pasted it; docstrings are not scanned for copies.
assert copies((topic,), surfaces) == ["'widget' (owner skill:a) is also stated on ['skill:b']"]
assert copies((topic._replace(shared_with=("skill:b",)),), surfaces) == []
assert index_gaps((topic,), surfaces) == ["'widget': ['not in the index']"]
# ── The skills are client-neutral (milestone 410 step 2) ────────────────
#
# Agent Skills is an open format read by dozens of clients (#4023), so the
# skills folder is the part of every client package that is shared verbatim.
# Anything only one client understands belongs in that client's adapter —
# for Claude Code, the plugin's static context, hooks and commands — or the
# skill reads as nonsense everywhere else. Each marker below names a thing
# exactly one client has:
# - "claude", "~/.claude", ".claude/" the client itself and its paths
# - "claude.md", "memory.md", "auto-memory", "automemoryenabled"
# Claude Code's local memory files/setting
# - "/compact", "/scribe:" Claude Code slash commands
# - "sessionstart", "userpromptsubmit", "pretooluse", "posttooluse"
# Claude Code hook event names
# - "write/edit" Claude Code's editor tool names
# - "claude_plugin_root" the Claude Code plugin root variable
# `Bash` is matched case-sensitively as a word: it is Claude Code's shell tool
# name, while "bash" in prose is just the shell.
CLIENT_SPECIFIC = (
"claude", "~/.claude", ".claude/", "claude.md", "memory.md", "auto-memory",
"automemoryenabled", "/compact", "/scribe:", "sessionstart", "userpromptsubmit",
"pretooluse", "posttooluse", "write/edit", "claude_plugin_root",
)
CLIENT_TOOL_NAME = re.compile(r"\bBash\b")
def client_specific_hits(text: str) -> list[str]:
hits = [m for m in CLIENT_SPECIFIC if m in text.lower()]
if CLIENT_TOOL_NAME.search(text):
hits.append("Bash")
return hits
def test_the_skills_name_no_particular_client():
offenders = {
str(p.relative_to(ROOT)): client_specific_hits(p.read_text())
for p in sorted((ROOT / "plugin/skills").glob("*/SKILL.md"))
}
offenders = {path: hits for path, hits in offenders.items() if hits}
assert not offenders, (
f"skills that name one client: {offenders}. Skills are shared by every "
f"client package (decision #4027); say the universal thing in the skill "
f"and put the client's own name for it in that client's adapter."
)
def test_the_client_guard_can_fail():
assert client_specific_hits("keep a copy in CLAUDE.md") == ["claude", "claude.md"]
assert client_specific_hits("edits made through Bash") == ["Bash"]
assert client_specific_hits("edits made through a bash shell") == []
# A skill is shipped verbatim inside every client's package, at whatever path
# that client installs skills to. A reference from a skill to anything outside
# its own folder — the adapter's hooks, a manifest, a relative path upward —
# points at a file that exists in one package layout and nowhere else
# (plugin/PACKAGING.md).
OUTSIDE_THE_SKILL = re.compile(r"\.\./|plugin/|hooks/|commands/|\.claude-plugin|\bplugin\.json\b|\bhooks\.json\b")
def test_the_skills_reference_nothing_outside_their_folder():
offenders = {
str(p.relative_to(ROOT)): sorted(set(OUTSIDE_THE_SKILL.findall(p.read_text())))
for p in sorted((ROOT / "plugin/skills").glob("*/SKILL.md"))
}
offenders = {path: refs for path, refs in offenders.items() if refs}
assert not offenders, (
f"skills that reference files outside their own folder: {offenders}. A "
f"skill ships verbatim in every client package; see plugin/PACKAGING.md."
)
def test_the_layout_guard_can_fail():
assert OUTSIDE_THE_SKILL.findall("run ../hooks/sync.sh") == ["../", "hooks/"]
assert OUTSIDE_THE_SKILL.findall("see plugin.json") == ["plugin.json"]
assert OUTSIDE_THE_SKILL.findall("record the plugin's behaviour") == []