fix(plugin): the instruction surfaces still said every rule binds (#3849)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m3s
CI & Build / Python tests (push) Successful in 1m27s
CI & Build / Build & push image (push) Successful in 41s

Step 3 shipped a line a session can receive — "Preference that may apply
here …" — into surfaces that told it, in the most authoritative voice it
has, that anything arriving in that shape is binding. That is the confusion
milestone 399 exists to prevent, arriving through the one channel a session
has least reason to doubt.

Silent in both directions, which is why it could not wait for step 6. A
session treating a preference as a rule refuses to proceed over something
the operator merely preferred; and it loses the whole reason preferences
exist, which is that they are brought up to date rather than obeyed.

Three surfaces, each to its own budget:

- SKILL.md gets the full account: kind decides force, the injected line names
  which in its opening words, and a preference is the one record a session
  keeps current itself (update_preference, with what taught the change).
- scribe_static_context.md gets six lines — enough to tell the kinds apart
  and to say a preference is yours to update.
- _INSTRUCTIONS gets four words. It is a MAP at 1978 of its 2000-char budget
  (#2562), and the detail belongs in the surfaces above and in the tool
  docstrings, which is what that budget exists to force.

Guarded so it cannot drift back: a surface that claims rules bind must name
the kind that does not. Pinned on the CLAIM rather than the word "bind",
because a bare substring also matches bind_repo, list_repo_bindings and
server.py's DNS-rebinding comment — a guard that would one day fail a skill
about repo binding is rule 167's named failure, raising a false alarm about
the very thing it protects. Falsified against all three surfaces losing the
mention.

Plugin version minted: the cache refreshes only on a version bump (#2209),
so a skill edit without one reaches no installed plugin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
This commit is contained in:
2026-09-10 23:20:04 -04:00
co-authored by Claude Opus 5
parent 26e0dff706
commit 8406871085
5 changed files with 79 additions and 7 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "scribe", "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.", "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": { "author": {
"name": "Bryan Van Deusen" "name": "Bryan Van Deusen"
}, },
+7 -1
View File
@@ -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. for the operator's work, and as your own working memory across sessions.
**At the start of this session:** **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`), - If the working repo maps to a Scribe project (check `list_repo_bindings`),
call `enter_project(<id>)` to load that project's rules, open tasks, and call `enter_project(<id>)` to load that project's rules, open tasks, and
recent notes in one shot. 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 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 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. 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 - **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 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 binds exactly as hard. So before a consequential act, `search` for a rule
+21 -3
View File
@@ -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 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 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 (see "Do this first"). Pull a record's full statement with `get_rule(id)`
statement with `get_rule(id)` when it's about to bite. When a project is in when it's about to bite. When a project is in scope, `enter_project(id)`
scope, `enter_project(id)` also returns its applicable rules. 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 Rules come in two tiers. **Always-on** rules are delivered — they arrive
whether or not you ask. **Conditional** rules are RETRIEVED, and one binds whether or not you ask. **Conditional** rules are RETRIEVED, and one binds
+3 -2
View File
@@ -88,8 +88,9 @@ Hierarchy: Project -> Milestone -> Task/Note. The map, by purpose:
active project_id to stay in scope. active project_id to stay in scope.
- WHERE work happens: Systems. Tag records with system_ids as you write; - WHERE work happens: Systems. Tag records with system_ids as you write;
create_system when the area is unmodelled. create_system when the area is unmodelled.
- HOW: rules bind. list_always_on_rules() at start; before a consequential - HOW: rules bind; preferences guide. list_always_on_rules() at start;
act, search(content_type="rule") — the resident set is not all of them. 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 / - UI: the project's design system is binding — resolve_design_system /
get_design_system_stylesheet before hand-writing a value. get_design_system_stylesheet before hand-writing a value.
- REUSE: search snippets before writing a helper; record what you build with - REUSE: search snippets before writing a helper; record what you build with
+47
View File
@@ -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"the bridge — it can be absent without saying so. Name it if it helps, "
f"but say to call {PULL}() regardless." 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."
)