Files
FabledScribe/tests/test_guidance_ownership.py
T
bvandeusenandClaude Opus 5 fb36599f2d
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 57s
CI & Build / TypeScript typecheck (push) Successful in 1m15s
CI & Build / Python tests (push) Successful in 1m46s
CI & Build / Build & push image (push) Successful in 35s
docs(plugin): find the existing plan before making one, and file related work into it (#4080)
Step 5 of milestone 415 "An existing plan is found before a new one is made".
Sessions opened a second milestone beside the roadmap milestone that already
covered the work, and filed related tasks loose, because no surface told them
to look first.

- writing-plans: a section on finding the plan that exists (enter_project's
  unplanned_milestones, search(content_type="milestone"), list_milestones);
  when an active milestone covers the work, add steps to it; a second
  milestone only for a separate arc; the gate's existing_milestone reply.
- using-scribe: "when you plan" gains the same check and milestone_id on
  related tasks.
- _INSTRUCTIONS PLAN line points at the milestone search (1,689 of 2,000).
- test_guidance_ownership pins the topic on writing-plans.
- Plugin version minted: 2026.09.15.1744.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
2026-09-15 13:45:52 -04:00

356 lines
19 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("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"),
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"),
# ── 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") == []