From ffd08507f15dd6a1495c5c20e45b773b32750f21 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 6 Aug 2026 08:23:36 -0400 Subject: [PATCH] fix(instructions): the push is an optimisation, not the bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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 --- plugin/.claude-plugin/plugin.json | 2 +- plugin/hooks/scribe_static_context.md | 12 +++ src/scribe/mcp/server.py | 35 +++++--- tests/test_instruction_surfaces_agree.py | 104 +++++++++++++++++++++++ 4 files changed, 140 insertions(+), 13 deletions(-) create mode 100644 tests/test_instruction_surfaces_agree.py diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 98685c7..18b1602 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "scribe", "description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.", - "version": "0.1.23", + "version": "0.1.24", "author": { "name": "Bryan Van Deusen" }, "mcpServers": { "scribe": { diff --git a/plugin/hooks/scribe_static_context.md b/plugin/hooks/scribe_static_context.md index dd5a313..a92049b 100644 --- a/plugin/hooks/scribe_static_context.md +++ b/plugin/hooks/scribe_static_context.md @@ -51,5 +51,17 @@ for the operator's work, and as your own working memory across sessions. `/compact` (name what you logged). You can't run it yourself — surface the recommendation and let them decide. Suggest it at seams, not every turn. +**If two Scribe instruction surfaces disagree** — this file, the MCP server's +tool instructions, the `using-scribe` skill — **follow the one that assumes +least about its own delivery.** This file is the floor: it ships with the +plugin and needs no API key and no network, so it still applies in exactly the +session where the others never arrived. The others may elaborate on what is +written here; they must not contradict it. Weigh a disagreement by which way it +fails, not by which surface said more: doing something a push would also have +covered costs one redundant call, while skipping it because you expected a push +that never came means working without the operator's rules and not knowing. +A contradiction between surfaces is a defect in the product — say so, so it +gets recorded and fixed rather than silently arbitrated again next session. + If the Scribe tools are unavailable, say so rather than silently falling back to local notes. diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index ceff8da..9b7cd43 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -174,19 +174,30 @@ written as a rule. A repeatable procedure is a PROCESS. Reusable code is a SNIPPET. Reach for a rule when the thing genuinely is a standing instruction about how to work, and nothing else can hold it. +Rules are pull-only: CALL list_always_on_rules() YOURSELF at the start of a +session, before substantive work. Do not wait to be handed them. The plugin's +SessionStart hook also pushes the always-on rules + active-project context, but +that is a delivery optimisation, not the bridge — it can be absent (instance +unreachable, key not configured, or the hook itself silently inert, which is +what #2198 was) and its absence does not announce itself. When the push did +arrive, the pull costs one redundant call; when it didn't, skipping the pull +means working without the operator's binding rules and having no signal that +you are. Those two costs are not comparable, so pull. + One thing NOT to do: don't bridge Scribe into a session by writing to the -host's native memory. Rules are pull-only, so a fresh session won't reach for -them unless its always-loaded context says to — but the bridge for that is the -Scribe plugin's SessionStart hook, which pushes the always-on rules + -active-project context into each session directly. So do NOT create or refresh -a "rules live in Scribe" pointer in CLAUDE.md / AGENTS.md / ~/.claude memory, -and do NOT keep rules, recall, or plans in those stores in parallel with Scribe -— Scribe holds the single copy. Native auto-memory stays for facts about the -user; CLAUDE.md for codebase onboarding. Never make Scribe's correctness depend -on the operator disabling a native function (e.g. autoMemoryEnabled): the -plugin must work with auto-memory at its default. If the plugin is ever removed -the session loses this push and rebuilds context over time — an acceptable cost, -and far better than a silent settings change the operator may not know about. +host's native memory. That explicit pull IS the bridge — it is written into +every instruction surface the session already loads, so it needs no pointer on +disk. So do NOT create or refresh a "rules live in Scribe" pointer in +CLAUDE.md / AGENTS.md / ~/.claude memory, and do NOT keep rules, recall, or +plans in those stores in parallel with Scribe — Scribe holds the single copy, +and a pointer Scribe cannot update is one that goes stale without anyone +noticing. Native auto-memory stays for facts about the user; CLAUDE.md for +codebase onboarding. Never make Scribe's correctness depend on the operator +disabling a native function (e.g. autoMemoryEnabled): the plugin must work with +auto-memory at its default. If the plugin is ever removed the session loses both +the push and these instructions, and rebuilds context over time — an acceptable +cost, and far better than a silent settings change the operator may not know +about. When you are working on a specific project, call enter_project(project_id) ONCE at session start (or whenever the active project changes). It returns the diff --git a/tests/test_instruction_surfaces_agree.py b/tests/test_instruction_surfaces_agree.py new file mode 100644 index 0000000..87358c7 --- /dev/null +++ b/tests/test_instruction_surfaces_agree.py @@ -0,0 +1,104 @@ +"""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." + )