diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 59a9fdd..8e55c7c 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.10.0221", + "version": "2026.09.11.0319", "author": { "name": "Bryan Van Deusen" }, diff --git a/plugin/hooks/scribe_static_context.md b/plugin/hooks/scribe_static_context.md index 5a6c5fc..258a670 100644 --- a/plugin/hooks/scribe_static_context.md +++ b/plugin/hooks/scribe_static_context.md @@ -6,7 +6,7 @@ of record (notes, tasks, projects, milestones, rules) reachable through the for the operator's work, and as your own working memory across sessions. **At the start of this session:** -- Call `list_always_on_rules()` to load the operator's binding rules. +- Call `list_always_on_rules()` to load the operator's standing rules. - If the working repo maps to a Scribe project (check `list_repo_bindings`), call `enter_project()` to load that project's rules, open tasks, and recent notes in one shot. @@ -21,6 +21,12 @@ 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. +- **Rules bind; preferences do not.** A record's `kind` says which. A **rule** + must be followed — ignoring it breaks something or crosses a boundary. A + **preference** is how the operator wants work done: worth following for + consistency, not a defect to miss. Injected lines name the kind in their + opening words. A preference is also yours to keep current when they correct + you (`update_preference`); a rule waits for them. - **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 diff --git a/plugin/skills/using-scribe/SKILL.md b/plugin/skills/using-scribe/SKILL.md index fffa5ba..93d0611 100644 --- a/plugin/skills/using-scribe/SKILL.md +++ b/plugin/skills/using-scribe/SKILL.md @@ -58,9 +58,27 @@ Two constraints on *how* that's achieved: 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. + (see "Do this first"). Pull a record'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. + + **`kind` says how much force a record carries, and it is never something to + infer.** A **rule** must be followed: ignoring it breaks something or + crosses a boundary. A **preference** records how the operator wants work + done, and ignoring it costs consistency rather than correctness. Both are + worth following and both arrive the same way; only one is a mistake to + miss. An injected line names which in its opening words — *"Standing rule + that may apply…"* against *"Preference that may apply…"* — and every + payload carries `kind` outright. + + **A preference is the one record you keep current yourself.** When the + operator corrects you, or the preference on file no longer matches how they + actually want something done, `update_preference` — that is expected, not a + liberty, and it wants the task or note that taught the change. Say in the + same turn that you did it, so they can disagree while it is in front of + them. A rule waits for the operator instead: `create_rule` proposes and + asks. If what you learned is that something MUST be done a certain way, + that is a rule to propose, not a preference to harden in place. Rules come in two tiers. **Always-on** rules are delivered — they arrive whether or not you ask. **Conditional** rules are RETRIEVED, and one binds diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index b37b5d4..0f9ee1b 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -88,8 +88,9 @@ 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 bind. list_always_on_rules() at start; before a consequential - act, search(content_type="rule") — the resident set is not all of them. +- HOW: rules bind; preferences guide. 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 diff --git a/tests/test_instruction_surfaces_agree.py b/tests/test_instruction_surfaces_agree.py index 4495650..6bd4ad6 100644 --- a/tests/test_instruction_surfaces_agree.py +++ b/tests/test_instruction_surfaces_agree.py @@ -252,3 +252,50 @@ def test_no_surface_names_the_push_without_stating_the_pull(): f"the bridge — it can be absent without saying so. Name it if it helps, " f"but say to call {PULL}() 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." + )