CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m3s
CI & Build / Python tests (push) Successful in 1m27s
CI & Build / Build & push image (push) Successful in 41s
Step 3 shipped a line a session can receive — "Preference that may apply here …" — into surfaces that told it, in the most authoritative voice it has, that anything arriving in that shape is binding. That is the confusion milestone 399 exists to prevent, arriving through the one channel a session has least reason to doubt. Silent in both directions, which is why it could not wait for step 6. A session treating a preference as a rule refuses to proceed over something the operator merely preferred; and it loses the whole reason preferences exist, which is that they are brought up to date rather than obeyed. Three surfaces, each to its own budget: - SKILL.md gets the full account: kind decides force, the injected line names which in its opening words, and a preference is the one record a session keeps current itself (update_preference, with what taught the change). - scribe_static_context.md gets six lines — enough to tell the kinds apart and to say a preference is yours to update. - _INSTRUCTIONS gets four words. It is a MAP at 1978 of its 2000-char budget (#2562), and the detail belongs in the surfaces above and in the tool docstrings, which is what that budget exists to force. Guarded so it cannot drift back: a surface that claims rules bind must name the kind that does not. Pinned on the CLAIM rather than the word "bind", because a bare substring also matches bind_repo, list_repo_bindings and server.py's DNS-rebinding comment — a guard that would one day fail a skill about repo binding is rule 167's named failure, raising a false alarm about the very thing it protects. Falsified against all three surfaces losing the mention. Plugin version minted: the cache refreshes only on a version bump (#2209), so a skill edit without one reaches no installed plugin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
302 lines
14 KiB
Python
302 lines
14 KiB
Python
"""The instruction surfaces must agree that the agent pulls the rules itself.
|
|
|
|
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: pulling
|
|
when a push also arrived costs one redundant call; not pulling when the push
|
|
never came costs the operator's rules entirely.
|
|
|
|
WHAT THIS DOES NOT DO
|
|
|
|
It cannot tell whether two surfaces contradict each other in prose generally —
|
|
that needs a reader. It pins the one instruction whose absence is known to be
|
|
load-bearing, and the specific shape the #2497 defect took: naming the push
|
|
without also stating the pull.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pathlib
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|
|
|
# The pull instruction, however a surface phrases the surrounding prose.
|
|
PULL = "list_always_on_rules"
|
|
|
|
# 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_pull():
|
|
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 load-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 PULL not in path.read_text():
|
|
missing.append(str(path.relative_to(ROOT)))
|
|
assert not missing, (
|
|
f"these surfaces no longer tell the agent to call {PULL}(): {missing}. "
|
|
f"The rules are pull-only and the push is best-effort, so a surface "
|
|
f"that omits this leaves a session bound by nothing (#2198, #2497)."
|
|
)
|
|
|
|
|
|
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; the client injects only ~2,048 "
|
|
f"and silently cuts the rest (#2562). This block is a MAP — move the "
|
|
f"detail to the tool's docstring (delivered at reach-for time), the "
|
|
f"plugin static context (always delivered), or a skill; see the "
|
|
f"comment 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)."
|
|
)
|
|
|
|
|
|
# Topics displaced from _INSTRUCTIONS when it was cut to fit the fold. Each
|
|
# must remain stated on at least one DELIVERED surface: a tool docstring
|
|
# (arrives with the tool schema), the plugin static context (always arrives),
|
|
# or a bundled skill (arrives on trigger match). Keyed by a phrase distinctive
|
|
# enough that its disappearance means the guidance is gone, not reworded —
|
|
# update the phrase alongside a deliberate rewording.
|
|
DISPLACED_TOPICS = {
|
|
"supersedes": "supersedes",
|
|
"trash is recoverable": "deleted_batch_id",
|
|
"duplicate gate": "duplicate",
|
|
"systems tag-as-you-write": "system_ids",
|
|
"reference note vs dev-log": "reference note",
|
|
"work-logs over body rewrites": "add_task_log",
|
|
"rule homes / altitude": "create_project_rule",
|
|
"rules vs other entities": "standing instruction",
|
|
"shared records are suggestions": "shared",
|
|
"processes run verbatim": "verbatim",
|
|
"snippet reuse reflex": "when_to_use",
|
|
"compaction at seams": "compact",
|
|
"plans are milestones": "start_planning",
|
|
"scope to the entered project": "cross-project",
|
|
"project bootstrap needs confirmation": "never guessing a project",
|
|
}
|
|
|
|
|
|
def test_displaced_topics_live_on_a_delivered_surface():
|
|
corpus = ""
|
|
for p in (ROOT / "src" / "scribe" / "mcp" / "tools").glob("*.py"):
|
|
corpus += p.read_text()
|
|
corpus += (ROOT / "plugin" / "hooks" / "scribe_static_context.md").read_text()
|
|
for p in (ROOT / "plugin" / "skills").rglob("SKILL.md"):
|
|
corpus += p.read_text()
|
|
corpus = corpus.lower()
|
|
missing = [
|
|
f"{topic} (phrase: {phrase!r})"
|
|
for topic, phrase in DISPLACED_TOPICS.items()
|
|
if phrase.lower() not in corpus
|
|
]
|
|
assert not missing, (
|
|
f"guidance displaced from _INSTRUCTIONS has fallen off every delivered "
|
|
f"surface (tool docstrings / static context / skills): {missing}. It "
|
|
f"was cut from _INSTRUCTIONS deliberately (#2562) on the premise it "
|
|
f"lives elsewhere — restore it somewhere that delivers."
|
|
)
|
|
|
|
|
|
# The SECOND pull (milestone 333 step 3). `list_always_on_rules()` fetches the
|
|
# resident tier; this one says that tier is not all of them, and that a
|
|
# conditional rule has to be gone looking for. However a surface words the
|
|
# surrounding prose, it names the call.
|
|
RETRIEVE = 'content_type="rule"'
|
|
|
|
|
|
def test_every_session_start_surface_states_the_conditional_retrieval():
|
|
"""The push/pull asymmetry, one level in.
|
|
|
|
The tests above pin that a session PULLS the resident rules rather than
|
|
trusting the SessionStart push. This pins the same shape between the two
|
|
TIERS: an always-on rule is delivered, a conditional one is retrieved, and
|
|
a surface that states only the first leaves a session reading its loaded
|
|
set as the whole rulebook.
|
|
|
|
That reading is wrong in the direction that costs something. "Nothing was
|
|
pushed" and "no rule applies" are different claims, and only one of them
|
|
has been checked — the same asymmetry as #2198, now between tiers instead
|
|
of between channels.
|
|
|
|
It is also what made the always-on tier the only one that worked, on any
|
|
install rather than this one (rule 115). A rule nothing retrieves has to be
|
|
resident to bind at all, so every rule worth keeping becomes resident; and
|
|
a resident rule costs tokens in every session forever, so a rulebook that
|
|
only delivers cannot grow past what one session can hold. Retrieval is what
|
|
lifts that ceiling — and it only fires if something asks.
|
|
"""
|
|
missing = []
|
|
for path in SESSION_START_SURFACES:
|
|
if RETRIEVE not in path.read_text():
|
|
missing.append(str(path.relative_to(ROOT)))
|
|
assert not missing, (
|
|
f"these surfaces state the always-on pull but never tell the agent to "
|
|
f"retrieve a conditional rule ({RETRIEVE}): {missing}. A session that "
|
|
f"reads its loaded set as the whole rulebook will act on \"I was not "
|
|
f"told\" as if it meant \"there is no rule\" (milestone 333 step 3)."
|
|
)
|
|
|
|
|
|
def test_no_surface_names_the_push_without_stating_the_pull():
|
|
"""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 PULL not in text
|
|
]
|
|
assert not offenders, (
|
|
f"these surfaces describe the SessionStart push but never state the "
|
|
f"explicit pull: {offenders}. The push is a delivery optimisation, not "
|
|
f"the bridge — it can be absent without saying so. Name it if it helps, "
|
|
f"but say to call {PULL}() 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."
|
|
)
|