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." + )