diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 34b4fd6..6d5993e 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "scribe", "description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).", - "version": "2026.09.18.1606", + "version": "2026.09.19.1614", "author": { "name": "Bryan Van Deusen" }, diff --git a/plugin/skills/using-scribe/SKILL.md b/plugin/skills/using-scribe/SKILL.md index 526326d..96025e5 100644 --- a/plugin/skills/using-scribe/SKILL.md +++ b/plugin/skills/using-scribe/SKILL.md @@ -93,21 +93,39 @@ Two constraints on *how* that's achieved: **`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. + done, and ignoring it costs consistency rather than correctness. A + **lesson** is what is worth knowing — a better way to think about a + problem, a solution that transfers — and ignoring it costs time. All three + are worth reading and all three arrive beside your work; only the first is + a mistake to miss. An injected line names which in its opening words — + *"Standing rule that may apply…"* against *"Preference that may apply…"* — + a lesson arrives in the records menu marked `lesson`, under a header that + says outright that it binds nothing, and every payload carries `kind`. + + **One question sorts them, and it is worth asking out loud: what happens if + someone doesn't do this?** *Something breaks, or a boundary is crossed* — a + rule, and the operator's to agree to. *It gets done a way they didn't want* + — a preference, recorded without a loop. *They lose time rediscovering it* + — a lesson, binding nobody. Ask it before proposing rather than settling it + silently, because it sometimes answers that what you are holding was never + a rule — and routing an observation is not losing it. **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 + liberty, and updating one mid-work is the normal case rather than an + interruption of it. 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. + **Preferences shape how work is done, never what gets recorded.** They + govern your conduct — how you report, how carefully you pace, which form + you reach for. What ends up in Scribe is decided by what the record is: + a preference never makes a task into a note, downgrades a rule, or keeps + something out of the corpus that belongs there. + **A retrieved rule outranks a default habit.** Before a hard-to-reverse or outward-facing act — changing shared state, publishing, deleting, sending something outside the session — the operator's rules decide what to do, not @@ -339,6 +357,13 @@ and none of that survives being written as a rule. A repeatable procedure is a **process**; reusable code is a **snippet**. Reach for a rule when the thing really is a standing instruction about how to work. +Then ask what force it carries, by the question above. A standing instruction +that merely costs consistency is a **preference** (`create_preference`), and a +transferable insight that costs time is a **lesson** (`create_lesson`), keyed +to the situation it applies to so a later session meets it there. Both are +first-class outcomes of noticing something, not what's left when a rule +proposal fails. + ## When a record doesn't reach the moment it should Retrieval misjudging is ordinary, and it is fixable — but only by whoever diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index a7672df..f13f96b 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -45,18 +45,18 @@ client reads Agent Skills) and in each tool's description. The index: decide_project_inception. - RULES: nothing preloads; a rule arrives when your work matches it. Before a consequential act — or before handing work back unsure you may finish it — - what_might_apply("what you are about to do"): the wide net, fifty ranked + what_might_apply("what you are about to do"): fifty ranked candidates, no bar. search(content_type="rule") reads one you already suspect. Silence means nothing matched, not none. Rules bind; preferences - guide. -- MISSED: a rule that should have reached you is a trigger to fix, not a + guide and you keep them current; lessons inform. +- MISSED: a rule that missed you is a trigger to fix, not a floor to move (retrieval_telemetry). - RECALL: search before acting, scoped with the active project_id. - RECORD: create_task; a fix is kind="issue". add_task_log as you go; status in_progress on start, done on finish. Tag system_ids as you write. - PLAN work with an arc: find the existing plan first (search(content_type="milestone")) and add steps to it; else - start_planning(steps=[...]). The plan is a milestone and each step a task. + start_planning(steps=[...]). The plan is a milestone, each step a task. - IDS exist only once a create returns them. Records that cite each other go through create_records, writing {{ref:N}} for the Nth record. - REUSE: search snippets before building; create_snippet what you build. diff --git a/src/scribe/mcp/tools/lessons.py b/src/scribe/mcp/tools/lessons.py index 664b3f0..9409b04 100644 --- a/src/scribe/mcp/tools/lessons.py +++ b/src/scribe/mcp/tools/lessons.py @@ -111,10 +111,13 @@ async def create_lesson( surface that is both global and situation-keyed, and proposing rules for things that should never have bound anyone. - WHAT A LESSON IS NOT: a shape to copy is a SNIPPET (create_snippet); a - procedure followed start to finish is a PROCESS (create_process); a record - of what happened, findable by topic, is a NOTE (create_note). A lesson is - the claim you would want handed to you in the same situation next time. + WHAT A LESSON IS NOT: how the operator wants work DONE is a PREFERENCE + (create_preference) — it guides every session rather than informing one, + and it is kept current as they correct you. A shape to copy is a SNIPPET + (create_snippet); a procedure followed start to finish is a PROCESS + (create_process); a record of what happened, findable by topic, is a NOTE + (create_note). A lesson is the claim you would want handed to you in the + same situation next time. `when_to_apply` IS THE RECORD. Everything else is the payload. diff --git a/src/scribe/mcp/tools/notes.py b/src/scribe/mcp/tools/notes.py index c72327e..2c649bb 100644 --- a/src/scribe/mcp/tools/notes.py +++ b/src/scribe/mcp/tools/notes.py @@ -120,6 +120,15 @@ async def create_note( is a task nobody will ever see again. - future sessions must OBEY it -> create_rule. The test is whether ignoring it would be a mistake, not merely uninformed. + - it is how the operator wants work DONE, and ignoring it costs + consistency rather than correctness -> create_preference. No approval + loop, and it stays yours to keep current as they correct you. + - "merely uninformed" is the other half of the rule test, and it has its + own home: something you learned that a later session should meet at the + moment it applies -> create_lesson. A note is found by topic when + someone goes looking; a lesson arrives on its own, keyed to the + SITUATION. If you would want this handed to you next time you were in + that spot, it is a lesson. - reusable code with a place in a repo -> create_snippet. The location is what lets it be found from the file someone is about to edit. - a procedure followed start to finish -> create_process. diff --git a/src/scribe/mcp/tools/rulebooks.py b/src/scribe/mcp/tools/rulebooks.py index e193ce8..d6c6268 100644 --- a/src/scribe/mcp/tools/rulebooks.py +++ b/src/scribe/mcp/tools/rulebooks.py @@ -268,6 +268,23 @@ async def create_rule( When the operator asks for a rule in so many words, that IS the yes — write it and move on. The loop below is for the rule you thought of. + FIRST, ASK WHAT KIND OF THING YOU ARE HOLDING. Force is the axis, and one + question sorts it: *what happens if someone doesn't do this?* + + * "something breaks, or a boundary is crossed" → a RULE. It must be + followed, so it is the operator's to agree to. Propose it here. + * "it gets done a way the operator didn't want" → a PREFERENCE + (create_preference). How they want work done; ignoring it costs + consistency rather than correctness. No approval loop — record it. + * "they lose time rediscovering it" → a LESSON (create_lesson). A better + way to think about a problem, or a solution that transfers, met again + at the moment it applies. It binds nobody, so it needs no yes. + + Asking this raises the value of noticing rather than lowering it: the + observation is worth keeping in all three cases, and what changes is + only which door it goes through. A proposal that turns out to be a + lesson has not failed — it has been routed. + A PROPOSAL CARRIES FOUR THINGS, and the fourth is the one that decides it: 1. WHAT it would require — the statement, in the words it would carry, @@ -289,8 +306,8 @@ async def create_rule( shrinks only on purpose, so a question that prevents a rule is worth more than any question that improves one's wording. - THEN CLOSE WITH A QUESTION THEY CAN ANSWER IN ONE WORD. Offer three - answers, and make the middle one the easy one: + THEN CLOSE WITH A QUESTION THEY CAN ANSWER IN ONE WORD. Offer these + answers, and make "LET'S TALK ABOUT IT" the easy one: * "Approve it AS WRITTEN" — you create it with the statement exactly as shown. This is what makes element 1 load-bearing: they approved TEXT, @@ -298,14 +315,27 @@ async def create_rule( * "LET'S TALK ABOUT IT" — the wording, the scope, whether it wants to be a rule at all. Most good rules arrive this way, so treat this answer as the expected one rather than a setback. - * "NO" — let it go. If the observation is still worth keeping, it is a - note (create_note): recorded, findable, and binding on nobody. + * "MAKE IT A PREFERENCE" — they want it done this way, but nothing + breaks if it isn't. create_preference records it without a loop, and + it stays yours to keep current as they correct you. + * "MAKE IT A LESSON" — worth knowing, binding nobody. create_lesson + stores it against the SITUATION it applies to, so a later session + meets it at that moment rather than having to go looking. Reach for + this whenever the answer to "what happens if someone doesn't do + this" was "they lose time". + * "NO" — let it go. If the observation is still worth keeping and none + of the above fits, it is a note (create_note): recorded, findable, + and binding on nobody. + + The middle three are not consolation prizes. They are where most good + observations belong, and the reason the kind question is worth asking + out loud rather than settled silently before the proposal. Where the interface offers structured choices, ask it that way — a question with named options is answered in a click, while the same question inside a paragraph is answered by scrolling past. Where it does - not, write the three options out as three options. Either way ask once - and let the answer stand; re-raising a declined proposal argues a rule + not, write the answers out as a list. Either way ask once and let the + answer stand; re-raising a declined proposal argues a rule into existence, which is the thing this whole loop exists to prevent. A rulebook rule is GLOBAL — it applies in every project — so it must read as @@ -431,14 +461,16 @@ async def create_project_rule( in get_project's project_rules and in list_rules(project_id=...). PROPOSE, THEN WRITE ON A YES — create_rule's opening carries the whole - loop: the four things a proposal states (what it would require, its - intent, why now, and how it would be enforced) and the one-word question - that closes it (approve as written / talk about it / no). All of it - applies here unchanged. Reach for that loop MORE readily on this surface, - not less: a project rule stays out of an unfiltered list_rules(), and a - conditional one stays out of session start too, so the operator's yes is - the one moment this rule is certain to have been seen by the person it - binds. + loop: the kind question that comes first (what happens if someone doesn't + do this — a rule binds, a preference guides, a lesson informs), the four + things a proposal states (what it would require, its intent, why now, and + how it would be enforced) and the one-word question that closes it + (approve as written / talk about it / make it a preference / make it a + lesson / no). All of it applies here unchanged. Reach for that loop MORE + readily on this surface, not less: a project rule stays out of an + unfiltered list_rules(), and a conditional one stays out of session start + too, so the operator's yes is the one moment this rule is certain to have + been seen by the person it binds. Check first whether a rule is the right shape at all — create_rule's opening asks that question and it applies identically here. A visual diff --git a/tests/test_guidance_ownership.py b/tests/test_guidance_ownership.py index 558794b..bbf1f38 100644 --- a/tests/test_guidance_ownership.py +++ b/tests/test_guidance_ownership.py @@ -119,6 +119,19 @@ TOPICS: tuple[Topic, ...] = ( index=('content_type="rule"', "nothing matched")), Topic("rules bind, preferences guide and are kept current", U, ("preference", "update_preference"), "a preference is the one record you keep current yourself", index=("preferences guide",)), + Topic("kind is force: a rule binds, a preference guides, a lesson informs", U, + ("lesson", "costs time", "costs consistency"), + "what happens if someone doesn't do this", + index=("lessons inform",), + # Stated twice on purpose, at the two moments it is needed: the + # skill while deciding what a record IS, and create_rule while a + # proposal is actually being written — which is where the + # mis-routing #3733 recorded happens, and the caller there has not + # necessarily read the skill. + shared_with=("docstrings",)), + Topic("preferences shape how work is done, never what is recorded", U, + ("never what gets recorded",), + "a preference never makes a task into a note"), Topic("recall before acting", U, ("recall before acting",), "for related prior work", index=("recall",)), Topic("stay inside the active project's scope", U, ("stay inside the active project", "cross-project"), "stay inside the active project's scope", index=("project_id",)), diff --git a/tests/test_instruction_surfaces_agree.py b/tests/test_instruction_surfaces_agree.py index fd57988..8fa4307 100644 --- a/tests/test_instruction_surfaces_agree.py +++ b/tests/test_instruction_surfaces_agree.py @@ -131,6 +131,53 @@ def test_a_surface_claiming_rules_bind_also_names_what_does_not(): ) +# ── the index names every strength, and who keeps the middle one current ── +# +# Milestones 385 and 399 both end here, and for the same reason. The index is +# the one guidance surface EVERY MCP client receives — a client that reads no +# Agent Skills still gets it — so a strength the index omits does not exist for +# that session. Before the lesson kind, "rules bind; preferences guide" was the +# whole corpus; a session that meets a third line shape with no name for it +# falls back on the nearest one it has, which is the binding one. +# +# The currency half is the claim #3896 was written around: a preference is the +# one record a session updates ITSELF, without asking. Stated only in the +# skill, it reaches a session that loaded the skill — which is exactly the +# session least likely to need telling. In the index it reaches everyone. +# +# Words, not wording: each entry is a family, and a rewrite satisfies it by +# saying the thing in any of these ways. +INDEX_STRENGTHS = { + "that rules bind": ("rules bind",), + "that preferences guide": ("preferences guide",), + "that lessons inform": ("lessons inform", "lessons are", "a lesson informs"), + "that a preference is kept current": ("current",), +} + + +def test_the_index_names_all_three_strengths(): + text = " ".join(_instructions_text().split()).lower() + missing = [claim for claim, family in INDEX_STRENGTHS.items() + if not any(t in text for t in family)] + assert not missing, ( + f"_INSTRUCTIONS no longer says {missing}. It is the only guidance every " + f"client receives, and a record kind it does not name is one the session " + f"has to guess the force of — which it does by reaching for the kind it " + f"does know. Keep it to a clause; the full statement lives on " + f"using-scribe (decision #4027)." + ) + + +def test_the_strength_guard_can_fail(): + """Rule 167. The guard has to be able to bite, and it bites on omission — + the failure that actually happens, when a kind is added to the product and + the index is left describing the corpus that came before it.""" + text = "rules bind; preferences guide and you keep them current." + missing = [claim for claim, family in INDEX_STRENGTHS.items() + if not any(t in text for t in family)] + assert missing == ["that lessons inform"] + + # ── Reporting back (milestone 409 step 3) ────────────────────────────── # # The reporting-back skill carries the shapes, but a skill only helps if it diff --git a/tests/test_rule_creation_asks_first.py b/tests/test_rule_creation_asks_first.py index c1cf60a..60ad443 100644 --- a/tests/test_rule_creation_asks_first.py +++ b/tests/test_rule_creation_asks_first.py @@ -76,6 +76,20 @@ _ELEMENTS = { ), "how it would be enforced": ("enforc",), "the answers offered back": ("as written", "talk about it", "discuss"), + # The fourth and fifth answers (#3733, #3896). They are what makes the + # question a ROUTING question rather than a yes/no one: an observation + # that turns out to be a preference or a lesson has found its home, not + # failed to become a rule. Written in one pass, because two people each + # adding one branch to a three-way distinction produce a list that does + # not read as a set. + "the preference branch": ("create_preference", "a preference"), + "the lesson branch": ("create_lesson", "a lesson"), + # And the test that routes BEFORE the proposal is drafted, so the kind is + # a question asked out loud rather than one settled silently. + "the force question": ( + "what happens if someone doesn't", "force is the axis", + "a rule binds, a preference guides", + ), }