diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 7f68734..eafde1b 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.47", + "version": "0.1.48", "author": { "name": "Bryan Van Deusen" }, diff --git a/plugin/skills/using-scribe/SKILL.md b/plugin/skills/using-scribe/SKILL.md index 9d1faa5..bb498c3 100644 --- a/plugin/skills/using-scribe/SKILL.md +++ b/plugin/skills/using-scribe/SKILL.md @@ -106,6 +106,30 @@ Two constraints on *how* that's achieved: re-measurement, a reversed decision), pass the old id in `supersedes` so the stale record is demoted and labelled rather than left competing. +9. **A few notes assert a FACT, and those can carry their own check.** + Supersession only fires once somebody has read a note and disagreed — which + is the case where it was already believed. A note asserting something about + *someone else's* software — what a service does on a duplicate upload, how a + forge numbers its CI runs, what an updater compares — can instead carry + `verify_with` (how to check it) and `expires_when` (the STATE that ends it: + "when the forge numbers runs per workflow", never "in six months"). + `notes_due_for_verification` lists them least-recently-confirmed first, with + never-checked at the top; `mark_note_verified` records what you found, and + `still_true=False` deliberately writes nothing — a note whose check failed + is wrong rather than in a state worth recording, so it keeps its place. + + **The test is one question: could this note become false without anyone + editing it?** If no, leave both fields empty. That is the normal case, and + an empty `verify_with` is the positive marker for "this is a decision, there + is nothing to go and check" — not an unfinished record. The sweep is only + worth reading while almost nothing is on it, so a check added out of + tidiness costs the whole surface, not just that note. + + Not for tasks — a task's decay is its status, and a done issue records what + happened rather than asserting something that can go false. Not for snippets + either: `verify_snippet` compares the recorded location and code against the + repo, which is richer and already wired to drift detection. + ## Stay inside the active project's scope Once a project is in scope — you called `enter_project`, or the working repo is diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index f371fbf..ad2e0a2 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -30,6 +30,21 @@ from quart import Quart # duplicate gate, the untagged-record systems_hint) act in-band in tool # responses, at the moment they apply. # Grow one of those, not this block. +# BUDGET: ~1980 of the client's ~2048-char cap (#2562). Everything below is +# competing for the last ~68 characters, so an addition here is a trade, never +# an append. +# +# Milestone 317 (a note's own verify_with / expires_when, and the sweep over +# them) was DECLINED a line, deliberately, by the operator — not overlooked. +# The reasoning, so it is not re-litigated blind: this is a map, and its own +# closing line says each tool's description carries the full contract. The +# sweep is a curation act, not a session-start reflex like enter_project or +# list_always_on_rules. Spending the last of the budget on it would leave the +# map unable to grow for something more central later. +# +# 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. _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 diff --git a/src/scribe/mcp/tools/notes.py b/src/scribe/mcp/tools/notes.py index 287490e..9b0aa9f 100644 --- a/src/scribe/mcp/tools/notes.py +++ b/src/scribe/mcp/tools/notes.py @@ -224,10 +224,12 @@ async def update_note( supersedes: Replace the ids of earlier notes this one replaces (set-semantics). None = leave unchanged; [] = clear all. See create_note for when to reach for it. - verify_with: How to check this note is still true. See create_note for - the norm-vs-constraint test that decides whether it should carry - one at all; the short form is "could this become false without - anyone editing it?". + verify_with: How to check this note is still true. Almost every note + should leave this empty — that is the normal, finished state, not + a gap: an empty check is the marker for "this is a decision, there + is nothing to go and check". See create_note for the full + norm-vs-constraint test; the short form is "could this become + false without anyone editing it?". expires_when: The STATE that ends it, not a date. clear: Names of fields to UNSET — "verify_with", "expires_when". Needed because "" means "leave this alone" here, so there is no diff --git a/tests/helpers.py b/tests/helpers.py index 0b32065..45ac4a0 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -38,6 +38,27 @@ async def drive_update_note(note, **kwargs): return await update_note(user_id=7, note_id=note.id, **kwargs) +def tool_doc(module: str, name: str) -> str: + """An MCP tool's docstring, whitespace-flattened. + + Flattened because these are hard-wrapped at ~76 characters, so any phrase + worth asserting on is liable to straddle a line break — a property of the + formatter, not of the guidance. The disambiguator guard (#3123) learned + that on its own first run, matching raw text and reporting a phrase absent + that was plainly there. + + Used by every test that pins the docstring CONTRACT rather than its + wording. The tool docstring is the agent-facing contract (rule 119), so + these guards exist to catch it being tidied down to a parameter list. + """ + import importlib + import re as _re + + fn = getattr(importlib.import_module(module), name) + assert fn.__doc__, f"{name} has no docstring at all" + return _re.sub(r"\s+", " ", fn.__doc__) + + def compiled_sql(element) -> str: """A SQLAlchemy clause or statement rendered as literal SQL text. diff --git a/tests/test_create_tools_disambiguate.py b/tests/test_create_tools_disambiguate.py index 360009c..0089707 100644 --- a/tests/test_create_tools_disambiguate.py +++ b/tests/test_create_tools_disambiguate.py @@ -29,6 +29,7 @@ parameters, with the "is this even the right tool" paragraph quietly gone. import re import pytest +from tests.helpers import tool_doc as _doc # The create surfaces and where they live. `start_planning` is here because # it is a create in everything but name — it is how a plan comes into being. @@ -50,21 +51,6 @@ _ALTERNATIVES = [ ] -def _doc(module: str, name: str) -> str: - """The docstring with its whitespace flattened. - - Flattened because a docstring is hard-wrapped: "design system" spans a - line break in at least one of these, and matching the raw text would - report it absent. The first draft of this check did exactly that, and - caught it on itself. - """ - import importlib - - fn = getattr(importlib.import_module(module), name) - assert fn.__doc__, f"{name} has no docstring at all" - return re.sub(r"\s+", " ", fn.__doc__) - - @pytest.mark.parametrize(("module", "name"), _SURFACES) def test_a_create_surface_names_at_least_two_alternatives(module, name): """Reaching for the wrong tool must still put the right one in view.""" diff --git a/tests/test_verification_guidance_survives.py b/tests/test_verification_guidance_survives.py new file mode 100644 index 0000000..aa7fc45 --- /dev/null +++ b/tests/test_verification_guidance_survives.py @@ -0,0 +1,108 @@ +"""The write surfaces say WHEN a note earns a check — not just that it can. + +WHY THIS EXISTS + +`verify_with` is a free-text field on the highest-volume record kind in the +product. A field described only as "how to verify this note" gets filled in on +every note within a week, and at that point the sweep returns the whole corpus +and means nothing. The signal is not "has a check" — it is "has a check AND +almost nothing else does". + +So the guidance is not decoration on this feature, it IS the feature's +precondition, and rule 119 puts it in the tool docstrings and the skill rather +than in a Scribe rule. That makes it exactly the kind of prose a later +docstring tidy-up deletes without noticing what it was for. + +WHAT THIS PINS, AND WHAT IT DOES NOT + +Structure, never wording, for `test_create_tools_disambiguate`'s reason: a test +that punishes rewriting is a test that gets deleted. Each write surface must +still (a) draw the norm-vs-constraint distinction in some form, (b) say the +empty case is normal, and (c) name where NOT to reach for it. + +It cannot tell whether the guidance is any good — only that the paragraph +explaining when to leave the field alone has not quietly become a parameter +list. +""" +import re + +import pytest +from tests.helpers import tool_doc as _doc + +# The surfaces that OFFER the field. The read surfaces (the sweep, the stamp) +# explain what a RESULT means, which is a different job — they are deliberately +# not held to this. +_WRITE_SURFACES = [ + ("scribe.mcp.tools.notes", "create_note"), + ("scribe.mcp.tools.notes", "update_note"), +] + + +@pytest.mark.parametrize("module,name", _WRITE_SURFACES) +def test_the_norm_versus_constraint_distinction_is_stated(module, name): + """The whole discipline in one line: a decision cannot go stale, a claim + about someone else's software can. Without it, "how would you check this" + reads as a chore to complete rather than a question with a usual answer of + "you wouldn't".""" + doc = _doc(module, name).lower() + assert "norm" in doc and "constraint" in doc, ( + f"{name} no longer draws the norm-vs-constraint distinction. Without " + f"it the field is just a box, and a box gets filled in." + ) + + +@pytest.mark.parametrize("module,name", _WRITE_SURFACES) +def test_the_empty_case_is_stated_as_normal(module, name): + """Said POSITIVELY, or an empty field reads as an unfinished record. This + is the single sentence standing between the sweep and irrelevance.""" + doc = _doc(module, name).lower() + assert re.search(r"leave (it|both|them|this|these)? ?empty|empty for", doc), ( + f"{name} no longer says that leaving the check empty is the normal " + f"case. Most notes are decisions; the field's default must read as a " + f"deliberate state, not a gap." + ) + + +@pytest.mark.parametrize("module,name", _WRITE_SURFACES) +def test_the_wrong_places_to_reach_for_it_are_named(module, name): + """A task's decay is its status; a snippet has verify_snippet. The service + refuses both — this is what should mean nobody ever hits that error.""" + doc = _doc(module, name).lower() + assert "task" in doc and "snippet" in doc, ( + f"{name} no longer names the records that must NOT carry a check. The " + f"gate still refuses them, but a refusal the caller could have " + f"foreseen is a worse door than one that explained itself." + ) + + +def test_expires_when_is_described_as_a_state_not_a_date(): + """The one field whose obvious reading is wrong. A date invents a staleness + schedule nobody can justify; a constraint expires when the ground moves, + which is a condition and not a time.""" + doc = _doc("scribe.mcp.tools.notes", "create_note") + assert "STATE" in doc, ( + "create_note no longer says expires_when is a STATE. Left to itself, " + '"expires" reads as a date, and every check would get an arbitrary one.' + ) + + +def test_the_skill_carries_the_test_a_writer_can_actually_apply(): + """The docstrings are read by whatever is holding the tool; the skill is + read while deciding what to write. The one-question form has to be in the + second place too, or the guidance only reaches callers who already opened + the tool.""" + import pathlib + + skill = pathlib.Path(__file__).resolve().parents[1] / ( + "plugin/skills/using-scribe/SKILL.md" + ) + text = " ".join(skill.read_text().split()) + assert "could this note become false without anyone editing it" in text.lower(), ( + "the using-scribe skill no longer carries the one-question test. That " + "question is what makes the distinction applicable rather than merely " + "true." + ) + assert "notes_due_for_verification" in text, ( + "the skill names the fields but not the surface that reads them — " + "guidance for writing a check with no route to acting on one." + )