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
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>
347 lines
18 KiB
Python
347 lines
18 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"),
|
|
"returns the project plus the rules bound to the areas it works in",
|
|
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",)),
|
|
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",)),
|
|
# ── 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") == []
|