CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 54s
CI & Build / Python tests (push) Successful in 1m28s
CI & Build / Build & push image (push) Successful in 27s
Step 3 of milestone 410 "One owner per piece of guidance".
_INSTRUCTIONS is rewritten as an index for every MCP client (1,597 of 2,000
chars): orient, rules, recall, record, plan, ids, reuse, UI, report. Each
line names its tool, and the block says every reflex is stated in full in
the using-scribe skill and in each tool description.
- names no client: CLAUDE.md, auto-memory and "the client injects ~2k
chars" are gone
- gains the two reflexes it lacked: records that cite each other go through
create_records with {{ref:N}}, and reports start from `placement`
The comment block above it now explains ownership (decision #4027) instead of
accumulating per-milestone trade history, and keeps the budget and its
reason (#2562).
build_session_context states only what the server knows about this session:
the active project and open work, its design system, an unbound-repo hint,
or that no project is bound. Removed: the "you are not holding the
operator's rules" section, the closing "Reflex: search Scribe" line, the
design-system usage sentence, and the plugin-specific header. using-scribe
owns all of that. The truncation note no longer restates the rules ask.
Tests: a pin that the live context carries no rules reflex; the cap test
drives truncation through the unbound-repo hint, since a bare session is now
one line; the budget test message describes the index.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
321 lines
15 KiB
Python
321 lines
15 KiB
Python
"""The instruction surfaces must agree on how a rule reaches a session.
|
|
|
|
WHY THIS EXISTS
|
|
|
|
Rule #119 makes the instruction surfaces the SPECIFICATION for product
|
|
behaviour — there is no other place the "load the operator's rules" obligation
|
|
is written down, and no code path enforces it. So a surface that states it
|
|
differently isn't a documentation slip; it is the product behaving differently.
|
|
|
|
That happened (#2497). `_INSTRUCTIONS` said the SessionStart hook "is the
|
|
bridge" for getting rules into a session, while the `using-scribe` skill said to
|
|
pull them yourself and treat any push as a bonus. An agent weighting the first
|
|
would reasonably skip the pull.
|
|
|
|
#2198 is the case where that is wrong: every plugin hook was silently inert for
|
|
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
|
|
when a rule had already arrived costs one redundant call; not asking when
|
|
nothing arrived costs the operator's rules entirely.
|
|
|
|
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
|
|
|
|
import pathlib
|
|
|
|
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",
|
|
)
|
|
|
|
# Surfaces a session loads before substantive work. Hand-written because
|
|
# "is this a session-start surface?" 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.
|
|
SESSION_START_SURFACES = (
|
|
ROOT / "src" / "scribe" / "mcp" / "server.py",
|
|
ROOT / "plugin" / "hooks" / "scribe_static_context.md",
|
|
ROOT / "plugin" / "skills" / "using-scribe" / "SKILL.md",
|
|
)
|
|
|
|
|
|
def _all_surfaces() -> list[tuple[str, str]]:
|
|
"""(label, text) for every file a SESSION loads as instructions.
|
|
|
|
Deliberately not every markdown file under plugin/: `README.md` describes
|
|
the push channel to the operator installing the plugin, and telling a human
|
|
what the hook does is not the same act as telling an agent it need not pull.
|
|
The boundary is "does a session read this", which is skills (loaded by
|
|
description match), the hook-injected static context, and the MCP server's
|
|
own instructions.
|
|
"""
|
|
found = [(str(p.relative_to(ROOT)), p.read_text())
|
|
for p in (ROOT / "plugin" / "skills").rglob("SKILL.md")]
|
|
found += [(str(p.relative_to(ROOT)), p.read_text())
|
|
for p in (ROOT / "plugin" / "hooks").glob("*.md")]
|
|
server = ROOT / "src" / "scribe" / "mcp" / "server.py"
|
|
found.append((str(server.relative_to(ROOT)), server.read_text()))
|
|
return found
|
|
|
|
|
|
def 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:
|
|
"""The _INSTRUCTIONS literal from server.py, as the client would see it."""
|
|
import re
|
|
src = (ROOT / "src" / "scribe" / "mcp" / "server.py").read_text()
|
|
match = re.search(r'_INSTRUCTIONS = """(.*?)"""', src, re.S)
|
|
assert match, "server.py no longer defines _INSTRUCTIONS as a triple-quoted literal"
|
|
return match.group(1).strip()
|
|
|
|
|
|
# Claude Code injects only the first ~2,048 characters of an MCP server's
|
|
# instructions and silently cuts the rest mid-word (#2562: observed live —
|
|
# the previous 20k-char version delivered ~10% of itself, and none of the
|
|
# Systems tagging guidance ever reached a session). 2,000 leaves margin.
|
|
INSTRUCTIONS_BUDGET = 2000
|
|
|
|
|
|
def test_instructions_fit_the_fold():
|
|
text = _instructions_text()
|
|
assert len(text) <= INSTRUCTIONS_BUDGET, (
|
|
f"_INSTRUCTIONS is {len(text)} chars; Claude Code injects only ~2,048 "
|
|
f"and silently cuts the rest (#2562). This block is an INDEX — state "
|
|
f"the topic in full on its owner (a skill or the tool's docstring, "
|
|
f"decision #4027) and give it at most a line here; see the comment "
|
|
f"above _INSTRUCTIONS."
|
|
)
|
|
|
|
|
|
def test_floor_states_the_systems_reflex():
|
|
"""Write-time tagging guidance must live on the surface that always arrives.
|
|
|
|
#2562's behavioral finding: with the guidance only in tool descriptions,
|
|
sessions filed records untagged. The static context is the delivery floor,
|
|
so the tag-as-you-write reflex has to be stated there.
|
|
"""
|
|
floor = (ROOT / "plugin" / "hooks" / "scribe_static_context.md").read_text()
|
|
for needle in ("system_ids", "create_system"):
|
|
assert needle in floor, (
|
|
f"plugin/hooks/scribe_static_context.md no longer mentions "
|
|
f"{needle} — the Systems tagging reflex must be stated on the "
|
|
f"floor, not only in tool descriptions (#2562)."
|
|
)
|
|
|
|
|
|
def test_floor_names_the_snippet_recording_triggers():
|
|
"""The floor must state the pattern-library recording model, 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. The floor
|
|
is the delivery surface for that reflex, so all three elements must stay
|
|
stated: the tool, the first-build trigger, and the backstop.
|
|
"""
|
|
floor = (ROOT / "plugin" / "hooks" / "scribe_static_context.md").read_text()
|
|
for needle in ("create_snippet", "first build", "second copy"):
|
|
assert needle in floor, (
|
|
f"plugin/hooks/scribe_static_context.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 on the floor (#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 ────────
|
|
#
|
|
# Added with the preference kind (milestone 399). Before it, "rules bind" was
|
|
# the whole truth and every surface said so flatly. It is now half of one, and
|
|
# the half that is missing is the dangerous half to omit: a session reading
|
|
# only "rules bind" and then receiving a preference has been told, by the most
|
|
# authoritative surface it has, to treat it as binding.
|
|
#
|
|
# That failure is silent in both directions. Treating a preference as a rule
|
|
# produces a session that refuses to proceed over something the operator only
|
|
# preferred; and it removes the reason preferences exist, which is that they
|
|
# can be brought up to date rather than obeyed.
|
|
#
|
|
# Same bargain as every test in this file: STRUCTURE, not wording. A surface
|
|
# passes by mentioning the other kind at all, so the prose stays free.
|
|
#
|
|
# PINNED ON THE CLAIM, NOT THE WORD "bind". A bare substring also matches
|
|
# `bind_repo`, `list_repo_bindings` and the DNS-rebinding comment in
|
|
# server.py — so it would one day fail a skill that mentions repo binding and
|
|
# has nothing to do with force, which is rule 167's named failure: a guard
|
|
# raising a false alarm about the very thing it protects. These phrases are
|
|
# the ones that actually assert bindingness to a reader.
|
|
BINDING_CLAIMS = (
|
|
"rules bind",
|
|
"binding rules",
|
|
"rules are binding",
|
|
"treat every one as binding",
|
|
)
|
|
OTHER_KIND = "preference"
|
|
|
|
|
|
def test_a_surface_claiming_rules_bind_also_names_what_does_not():
|
|
offenders = [
|
|
label for label, text in _all_surfaces()
|
|
if any(c in text.lower() for c in BINDING_CLAIMS)
|
|
and OTHER_KIND not in text.lower()
|
|
]
|
|
assert not offenders, (
|
|
f"these surfaces tell a session that rules bind and never mention "
|
|
f"preferences: {offenders}. A preference arrives through the same arms "
|
|
f"and renders in the same line shape, so a surface that describes only "
|
|
f"the binding kind is read as covering both — and the session treats "
|
|
f"'how the operator likes this done' as something it may not proceed "
|
|
f"past. Name the other kind, however briefly."
|
|
)
|
|
|
|
|
|
# ── Reporting back (milestone 409 step 3) ──────────────────────────────
|
|
#
|
|
# The reporting-back skill carries the shapes, but a skill only helps if it
|
|
# fires. The reflex that sends a session to it lives on the two plugin
|
|
# surfaces a session always reads; the in-band cue on update_task is the half
|
|
# that reaches clients with no plugin at all. Since milestone 410 the server's
|
|
# index carries a REPORT line too, pointing at `placement`.
|
|
|
|
REPORT_REFLEX = "report back in a shape the operator can read"
|
|
REPORT_SURFACES = (
|
|
ROOT / "plugin" / "hooks" / "scribe_static_context.md",
|
|
ROOT / "plugin" / "skills" / "using-scribe" / "SKILL.md",
|
|
)
|
|
|
|
|
|
def test_the_reporting_reflex_reaches_every_plugin_surface():
|
|
missing = []
|
|
for path in REPORT_SURFACES:
|
|
text = " ".join(path.read_text().split()).lower()
|
|
if REPORT_REFLEX not in text or "reporting-back" not in text:
|
|
missing.append(str(path.relative_to(ROOT)))
|
|
assert not missing, (
|
|
f"these surfaces no longer send a session to the reporting-back skill "
|
|
f"({REPORT_REFLEX!r} plus the skill's name): {missing}. Without the "
|
|
f"reflex the skill loads only when its description happens to match."
|
|
)
|
|
|
|
|
|
def test_the_reporting_reflex_takes_placement_from_the_record():
|
|
"""The failure milestone 409 began from: a placement reconstructed from
|
|
memory reads exactly like a real one when it is wrong."""
|
|
for path in REPORT_SURFACES:
|
|
text = " ".join(path.read_text().split())
|
|
assert "`placement`" in text, f"{path.relative_to(ROOT)} no longer points at the placement block"
|