diff --git a/src/scribe/mcp/tools/notes.py b/src/scribe/mcp/tools/notes.py index 223ac18..d8b3931 100644 --- a/src/scribe/mcp/tools/notes.py +++ b/src/scribe/mcp/tools/notes.py @@ -107,6 +107,17 @@ async def create_note( ) -> dict: """Create a new note in Scribe. + WHAT ELSE COULD HOLD THIS? A note is the right home when the answer is + "nothing": it records what you know, nobody owes anything on it, and + nothing enforces it. Otherwise — + - someone has to DO something -> create_task. A note titled "we should…" + 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. + - 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. + Args: title: Note title (required). body: Markdown content. Supports [[wikilinks]] to other notes by title. diff --git a/src/scribe/mcp/tools/processes.py b/src/scribe/mcp/tools/processes.py index e3994b6..df755be 100644 --- a/src/scribe/mcp/tools/processes.py +++ b/src/scribe/mcp/tools/processes.py @@ -54,6 +54,12 @@ async def create_process( ) -> dict: """Create a stored process (a reusable saved prompt). + FOLLOWED, OR READ? A process is invoked deliberately and worked through + start to finish. If it should apply whether or not anyone invokes it, it + is a rule (create_rule) — that is the whole difference between a procedure + and a standing instruction. If it is knowledge to consult rather than + steps to execute, it is a note (create_note). + AUTHOR IT AS A SHAPE, NOT A SCRIPT. A process's value is the accumulated procedure — the steps, the taxonomy, the quality bar, the failure modes worth guarding. It must not force anything the invoking conversation diff --git a/src/scribe/mcp/tools/rulebooks.py b/src/scribe/mcp/tools/rulebooks.py index ee5da8e..a5c2dde 100644 --- a/src/scribe/mcp/tools/rulebooks.py +++ b/src/scribe/mcp/tools/rulebooks.py @@ -407,6 +407,13 @@ async def create_project_rule( the rule is returned in get_project's applicable_rules (under project_rules) and in list_rules(project_id=...). + 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 + standard is a design system; a procedure is a process (create_process); + reusable code is a snippet (create_snippet). Each of those is structure a + tool can resolve, render and check, where a rule is only prose someone + has to remember and apply. + ONE RULE = ONE THING YOU COULD VIOLATE — see create_rule. A rule that STRICTENS or REPLACES an inherited one is not a fresh rule: write it, then relate_rules(kind="overrides") to the rule it supersedes, so the pair stays diff --git a/src/scribe/mcp/tools/snippets.py b/src/scribe/mcp/tools/snippets.py index ef61aec..faf7d5b 100644 --- a/src/scribe/mcp/tools/snippets.py +++ b/src/scribe/mcp/tools/snippets.py @@ -115,6 +115,13 @@ async def create_snippet( """Record a shape in the project's pattern library, so every later instance starts from it instead of re-deriving it. + IS THE SHAPE THE POINT, OR THE ADVICE? A snippet is code with a LOCATION — + that is what lets it surface from the file someone is about to edit. If + what wants recording is a standing instruction about how to work, it is a + rule (create_rule); a procedure followed start to finish is a process + (create_process); what you LEARNED rather than what to copy is a note + (create_note). + Reach for this the FIRST time any shape is built — a component, a control, a route handler, a service class, a helper, a test scaffold — not only when something is judged "reusable": the builder of the first instance diff --git a/src/scribe/mcp/tools/tasks.py b/src/scribe/mcp/tools/tasks.py index c0221e1..3a17aef 100644 --- a/src/scribe/mcp/tools/tasks.py +++ b/src/scribe/mcp/tools/tasks.py @@ -135,6 +135,13 @@ async def create_task( ) -> dict: """Create a new task in Scribe. + IS ANYTHING ACTUALLY OWED? A task carries a status and someone is on the + hook to move it. If nothing is owed — you are recording what you learned, + decided or observed — that is a note (create_note), and filing it here + leaves a to-do nobody will ever close. If the work is an ARC of several + steps toward one goal, start_planning makes the milestone that holds + them; a task is one step, not the plan. + Args: title: Task title (required). body: Markdown description / notes for the task. @@ -323,7 +330,9 @@ async def start_planning(project_id: int, title: str) -> dict: Reach for this when the work has an ARC — several steps toward one goal, worth tracking as a unit. Work without one (a fix, a one-file change, a question answered) is a task, not a plan: create_task, drive its status, and - record progress with add_task_log. A milestone holding a single step is + record progress with add_task_log. A design or decision you are RECORDING + rather than executing is a note (create_note) — a plan nobody is going to + work through is a document filed in the place reserved for open work. A milestone holding a single step is ceremony, and it leaves the project with a plan that never meant anything. Creates a MILESTONE that IS the plan: its `body` is seeded with a design diff --git a/tests/test_create_tools_disambiguate.py b/tests/test_create_tools_disambiguate.py new file mode 100644 index 0000000..360009c --- /dev/null +++ b/tests/test_create_tools_disambiguate.py @@ -0,0 +1,95 @@ +"""Every create_* tool says what it is NOT for (#3123). + +WHY THIS EXISTS + +Scribe's record kinds are reached for interchangeably — a note written where +a task was owed, a rule written where a snippet belonged — and the moment of +choice is the only moment a correction is cheap. The tool docstring IS the +agent-facing contract (rule 119 puts product guidance there and nowhere +else), so a docstring that only documents parameters answers "how do I call +this" while leaving "should I be calling this at all" unasked. + +The gap was lopsided before this: `create_rule` and `start_planning` both +carried a real disambiguator, and `create_note` — far and away the +highest-volume surface — carried none. Guidance sat in the rarest tool and +was missing from the most common one. + +WHAT THIS PINS, AND WHAT IT DOES NOT + +It asserts STRUCTURE, never wording: each create surface must name at least +two sibling surfaces, so a caller who reached for the wrong one is told +where the right one is. Prose stays free to be rewritten — pinning phrasing +would make every improvement a test failure, and a test that punishes +editing is a test that gets deleted. + +It cannot tell whether the guidance is any GOOD. It only catches the +regression that actually happens: a docstring rewritten down to its +parameters, with the "is this even the right tool" paragraph quietly gone. +""" +import re + +import pytest + +# 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. +_SURFACES = [ + ("scribe.mcp.tools.notes", "create_note"), + ("scribe.mcp.tools.tasks", "create_task"), + ("scribe.mcp.tools.tasks", "start_planning"), + ("scribe.mcp.tools.snippets", "create_snippet"), + ("scribe.mcp.tools.processes", "create_process"), + ("scribe.mcp.tools.rulebooks", "create_rule"), + ("scribe.mcp.tools.rulebooks", "create_project_rule"), +] + +# What a caller could have wanted instead. A surface naming two of these has +# pointed somewhere; naming none has left them where they were. +_ALTERNATIVES = [ + "create_note", "create_task", "create_rule", "create_project_rule", + "create_snippet", "create_process", "start_planning", "design system", +] + + +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.""" + doc = _doc(module, name) + named = { + alt for alt in _ALTERNATIVES + if alt != name and re.search(re.escape(alt), doc, re.IGNORECASE) + } + assert len(named) >= 2, ( + f"{name}'s docstring names {sorted(named) or 'no'} alternative " + f"surface(s). A caller who reached for it by mistake gets no " + f"correction. Say what belongs elsewhere and why — see create_rule " + f"or create_note for the shape." + ) + + +@pytest.mark.parametrize(("module", "name"), _SURFACES) +def test_a_create_surface_still_documents_its_arguments(module, name): + """The guard above must not be satisfiable by deleting the parameter docs. + + Both halves matter and they pull in opposite directions: a docstring can + be made to pass the disambiguator check by becoming an essay about the + other tools, which would be a worse contract than the one being fixed. + """ + assert "Args:" in _doc(module, name), ( + f"{name}'s docstring lost its Args: block — the parameter contract is " + f"what a caller reads to make the call at all." + )