diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index f0f1eaf..4f3a5a9 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": "2026.09.03.0329", + "version": "2026.09.04.0140", "author": { "name": "Bryan Van Deusen" }, diff --git a/plugin/hooks/scribe_static_context.md b/plugin/hooks/scribe_static_context.md index 9a42c8c..5a6c5fc 100644 --- a/plugin/hooks/scribe_static_context.md +++ b/plugin/hooks/scribe_static_context.md @@ -21,6 +21,16 @@ for the operator's work, and as your own working memory across sessions. compaction — call `list_always_on_rules()` (and `enter_project()` when a project is in scope) BEFORE acting. When a loaded rule and a default habit disagree, the rule wins; if no rule speaks to it, ask rather than assume. +- **What you loaded is not all of the rules.** Only the always-on tier arrives + that way; conditional rules are RETRIEVED, and one you were never handed + binds exactly as hard. So before a consequential act, `search` for a rule + about it (`content_type="rule"`) rather than concluding from an empty + loaded set that nothing applies. "I was not told" is not the same as "there + is no rule," and only one of those is checkable. + This bites hardest on which TOOL to reach for — curling an API that has an + MCP client, standing up a local stack, running a suite CI owns. Those feel + like mechanics rather than decisions, so they raise no doubt and generate no + query; the moment you are most confident is the moment to look. - **Recall before acting** — before you answer anything about the operator's work or start a task, `search` Scribe first; assume a related note, task, or decision already exists. Concretely, reach for recall whenever a request diff --git a/plugin/skills/using-scribe/SKILL.md b/plugin/skills/using-scribe/SKILL.md index 9a81f64..fffa5ba 100644 --- a/plugin/skills/using-scribe/SKILL.md +++ b/plugin/skills/using-scribe/SKILL.md @@ -56,10 +56,31 @@ Two constraints on *how* that's achieved: re-deriving it or opening a duplicate. When a project is in scope, pass its `project_id` so results stay scoped. -2. **Standing rules are binding.** Load them via `list_always_on_rules()` at - session start (see "Do this first"); treat every one as binding. Pull a - rule's full statement with `get_rule(id)` when it's about to bite. When a - project is in scope, `enter_project(id)` also returns its applicable rules. +2. **Standing rules are binding — and the ones you were handed are not all of + them.** Load the resident set via `list_always_on_rules()` at session start + (see "Do this first"); treat every one as binding. Pull a rule's full + statement with `get_rule(id)` when it's about to bite. When a project is in + scope, `enter_project(id)` also returns its applicable rules. + + Rules come in two tiers. **Always-on** rules are delivered — they arrive + whether or not you ask. **Conditional** rules are RETRIEVED, and one binds + just as hard for never having been handed to you. So before a consequential + act, `search(content_type="rule")` on what you are about to do. An empty + loaded set is not evidence that no rule applies; it is only evidence that + none was pushed, and those are different claims. + + The tier split exists because delivery does not scale: every resident rule + costs tokens in every session forever, so a rulebook that grows past a few + dozen either stops growing or stops fitting. Retrieval is what lets the + rulebook keep growing — but retrieval only fires if something asks. + + **Ask hardest where you feel most certain.** Rules about which TOOL to reach + for — use the forge's MCP client rather than curling its API, don't stand up + a local stack, don't run the suite CI owns — govern moves that feel like + mechanics rather than decisions. A reflex raises no doubt, so it generates + no query, so the rule that would have stopped it is never retrieved. That is + the failure this instruction exists to prevent, and confidence is its only + warning sign. 3. **Update over duplicate.** When recording, prefer updating an existing note/rule/task over creating a new one. Search first; revise what's there. diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index ad2e0a2..b37b5d4 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -45,6 +45,31 @@ from quart import Quart # The accepted cost: an agent that never opens create_note's docstring never # learns the field exists. Guidance lives in the create_note / update_note # docstrings and the using-scribe skill instead. +# +# Milestone 333 step 3 (2026-09-04) bought the HOW bullet's second clause — +# search(content_type="rule") before a consequential act — by TRADING OUT +# "Processes are saved procedures (follow verbatim)" and "Deletes are +# trash-recoverable". Recorded so the trade is not silently reversed: +# - Both were already in test_instruction_surfaces_agree's DISPLACED_TOPICS +# and already stated on a delivered surface, so nothing fell off: the +# process reflex is in every scribe-proc-* skill listing (each says the +# process governs and is followed verbatim), and trash recovery is in the +# delete_*/list_trash/restore docstrings, which is where per-tool guidance +# belongs by this block's own doctrine. +# - What it bought is not per-tool guidance and has nowhere else to live at +# session-start altitude. Rules were retrievable only by RESIDENCY: the +# always-on preload put them in front of the agent, and nothing told a +# session to go looking for one it had not been handed. The tier split is +# therefore load-bearing on ANY install (rule 115): a delivered rule costs +# tokens in every session forever, so a rulebook that only delivers cannot +# grow past what one session can hold, and every rule worth keeping has to +# become resident to bind at all. Retrieval is what lets it keep growing — +# and retrieval fires only if something asks, which nothing told a session +# to do. A tool-choice reflex asks least of all (#3476, #161). +# - This states the PULL for conditional rules, exactly as the surrounding +# line states it for always-on ones. Rule 119 makes these surfaces the +# specification, so the same sentence lands on all three session-start +# surfaces, and test_instruction_surfaces_agree pins it. _INSTRUCTIONS = """ Scribe is the operator's self-hosted second brain and system of record — and yours: recall from it before acting, record as you go. Keep no parallel copy @@ -63,13 +88,13 @@ Hierarchy: Project -> Milestone -> Task/Note. The map, by purpose: active project_id to stay in scope. - WHERE work happens: Systems. Tag records with system_ids as you write; create_system when the area is unmodelled. -- HOW: rules are binding — list_always_on_rules() at session start. +- HOW: rules bind. list_always_on_rules() at start; before a consequential + act, search(content_type="rule") — the resident set is not all of them. - UI: the project's design system is binding — resolve_design_system / get_design_system_stylesheet before hand-writing a value. - REUSE: search snippets before writing a helper; record what you build with create_snippet; classify shapes against canon (classify_shapes) — a - consumer map is rows, never prose. Processes are saved procedures (follow - verbatim). Deletes are trash-recoverable. + consumer map is rows, never prose. A task is a note with status (*_note vs *_task tools). Creates are duplicate-gated: a near-match BLOCKS and returns the existing diff --git a/tests/test_instruction_surfaces_agree.py b/tests/test_instruction_surfaces_agree.py index 89e1497..4495650 100644 --- a/tests/test_instruction_surfaces_agree.py +++ b/tests/test_instruction_surfaces_agree.py @@ -195,6 +195,46 @@ def test_displaced_topics_live_on_a_delivered_surface(): ) +# 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.