Files
FabledScribe/tests/test_instruction_surfaces_agree.py
T
bvandeusen ffd08507f1
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 29s
fix(instructions): the push is an optimisation, not the bridge
_INSTRUCTIONS told an agent the SessionStart hook was how rules reach a
session, and used that as the argument against a host-memory pointer. The
using-scribe skill said the opposite — pull them yourself, treat any push as a
bonus. Nothing said which wins, and #119 makes these surfaces the
specification, so this was the product behaving two ways.

#2198 is the case that settles it: every plugin hook was silently inert for an
extended period. An agent trusting the push would have run with no binding
rules and no signal, while those rules govern branch, commit and push.

So: _INSTRUCTIONS now leads with the explicit pull and names the hook as a
delivery optimisation. The argument against a host-memory pointer survives —
it never needed the hook to be reliable, because the pull IS the bridge and it
is written into every surface a session already loads.

The static context gains the tiebreaker for the next disagreement: follow the
surface that assumes least about its own delivery. "Most detailed wins" is
wrong precisely because the most detailed surface is the one with a delivery
precondition. It goes there by its own logic — a tiebreaker arriving over MCP
cannot arbitrate what to do when MCP is absent.

Guarded by tests/test_instruction_surfaces_agree.py: every session-start
surface states the pull, and no surface names the push without it. Plugin
version bumped so the cache that executes actually picks the file up (#2209).

Refs #2497
2026-08-06 08:23:36 -04:00

105 lines
4.6 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 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."
)