test(410): an exactly-one-owner guard replaces the tests that required every surface to repeat itself (#4033)
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
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>
This commit is contained in:
+176
-109
@@ -1,4 +1,4 @@
|
|||||||
"""Every piece of agent guidance has one owner — and none of it is lost on the way there.
|
"""Every piece of agent guidance has exactly one owner that states it.
|
||||||
|
|
||||||
WHY THIS EXISTS (milestone 410, decision #4027)
|
WHY THIS EXISTS (milestone 410, decision #4027)
|
||||||
|
|
||||||
@@ -8,29 +8,36 @@ the server builds, the `using-scribe` skill, and tool docstrings. An earlier
|
|||||||
design (#2494) made that deliberate — insurance against any one surface failing
|
design (#2494) made that deliberate — insurance against any one surface failing
|
||||||
silently — and the copies drifted apart instead (#2497, #4022).
|
silently — and the copies drifted apart instead (#2497, #4022).
|
||||||
|
|
||||||
Decision #4027 replaced the redundancy with ownership: the server orients, the
|
Decision #4027 replaced the redundancy with ownership: the server orients with
|
||||||
skills hold the depth, and each client adapter holds only its own timing and
|
a short index, the skills hold the depth, tool docstrings hold each tool's
|
||||||
conventions. This module is the registry that decision is enforced from.
|
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 NOW, AND WHAT COMES LATER
|
WHAT IT PINS
|
||||||
|
|
||||||
Step 1 — the LOSS GUARD. Every topic below must still be stated on at least one
|
1. OWNERS STATE THEIR TOPIC. Every topic's markers and its distinctive
|
||||||
surface a session actually receives. Milestone 410's later steps delete copies;
|
statement appear on its owner — so moving or trimming text cannot drop a
|
||||||
this is what stops the last copy of a topic going with them.
|
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.
|
||||||
|
|
||||||
Step 6 will add the OWNERSHIP guard: a topic's full statement on its `owner`
|
WHAT IT CANNOT SEE
|
||||||
and nowhere else. The `owner` column is recorded here from the start so the
|
|
||||||
registry is written once, but nothing asserts it yet — during the moves a
|
|
||||||
topic is legitimately in several places at once.
|
|
||||||
|
|
||||||
MARKERS ARE PHRASES, NOT WORDS
|
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.
|
||||||
|
|
||||||
A topic is "stated" when ALL of its markers appear on one surface — so a
|
MARKERS AND STATEMENTS ARE PHRASES, NOT WORDS
|
||||||
topic's markers must travel together, and a lone common word never counts
|
|
||||||
(BINDING_CLAIMS in test_instruction_surfaces_agree explains the false alarm a
|
Reword a topic deliberately and update its markers/statement in the same
|
||||||
bare word raises). Tool names are the preferred marker: they change only when
|
commit; each failure message names the topic, the surface and the phrase.
|
||||||
the tool does. Reword a topic deliberately and update its markers in the same
|
|
||||||
commit; the failure message names which marker went missing where.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -42,7 +49,7 @@ ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|||||||
|
|
||||||
|
|
||||||
def _norm(text: str) -> str:
|
def _norm(text: str) -> str:
|
||||||
# Whitespace-flattened and lowercased: prose is hard-wrapped, so a marker
|
# Whitespace-flattened and lowercased: prose is hard-wrapped, so a phrase
|
||||||
# can straddle a line break without the guidance having changed.
|
# can straddle a line break without the guidance having changed.
|
||||||
return " ".join(text.split()).lower()
|
return " ".join(text.split()).lower()
|
||||||
|
|
||||||
@@ -62,7 +69,7 @@ def _live_session_context_source() -> str:
|
|||||||
def delivered_surfaces() -> dict[str, str]:
|
def delivered_surfaces() -> dict[str, str]:
|
||||||
"""Every surface a session receives as guidance, by label.
|
"""Every surface a session receives as guidance, by label.
|
||||||
|
|
||||||
The one definition of "delivered" for this module and its successors:
|
The one definition of "delivered" for this module:
|
||||||
- `instructions` — the MCP server's `_INSTRUCTIONS` (every MCP client)
|
- `instructions` — the MCP server's `_INSTRUCTIONS` (every MCP client)
|
||||||
- `docstrings` — the MCP tool modules (tool descriptions, every client)
|
- `docstrings` — the MCP tool modules (tool descriptions, every client)
|
||||||
- `skill:<name>` — each bundled Agent Skill
|
- `skill:<name>` — each bundled Agent Skill
|
||||||
@@ -85,99 +92,155 @@ def delivered_surfaces() -> dict[str, str]:
|
|||||||
return {label: _norm(text) for label, text in surfaces.items()}
|
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):
|
class Topic(NamedTuple):
|
||||||
key: str
|
key: str
|
||||||
owner: str # a label from delivered_surfaces(); asserted from step 6
|
owner: str # a label from delivered_surfaces()
|
||||||
markers: tuple[str, ...]
|
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
|
||||||
|
|
||||||
|
|
||||||
# The ownership map from milestone 410's body, one row per topic, plus the
|
U = "skill:using-scribe"
|
||||||
# topics once guarded as "displaced from _INSTRUCTIONS" (#2562), folded in so
|
|
||||||
# there is one list. Retired topics (the surface-precedence tiebreaker) are
|
|
||||||
# absent on purpose: nothing has to keep saying them.
|
|
||||||
TOPICS: tuple[Topic, ...] = (
|
TOPICS: tuple[Topic, ...] = (
|
||||||
# ── the working reflexes — owned by the using-scribe skill ──
|
# ── the working reflexes — owned by the using-scribe skill ──
|
||||||
Topic("scribe is the system of record; keep one copy", "skill:using-scribe", ("one copy",)),
|
Topic("scribe is the system of record; keep one copy", U, ("one copy",),
|
||||||
Topic("orient: enter the project, check repo bindings", "skill:using-scribe",
|
"let any existing local memory shrink", index=("one copy",)),
|
||||||
("enter_project", "list_repo_bindings")),
|
Topic("orient: enter the project, check repo bindings", U, ("enter_project", "list_repo_bindings"),
|
||||||
Topic("rules are retrieved; ask before a consequential act", "skill:using-scribe",
|
"returns the project plus the rules bound to the areas it works in",
|
||||||
('content_type="rule"', "nothing matched")),
|
index=("enter_project",)),
|
||||||
Topic("rules bind, preferences guide and are kept current", "skill:using-scribe",
|
Topic("rules are retrieved; ask before a consequential act", U, ('content_type="rule"', "nothing matched"),
|
||||||
("preference", "update_preference")),
|
"an empty session is not evidence of an empty rulebook",
|
||||||
Topic("recall before acting", "skill:using-scribe", ("recall before acting",)),
|
index=('content_type="rule"', "nothing matched")),
|
||||||
Topic("stay inside the active project's scope", "skill:using-scribe",
|
Topic("rules bind, preferences guide and are kept current", U, ("preference", "update_preference"),
|
||||||
("stay inside the active project", "cross-project")),
|
"a preference is the one record you keep current yourself", index=("preferences guide",)),
|
||||||
Topic("record as you go; honest status; fixes are issues", "skill:using-scribe",
|
Topic("recall before acting", U, ("recall before acting",), "for related prior work", index=("recall",)),
|
||||||
("add_task_log", "in_progress", 'kind="issue"')),
|
Topic("stay inside the active project's scope", U, ("stay inside the active project", "cross-project"),
|
||||||
Topic("an id exists only once a create returns it", "skill:using-scribe",
|
"stay inside the active project's scope", index=("project_id",)),
|
||||||
("exists only once a create", "{{ref:")),
|
Topic("record as you go; honest status; fixes are issues", U, ("add_task_log", "in_progress", 'kind="issue"'),
|
||||||
Topic("tag records to systems as you write", "skill:using-scribe", ("system_ids", "create_system")),
|
"fixes are issues, not work-logs", index=("add_task_log", 'kind="issue"')),
|
||||||
Topic("answer the systems_hint at the moment of work", "skill:using-scribe", ("systems_hint",)),
|
Topic("an id exists only once a create returns it", U, ("exists only once a create", "{{ref:"),
|
||||||
Topic("a retrieved rule outranks a default habit", "skill:using-scribe", ("outranks a default habit",)),
|
"exists only once a create call returns it", index=("create_records", "{{ref:n}}")),
|
||||||
Topic("log on completion and on a problem", "skill:using-scribe", ("hit or discover a problem",)),
|
Topic("tag records to systems as you write", U, ("system_ids", "create_system"),
|
||||||
Topic("the project's design system binds ui", "skill:using-scribe", ("resolve_design_system",)),
|
"would someone investigating that subsystem want this record", index=("system_ids",)),
|
||||||
Topic("name the record, never just its number", "skill:using-scribe", ("name the record",)),
|
Topic("answer the systems_hint at the moment of work", U, ("systems_hint",),
|
||||||
Topic("project inception is a decision", "skill:using-scribe", ("decide_project_inception",)),
|
"treat it as the tagging question asked at the moment of work"),
|
||||||
Topic("where a new rule goes, and its trigger", "skill:using-scribe",
|
Topic("a retrieved rule outranks a default habit", U, ("outranks a default habit",),
|
||||||
("create_project_rule", "when_to_apply")),
|
"a retrieved rule outranks a default habit"),
|
||||||
Topic("a rule vs the other entities", "skill:using-scribe", ("standing instruction",)),
|
Topic("log on completion and on a problem", U, ("hit or discover a problem",), "hit or discover a problem"),
|
||||||
Topic("reference notes update in place; dev-logs don't", "skill:using-scribe", ("reference note",)),
|
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 ──
|
# ── process arcs — owned by their skills ──
|
||||||
Topic("plan in a milestone, steps created together", "skill:writing-plans", ("start_planning", "{{ref:")),
|
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",
|
Topic("reuse recorded shapes; record at first build", "skill:reusing-code",
|
||||||
("create_snippet", "when_to_use", "first build", "second copy")),
|
("create_snippet", "when_to_use", "first build", "second copy"),
|
||||||
Topic("report back where the work stands", "skill:reporting-back", ("reporting-back", "placement")),
|
"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 ──
|
# ── per-tool contracts and in-band behaviour — owned by the server ──
|
||||||
Topic("closing a task cues the report", "docstrings", ("report_back",)),
|
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")),
|
Topic("a note that asserts a fact carries its check", "docstrings", ("verify_with", "expires_when"),
|
||||||
Topic("supersession demotes, never hides", "docstrings", ("supersedes",)),
|
"could this note become false without anyone editing it",
|
||||||
Topic("deletes are recoverable from the trash", "docstrings", ("deleted_batch_id",)),
|
# Stated at two different moments on purpose: the tool contract is
|
||||||
Topic("creates are duplicate-gated", "docstrings", ("near-duplicate",)),
|
# read when the field is about to be filled, the skill while deciding
|
||||||
Topic("shared records are another user's suggestion", "docstrings", ("shared: true",)),
|
# what to write at all (test_verification_guidance_survives pins both).
|
||||||
Topic("stored processes are followed verbatim", "docstrings", ("stored processes", "verbatim")),
|
shared_with=(U,)),
|
||||||
Topic("a project is never guessed", "docstrings", ("never guessing a project",)),
|
Topic("supersession demotes, never hides", "docstrings", ("supersedes",),
|
||||||
Topic("an unbound repo gets a bind hint", "live", ("bind_repo",)),
|
"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 ──
|
# ── the Claude Code adapter's own conventions ──
|
||||||
Topic("compact at clean seams", "static", ("/compact",)),
|
Topic("compact at clean seams", "static", ("/compact",), "compact at clean seams"),
|
||||||
Topic("stored processes sync into local skills", "commands", ("scribe-proc-",)),
|
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",)),
|
Topic("say so when scribe's tools are unavailable", "static", ("tools are unavailable",),
|
||||||
|
"rather than silently falling back to local notes"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def missing_topics(topics, surfaces: dict[str, str]) -> list[str]:
|
def owner_gaps(topics, surfaces: dict[str, str]) -> list[str]:
|
||||||
"""Topics no single surface states in full — with the nearest miss named.
|
"""Topics whose owner no longer carries every marker and the statement."""
|
||||||
|
gaps = []
|
||||||
Pure, so the guard's ability to fail is itself testable.
|
for t in topics:
|
||||||
"""
|
text = surfaces.get(t.owner, "")
|
||||||
missing = []
|
absent = [p for p in (*t.markers, t.statement) if p.lower() not in text]
|
||||||
for topic in topics:
|
if absent:
|
||||||
markers = [m.lower() for m in topic.markers]
|
elsewhere = [label for label, other in surfaces.items() if t.statement.lower() in other]
|
||||||
if any(all(m in text for m in markers) for text in surfaces.values()):
|
gaps.append(f"{t.key!r} on {t.owner}: missing {absent}; statement found on {elsewhere or 'nothing'}")
|
||||||
continue
|
return gaps
|
||||||
partial = {
|
|
||||||
label: [m for m in markers if m not in text]
|
|
||||||
for label, text in surfaces.items()
|
|
||||||
if any(m in text for m in markers)
|
|
||||||
}
|
|
||||||
missing.append(f"{topic.key!r} — markers {topic.markers}; nearest: {partial or 'nowhere'}")
|
|
||||||
return missing
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_guidance_topic_has_fallen_off_every_surface():
|
def copies(topics, surfaces: dict[str, str]) -> list[str]:
|
||||||
missing = missing_topics(TOPICS, delivered_surfaces())
|
"""Topics whose full statement also appears on a surface that doesn't own it."""
|
||||||
assert not missing, (
|
found = []
|
||||||
"these guidance topics are no longer stated in full on ANY delivered "
|
for t in topics:
|
||||||
"surface:\n " + "\n ".join(missing) + "\nMilestone 410 moves guidance "
|
allowed = {t.owner, *t.shared_with}
|
||||||
"to one owner per topic (decision #4027); a move that deletes a copy "
|
extra = [label for label, text in surfaces.items()
|
||||||
"must leave the topic stated on its owner. If the topic was reworded on "
|
if label not in NOT_COPY_SCANNED and label not in allowed and t.statement.lower() in text]
|
||||||
"purpose, update its markers here in the same commit."
|
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_every_owner_is_a_surface_that_exists():
|
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())
|
labels = set(delivered_surfaces())
|
||||||
unknown = [(t.key, t.owner) for t in TOPICS if t.owner not in labels]
|
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 that name no delivered surface: {unknown}"
|
assert not unknown, f"owners or sharers that name no delivered surface: {unknown}"
|
||||||
|
|
||||||
|
|
||||||
def test_topic_keys_are_unique():
|
def test_topic_keys_are_unique():
|
||||||
@@ -185,20 +248,24 @@ def test_topic_keys_are_unique():
|
|||||||
assert len(keys) == len(set(keys))
|
assert len(keys) == len(set(keys))
|
||||||
|
|
||||||
|
|
||||||
def test_the_loss_guard_can_fail():
|
def test_the_ownership_guards_can_fail():
|
||||||
"""Rule 167: a guard that cannot fail protects nothing.
|
"""Rule 167: each guard is shown turning red once."""
|
||||||
|
surfaces = {
|
||||||
A topic whose markers are split across two surfaces is NOT stated — the
|
"instructions": "use the widget",
|
||||||
phrases have to travel together — and one whose marker is nowhere is
|
"docstrings": "the widget owner statement lives here",
|
||||||
reported with 'nowhere'.
|
"skill:a": "widget owner statement lives here, and widget tool",
|
||||||
"""
|
"skill:b": "a pasted copy: widget owner statement lives here",
|
||||||
surfaces = {"a": "enter_project here", "b": "list_repo_bindings there"}
|
"static": "",
|
||||||
split = Topic("split", "a", ("enter_project", "list_repo_bindings"))
|
}
|
||||||
absent = Topic("absent", "a", ("no such phrase",))
|
topic = Topic("widget", "skill:a", ("widget tool",), "widget owner statement lives here",
|
||||||
whole = Topic("whole", "a", ("enter_project",))
|
index=("use the widget", "not in the index"))
|
||||||
reported = missing_topics((split, absent, whole), surfaces)
|
moved = topic._replace(owner="static")
|
||||||
assert len(reported) == 2
|
assert owner_gaps((topic,), surfaces) == []
|
||||||
assert reported[0].startswith("'split'") and "nowhere" in reported[1]
|
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) ────────────────
|
# ── The skills are client-neutral (milestone 410 step 2) ────────────────
|
||||||
|
|||||||
@@ -1,39 +1,35 @@
|
|||||||
"""The instruction surfaces must agree on how a rule reaches a session.
|
"""What the instruction surfaces must say that one owner per topic cannot enforce.
|
||||||
|
|
||||||
WHY THIS EXISTS
|
WHY THIS FILE IS SMALLER THAN IT WAS
|
||||||
|
|
||||||
Rule #119 makes the instruction surfaces the SPECIFICATION for product
|
Until milestone 410 this file pinned REDUNDANCY: every session-start surface —
|
||||||
behaviour — there is no other place the "load the operator's rules" obligation
|
the MCP `_INSTRUCTIONS`, the plugin's static context, the `using-scribe` skill —
|
||||||
is written down, and no code path enforces it. So a surface that states it
|
had to restate how a rule reaches a session, what an empty session means, the
|
||||||
differently isn't a documentation slip; it is the product behaving differently.
|
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.
|
||||||
|
|
||||||
That happened (#2497). `_INSTRUCTIONS` said the SessionStart hook "is the
|
Decision #4027 replaced that with ownership, and tests/test_guidance_ownership.py
|
||||||
bridge" for getting rules into a session, while the `using-scribe` skill said to
|
now enforces it: every topic stated in full by exactly one owner, with a
|
||||||
pull them yourself and treat any push as a bonus. An agent weighting the first
|
one-line pointer in the server's index. The tests that required every surface
|
||||||
would reasonably skip the pull.
|
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.
|
||||||
|
|
||||||
#2198 is the case where that is wrong: every plugin hook was silently inert for
|
WHAT STAYS HERE
|
||||||
an extended period, and nothing announced it. An agent trusting the push would
|
|
||||||
have run with no binding rules and no signal — while those rules govern branch,
|
|
||||||
commit, push and other hard-to-reverse actions.
|
|
||||||
|
|
||||||
The asymmetry is the whole argument, and it is what these tests pin: asking
|
Properties of a CLAIM rather than of who owns a topic: the index fits the
|
||||||
when a rule had already arrived costs one redundant call; not asking when
|
fold; a surface that says rules bind also names what does not; using-scribe
|
||||||
nothing arrived costs the operator's rules entirely.
|
still sends a session to the reporting-back skill.
|
||||||
|
|
||||||
MILESTONE 394 SHARPENED IT RATHER THAN RETIRING IT. There is no longer a
|
|
||||||
resident set to pull, so "no rule in front of me" went from a rare and
|
|
||||||
suspicious state to the ordinary state of most turns. The instruction that
|
|
||||||
used to be supplementary — go and ask — is now the only route a rule has, and
|
|
||||||
the surfaces must additionally say what an EMPTY session means, or a session
|
|
||||||
reads silence as permission on nearly every turn.
|
|
||||||
|
|
||||||
WHAT THIS DOES NOT DO
|
|
||||||
|
|
||||||
It cannot tell whether two surfaces contradict each other in prose generally —
|
|
||||||
that needs a reader. It pins the instructions whose absence is known to be
|
|
||||||
load-bearing, and the specific shape the #2497 defect took: naming the push
|
|
||||||
without also stating how to ask.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -41,50 +37,6 @@ import pathlib
|
|||||||
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
# THE PULL IS NOW THE ASK (milestone 394). This was `list_always_on_rules`,
|
|
||||||
# the call that fetched the resident set. There is no resident set and no such
|
|
||||||
# call: a rule reaches a session by retrieval, and the only thing a session can
|
|
||||||
# DO about a rule it has not been handed is go looking for one.
|
|
||||||
#
|
|
||||||
# So the two halves this file used to pin separately — "pull the resident set"
|
|
||||||
# and "and retrieve the conditional ones too" — have collapsed into one
|
|
||||||
# instruction, and it is the load-bearing one rather than the supplementary
|
|
||||||
# one it used to be.
|
|
||||||
ASK = 'content_type="rule"'
|
|
||||||
|
|
||||||
# A surface must also say what an EMPTY session means, which is the half that
|
|
||||||
# is newly dangerous. Under residency, "no rule in front of me" was rare and
|
|
||||||
# suspicious. Under retrieval it is the ordinary state of most turns, so a
|
|
||||||
# session that reads it as "there is no rule" is wrong on nearly every turn
|
|
||||||
# rather than occasionally — the #3720 defect at session scale.
|
|
||||||
#
|
|
||||||
# Claim phrases, not a single word, for the reason BINDING_CLAIMS gives below:
|
|
||||||
# a bare "matched" or "silence" appears in prose that is not making this claim
|
|
||||||
# at all. A surface passes by asserting the distinction however it words it.
|
|
||||||
ABSENCE_CLAIMS = (
|
|
||||||
"nothing matched",
|
|
||||||
"is not the same as \"there is no rule",
|
|
||||||
"never \"there is no rule",
|
|
||||||
"silence is not absence",
|
|
||||||
"not evidence there is none",
|
|
||||||
)
|
|
||||||
|
|
||||||
# The surfaces that STATE the rules reflex. Hand-written because "who owns
|
|
||||||
# this?" is an editorial fact, not a derivable one — but each entry is asserted
|
|
||||||
# to EXIST, so a move or rename fails loudly here instead of quietly dropping
|
|
||||||
# that surface from the check.
|
|
||||||
#
|
|
||||||
# Since milestone 410 (decision #4027) that is the owner and the index, not
|
|
||||||
# every surface a session loads: `using-scribe` states the reflex in full and
|
|
||||||
# the server's `_INSTRUCTIONS` gives it one line for every MCP client. The
|
|
||||||
# Claude Code adapter's static context used to be a third copy; it now points
|
|
||||||
# at the skill instead, and tests/test_guidance_ownership.py keeps the topic
|
|
||||||
# from falling off.
|
|
||||||
SESSION_START_SURFACES = (
|
|
||||||
ROOT / "src" / "scribe" / "mcp" / "server.py",
|
|
||||||
ROOT / "plugin" / "skills" / "using-scribe" / "SKILL.md",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _all_surfaces() -> list[tuple[str, str]]:
|
def _all_surfaces() -> list[tuple[str, str]]:
|
||||||
"""(label, text) for every file a SESSION loads as instructions.
|
"""(label, text) for every file a SESSION loads as instructions.
|
||||||
@@ -105,54 +57,6 @@ def _all_surfaces() -> list[tuple[str, str]]:
|
|||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
def test_every_session_start_surface_states_the_ask():
|
|
||||||
"""Retrieval is the only delivery, so asking is the only recourse."""
|
|
||||||
missing = []
|
|
||||||
for path in SESSION_START_SURFACES:
|
|
||||||
assert path.exists(), (
|
|
||||||
f"{path.relative_to(ROOT)} is gone — it was one of the surfaces "
|
|
||||||
f"carrying the rules instruction. If it moved, update "
|
|
||||||
f"SESSION_START_SURFACES; if it was retired, check the instruction "
|
|
||||||
f"still lives somewhere a fresh session reads."
|
|
||||||
)
|
|
||||||
if ASK not in path.read_text():
|
|
||||||
missing.append(str(path.relative_to(ROOT)))
|
|
||||||
assert not missing, (
|
|
||||||
f"these surfaces never tell the agent how to ask for a rule "
|
|
||||||
f"({ASK}): {missing}. Nothing is pushed and nothing is resident, so a "
|
|
||||||
f"surface that omits this leaves a session with no way to reach a rule "
|
|
||||||
f"it was not handed — bound by nothing (#2198, #2497, milestone 394)."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_session_start_surface_says_an_empty_session_is_not_an_empty_rulebook():
|
|
||||||
"""The half that got dangerous when residency went away.
|
|
||||||
|
|
||||||
Under the old model a session opened holding every applicable rule, so
|
|
||||||
"nothing is in front of me" was a rare state and a suspicious one. Under
|
|
||||||
retrieval it is the NORMAL state of most turns. A surface that describes
|
|
||||||
where rules come from, without also saying what their absence means, leaves
|
|
||||||
a session reading silence as permission — on nearly every turn rather than
|
|
||||||
occasionally.
|
|
||||||
|
|
||||||
That is #3720's defect ("absence reads as non-existence") moved from a
|
|
||||||
readout to the session itself, and this milestone is what makes every
|
|
||||||
session start in the absent state.
|
|
||||||
"""
|
|
||||||
missing = []
|
|
||||||
for path in SESSION_START_SURFACES:
|
|
||||||
text = path.read_text().lower()
|
|
||||||
if not any(c.lower() in text for c in ABSENCE_CLAIMS):
|
|
||||||
missing.append(str(path.relative_to(ROOT)))
|
|
||||||
assert not missing, (
|
|
||||||
f"these surfaces say how a rule arrives but never what it means when "
|
|
||||||
f"none does: {missing}. 'No rule arrived' means 'nothing matched', "
|
|
||||||
f"never 'there is no rule' — and only one of those has been checked. "
|
|
||||||
f"Say it however you like; one of {ABSENCE_CLAIMS} is what this looks "
|
|
||||||
f"for."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _instructions_text() -> str:
|
def _instructions_text() -> str:
|
||||||
"""The _INSTRUCTIONS literal from server.py, as the client would see it."""
|
"""The _INSTRUCTIONS literal from server.py, as the client would see it."""
|
||||||
import re
|
import re
|
||||||
@@ -180,71 +84,6 @@ def test_instructions_fit_the_fold():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_the_systems_reflex_is_stated_by_its_owner():
|
|
||||||
"""Write-time tagging guidance must be stated as a reflex, not only per tool.
|
|
||||||
|
|
||||||
#2562's behavioral finding: with the guidance only in tool descriptions,
|
|
||||||
sessions filed records untagged. The tag-as-you-write reflex was pinned on
|
|
||||||
the static context then; since milestone 410 its owner is using-scribe
|
|
||||||
(decision #4027), with the in-band `systems_hint` as the half that fires on
|
|
||||||
its own.
|
|
||||||
"""
|
|
||||||
owner = (ROOT / "plugin" / "skills" / "using-scribe" / "SKILL.md").read_text()
|
|
||||||
for needle in ("system_ids", "create_system", "systems_hint"):
|
|
||||||
assert needle in owner, (
|
|
||||||
f"plugin/skills/using-scribe/SKILL.md no longer mentions "
|
|
||||||
f"{needle} — the Systems tagging reflex must be stated by its "
|
|
||||||
f"owner, not only in tool descriptions (#2562)."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_snippet_recording_triggers_are_stated_by_their_owner():
|
|
||||||
"""The pattern-library recording model must be stated, by name.
|
|
||||||
|
|
||||||
#2664's behavioral finding: recording guidance as a trailing clause of the
|
|
||||||
reuse bullet converted zero times outside snippet-minded sessions. The
|
|
||||||
2026-08-16 ruling (decision #2686) then replaced the reactive model
|
|
||||||
entirely: every shape is recorded at FIRST build — no "will it recur?"
|
|
||||||
judgment — and second-copy consolidation is only the backstop. All three
|
|
||||||
elements must stay stated: the tool, the first-build trigger, and the
|
|
||||||
backstop. Since milestone 410 the owner is the reusing-code skill.
|
|
||||||
"""
|
|
||||||
owner = " ".join((ROOT / "plugin" / "skills" / "reusing-code" / "SKILL.md").read_text().split())
|
|
||||||
for needle in ("create_snippet", "first build", "second copy"):
|
|
||||||
assert needle in owner, (
|
|
||||||
f"plugin/skills/reusing-code/SKILL.md no longer states the "
|
|
||||||
f"snippet-recording model ({needle!r}) — record-every-shape-at-"
|
|
||||||
f"first-build with second-copy consolidation as the backstop must "
|
|
||||||
f"be stated by its owner (#2664, decision #2686)."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# The "displaced from _INSTRUCTIONS" topics (#2562) used to be listed here with
|
|
||||||
# their own delivered-surface check. They are folded into the one topic registry
|
|
||||||
# in tests/test_guidance_ownership.py (milestone 410), which covers every topic
|
|
||||||
# of the ownership map and defines "delivered surface" once.
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_surface_names_the_push_without_stating_the_ask():
|
|
||||||
"""The exact shape #2497 took.
|
|
||||||
|
|
||||||
Mentioning the SessionStart hook is fine and often useful. Mentioning it
|
|
||||||
*instead of* the pull is the defect: it reads as "this is handled", and the
|
|
||||||
surface that says so is the one an agent has least reason to doubt.
|
|
||||||
"""
|
|
||||||
offenders = [
|
|
||||||
label for label, text in _all_surfaces()
|
|
||||||
if "SessionStart" in text and ASK not in text
|
|
||||||
]
|
|
||||||
assert not offenders, (
|
|
||||||
f"these surfaces describe the SessionStart push but never state how to "
|
|
||||||
f"ask: {offenders}. The push is a delivery optimisation, not the "
|
|
||||||
f"bridge — it can be absent without saying so, and since milestone 394 "
|
|
||||||
f"it carries no rules at all. Name it if it helps, but say how to ask "
|
|
||||||
f"({ASK}) regardless."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── force: a surface that says rules bind must say what does not ────────
|
# ── force: a surface that says rules bind must say what does not ────────
|
||||||
#
|
#
|
||||||
# Added with the preference kind (milestone 399). Before it, "rules bind" was
|
# Added with the preference kind (milestone 399). Before it, "rules bind" was
|
||||||
|
|||||||
Reference in New Issue
Block a user