CI & Build / Python lint (push) Failing after 3s
CI & Build / Plugin hooks (push) Failing after 12s
CI & Build / integration (push) Failing after 27s
CI & Build / TypeScript typecheck (push) Failing after 35s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
Milestone 394, steps 5-8. Operator: "remove the always on rule functionality as the goal was to not have it at all since it didn't seem to work as expected." Unconditional preload had three failures the retrieval arms do not. It could not be MEASURED — a resident rule is in the context whether or not it mattered, so nothing distinguished "this governed the act" from "this was scenery", and it was the one surface structurally exempt from the scoreboard judging every other. It was SUMMARISED AWAY by compaction while the session went on believing it held the rules. And it CROWDED OUT the few rules that applied with the thirty that did not. WHAT GOES Schema (0100): rules.tier + ck_rules_tier, rule_versions.tier, rulebooks.always_on, and project_rulebook_exclusions — a table recording a project's opt-out of something that no longer binds it unasked. Tools: list_always_on_rules, exclude_always_on_rulebook, include_always_on_rulebook. Service: the same three plus rules_etag_for, _valid_tier and the whole etag family. The SessionStart preload and the write-path staleness arm go with them: nothing is resident, so nothing can have drifted since a session loaded it. THREE CALLS WORTH REVIEWING enter_project got NARROWER, not wider. Its filter was `always_on OR area-tagged`; dropping the tier arm leaves the deterministic half, so a project with no canonical-tagged Systems gets no bulk rules and reaches them by retrieval instead. Dropping the whole clause would have made that payload bigger than the preload this milestone deletes. Backups import tolerantly. A pre-394 archive carries tier, always_on and the retired inception choice; none is read, and the exclusion key is DROPPED rather than remapped, because restoring it would write data that validate_inception now rejects as unknown. The migration is irreversible in the way that matters and says so: downgrade recreates the columns at their defaults and cannot restore which rules were always-on. A value invented to fill a hole is not a measurement. THE INSTRUCTION SURFACES SAY THE HARDER THING Deleting "call list_always_on_rules()" is easy; replacing it is not, because the new model asks a session to trust something it cannot see. All three surfaces now say a session holds nothing, that rules arrive when work matches them, and — the half that got dangerous — that "no rule arrived" means "nothing matched", never "there is no rule". Under residency an empty session was rare and suspicious; it is now the ordinary state of most turns, so reading it as permission is wrong on nearly every turn rather than occasionally. That is #3720's defect at session scale. test_instruction_surfaces_agree is repointed rather than retired: its two halves collapsed into one instruction, and it gains a guard that every surface states what absence means. _INSTRUCTIONS is back at 1999/2000 — the inception clause paid for the longer HOW line. UI (rule 27, and the opportunity step 8 named) The tier selector is gone, and what replaces it is the point: `when_to_apply` is now the field that decides whether a rule is ever seen, so the editor marks it required, warns while it is empty, and both rule lists badge a trigger-less rule "never surfaces". A rule without one is not quiet, it is unreachable. TESTS Two files deleted outright — test_rules_etag.py and test_inception_rules.py tested subsystems that no longer exist. Elsewhere obsolete cases were removed and the rest repointed. One deserves naming: the wiring test asserted the act arms pass no `tier`, which had become an assertion that could not fail. It is repointed onto `kind`, which does still exist and where the same claim is live — a preference must reach a write exactly as a rule does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
325 lines
15 KiB
Python
325 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; 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."
|
|
)
|
|
|
|
|
|
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."
|
|
)
|