diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index c4d53f2..a76f342 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.25", + "version": "0.1.26", "author": { "name": "Bryan Van Deusen" }, "mcpServers": { "scribe": { diff --git a/plugin/hooks/scribe_static_context.md b/plugin/hooks/scribe_static_context.md index a92049b..1a3426e 100644 --- a/plugin/hooks/scribe_static_context.md +++ b/plugin/hooks/scribe_static_context.md @@ -37,6 +37,14 @@ for the operator's work, and as your own working memory across sessions. moment it's complete. When you **fix** something — even in passing — record it as its own issue (`create_task(kind="issue")`), not as a work-log line on an unrelated open task. +- **Tag to Systems as you write** — `enter_project` lists the project's + Systems (its named subsystems/areas). When you create or meaningfully update + a record, ask which area it is about and pass `system_ids`; if the area has + no System yet, create it with `create_system` (name + a one-paragraph + charter) rather than leaving it unmodelled. A record about no particular + area takes none — don't force it. Untagged writes in a project that has + Systems come back with a `systems_hint` naming them — treat it as the tagging + question asked at exactly the right moment, not as noise to skip past. - **Reuse before rebuilding** — before writing a new helper/utility/component, search recorded **snippets** (reusable code recorded once for recall) and reuse the prior art instead of re-solving it; when you build something @@ -51,6 +59,14 @@ for the operator's work, and as your own working memory across sessions. `/compact` (name what you logged). You can't run it yourself — surface the recommendation and let them decide. Suggest it at seams, not every turn. +**How the instruction surfaces divide the work:** this file carries the +session-level reflexes (WHEN to reach for Scribe); each tool's own description +carries its full contract (HOW to call it — read it when you load the tool); +the bundled skills carry process arcs (planning, debugging, verification). The +MCP server's instruction block is deliberately only a map — the client injects +roughly its first 2,000 characters and silently cuts the rest, so nothing +load-bearing lives below that fold. + **If two Scribe instruction surfaces disagree** — this file, the MCP server's tool instructions, the `using-scribe` skill — **follow the one that assumes least about its own delivery.** This file is the floor: it ships with the diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index fd46b14..be0653b 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -5,306 +5,64 @@ from mcp.server.fastmcp import FastMCP from mcp.server.transport_security import TransportSecuritySettings from quart import Quart +## The delivery budget — read before editing this block +# +# Claude Code injects only the FIRST ~2,048 CHARACTERS of an MCP server's +# instructions into the system prompt; the rest is silently cut mid-word +# (#2562 — the cut was observed live at exactly offset 2,048, and ~90% of the +# previous 20k-char version of this block never reached any session). So this +# block is deliberately a MAP, not a manual, and a test pins it under the +# fold (test_instruction_surfaces_agree.py::test_instructions_fit_the_fold). +# +# Where the detail lives instead — each surface has one job: +# - Tool docstrings: the per-tool HOW. Delivered with the tool schema, at +# reach-for time when the client defers tools. Guidance about one tool +# belongs there, not here. +# - Plugin static context (plugin/hooks/scribe_static_context.md): the +# session-level reflexes (recall-first, record-as-you-go, tag-to-Systems, +# compaction). Always delivered in full; needs no key and no network. +# - Plugin skills: process arcs (planning, debugging, verification…). +# Their listing line is the always-visible trigger; the body loads on +# match. Stored Processes become skills via /scribe:sync. +# - The server itself: behaviors prose can't be trusted to fire (the +# 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. _INSTRUCTIONS = """ -Scribe is the user's self-hosted second-brain and project-management data -store, and your own system of record for their work. You (Claude) are the -assistant: record what you do here — tasks, work-logs, decisions, notes — and -recall from here before acting. Do not keep the user's project work in local -files (CLAUDE.md, scratch/auto memory) in parallel; Scribe holds the single copy. +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 +in local files (CLAUDE.md, auto-memory); Scribe holds the single copy. -Hierarchy: Project -> Milestone -> Task/Note. +Hierarchy: Project -> Milestone -> Task/Note. The map, by purpose: +- ORIENT: enter_project(id) at session start — rules, open tasks, recent + notes, Systems and design system in one call. +- DO: create_task. Fixed a problem? kind="issue" (symptom -> root cause -> + fix), never a work-log line on an unrelated task. Log with add_task_log; + keep status honest — in_progress on start, done on finish. +- PLAN work with an arc: start_planning. The plan IS a milestone; each step is + a child task, not a checkbox. No local plan .md files. +- CAPTURE: create_note. RECALL: search first, before answering about the + operator's work or opening a task — assume prior art exists, and pass the + 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 to work: rules are pull-only and binding — call list_always_on_rules() + yourself at session start; a push that also delivered them was an + optimisation, not the bridge. +- 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 + create_snippet. Saved procedures are Processes (follow verbatim). Deletes + are trash-recoverable. -What each part is for, and when to reach for it: -- Project: the top-level container for a body of work. -- Milestone: groups related tasks within a project toward a goal (status - active/done). A milestone is ALSO the home of a plan — its `body` holds the - design/intent (Goal/Approach/Verification) and its child tasks are the steps. - Use one when a chunk of work needs its own arc. -- Task: a unit of actionable work with a lifecycle (status - todo/in_progress/done/cancelled, optional priority). A task is a note with a - status — reach for one when there is something to DO. Record progress over - time with work-logs (add_task_log) rather than rewriting the body. -- Issue: a task whose kind is corrective — a problem you fixed or are fixing, as - opposed to productive `work`. Create it with create_task(kind="issue"); the - body carries symptom → root cause → fix. It has the full task lifecycle, and - can link the originating task it arose from (arose_from_id) and the System(s) - it touches (system_ids). Reach for one whenever you fix something — even in - passing — instead of burying the fix in another task's work-log. -- Plan: a MILESTONE acting as a plan container — HOW you'll execute a chunk of - work. The design/intent lives in the milestone `body`; each step is its own - child task (create_task(milestone_id=...)), tracked with status + work-logs — - NOT a checkbox buried in the body. Create one with start_planning when the - work has an arc (same test as a milestone, above) and you want the approach - reviewable before you start; read it back with get_milestone (body + steps). - Work without an arc is a task, not a plan. (The old kind=plan task is retired - — some historical plan-tasks still exist and remain readable, but don't - create new ones.) -- Note: durable free-form knowledge — reference material, decisions, logs of - what happened. - No lifecycle, not actionable. Reach for one to CAPTURE something worth keeping. -- Design system: the visual standards a project's UI is built from — design - tokens (name + value per mode) plus the prose a token table cannot hold - (aesthetic, voice, what is out of scope). Systems INHERIT: a child holds only - what it changes and the chain supplies the rest, so a family's house style and - one app's departures from it are the same structure at two depths. A project - points at one with set_project_design_system, and enter_project then hands it - back with the guidance chain-merged. Treat it as binding for UI work: reach - for a token (resolve_design_system / get_design_system_stylesheet) before - writing a colour, size, radius or duration by hand. Do NOT record a design - system as a rulebook — rules are for behaviour, and tokens kept as prose - cannot be resolved, inherited, rendered to a stylesheet, or checked against - code. -- System: a per-project, reusable, self-describing subsystem/area — the - project's vocabulary for WHERE work happens. enter_project returns the list. - TAG AS YOU WRITE: when you create or meaningfully update a note, task, or - snippet, ask which of those areas it is about and pass system_ids. The test: - would someone investigating that subsystem want this record in the pile - list_system_records returns? Cross-cutting records take several; a record - about no particular area takes none — don't force it. If the area a record - describes has no System yet, CREATE it (create_system: name + a one-paragraph - charter) and tag the record — a subsystem that exists in the code deserves a - System the moment two records would share it, the same two-or-more test - snippets use; don't wait to be asked to name an area that plainly exists. - Read a subsystem back with list_system_records, or search(system_id=...) for - a ranked cut. -- Reference note vs dev-log — STATE vs CHRONICLE. A dev-log records what - HAPPENED: write it once, never rewrite it. A durable finding — how a - subsystem works, a measured number, an architecture fact — belongs in that - System's REFERENCE NOTE ("«System name» — reference", tagged to the System), - which is UPDATED IN PLACE as the facts change. Updating loses nothing: every - meaningful edit is snapshotted (note versions are the changelog). Create the - reference note if the System lacks one; update it if it exists; have the - dev-log [[link]] it rather than restating state. State smeared across dated - logs is unreachable by search — sixteen near-identical dev-logs tie, and no - ranking can pick the right one, because no right one exists. +A task is a note with status; *_note tools for notes, *_task for tasks. +Creates are duplicate-gated: a near-match BLOCKS and returns the existing +id — update it, don't force. shared:true records are another user's — a +suggestion, not the operator's settled practice. -Mechanics: -- Notes and Tasks share a model; tasks are notes with is_task=True. -- Use the *_note tools for notes, the *_task tools for tasks. Don't mix them. -- Tags are plain strings (no `#` prefix). Empty list clears tags; omit to leave - unchanged on updates. -- For optional integer FKs (project_id, milestone_id, parent_id), use 0 to mean - "not set". On update_task, -1 clears an existing FK (e.g. milestone_id=-1 - removes the task from its milestone); 0 leaves it unchanged. - -Reach for Scribe to RECALL, not just to record. Scribe is a second brain — -its value is mostly in what it already holds, so make searching it a reflex, -not something you wait to be asked for: -- Before you answer a question about the user's work, or start a task, search - Scribe first (search / list_tasks / list_notes). Assume relevant prior work - already exists — a related task, an earlier decision, a prior note — and look - before you re-derive it or open a duplicate. -- Before creating a task, search for an existing one (search content_type= - 'task') — don't open a second task for work already tracked. -- create_note / create_task enforce this: if a title- or meaning-similar record - already exists in the same project, the call is BLOCKED and returns - {"duplicate": true, "existing_id": ...} instead of creating. UPDATE that - record (update_note / update_task / add_task_log) rather than duplicating. - Only pass force=true when it's genuinely a distinct record — a duplicate both - bloats the store and surfaces as a stale competing copy in later searches. -- When a note genuinely IS new but overtakes an older one, say so: pass the - older note's id in `supersedes` on create_note / update_note. Reach for it on - a re-measurement, a decision that reverses an earlier one, a dev-log covering - ground a previous one covered. The old note stays readable and keeps its - place; it stops competing for the same question and arrives labelled. This is - the third answer alongside update-instead and force: not everything that - resembles an existing record should be folded into it, and not everything - distinct should compete with it forever. If a result carries `superseded_by`, - a later note claims to have brought it up to date — read it as what was true - when written and open the newer one before acting. -- Scope to the project in scope. When a project is active (you called - enter_project), pass its project_id to search / list_tasks / list_notes so - results stay inside that project. Querying with no project_id pulls in every - project and bleeds unrelated work into the session — only do it for a - deliberate cross-project sweep. get_recent takes no project filter and spans - every project; when one is active, prefer the scoped list_* tools over it. - And this is not only about reads: once a project is in scope, only reference - or offer work on THAT project — don't surface or propose work from other - projects unless the operator widens scope. If something clearly belongs to a - different project, say so and ask before switching; never silently operate - cross-project. The active project does not stick on the server (each call is - self-contained); carrying its id forward is on you. - -Keep task state honest — this is what makes the project a trustworthy record: -- When you begin working a task, set it to in_progress (update_task - status=in_progress). -- Log progress as you go with add_task_log — at meaningful steps, not saved up - for the end. -- The moment a task's work is complete, set it done. Never leave finished work - at todo/in_progress — an out-of-date status makes Scribe misrepresent what's - left to do. -- At a meaningful point — finishing a task, or hitting or discovering a problem - that changes direction — write a short dated note on the project (create_note) - capturing what happened (the pivots, not just the wins), and set the finished - task to done. -- When you fix a problem — even one solved in passing — record it as its own - issue (create_task(kind="issue")) with symptom → root cause → fix in the body, - NOT as a work-log line on whatever task happened to be open. An issue is - corrective work with its own lifecycle; recording it discretely (optionally - linked via arose_from_id to the task it came from, and system_ids to the - subsystem it touches) is what makes it findable so it isn't diagnosed from - scratch next time. - -Compaction hygiene — recommend compacting at clean seams. Because you record -progress as you go, a context compaction is SAFE: the durable state lives in -Scribe (task status, work-logs, decision notes), not the transcript, so it -survives the summary. Use this rather than letting auto-compaction fire mid-task: -- At the end of a coherent block of work (a task closed, a plan phase finished) - in a long session, first make sure in-flight state is actually in Scribe — - update task status, add a work-log, capture any decision as a note. Surface - the few things worth logging before suggesting the compact. -- Then tell the operator it's a good, safe moment to /compact, naming what you - logged ("logged to #X/#Y — safe to /compact, nothing will be lost"). You - cannot run /compact yourself; surface the recommendation and let them decide. -- Recommend it at genuine seams, not every turn. The next session's start will - prompt you to reload your bearings from Scribe — so a clean-seam compact plus - that reload loses nothing. - -Scribe maintains a Rulebook system (Rulebook -> Topic -> Rule). Rules carry -an actionable statement plus optional Why and How-to-apply context. At the -start of any session that touches Scribe, call list_always_on_rules() to -load the standing rules — treat them as binding. When you also have a project -in scope, get_project(id) returns applicable_rules (rules from rulebooks the -project subscribes to) and subscribed_rulebooks; consult those too. Full text -(Why / How-to-apply) is available via get_rule(id). - -Workflow and standards rules live in Scribe. When you notice a pattern -worth codifying, call create_rule (cross-project, lands in a rulebook+topic) -or create_project_rule (one project only, no rulebook ceremony). Do NOT add -new engineering rules to CLAUDE.md or to ~/.claude/.../memory/feedback_*.md -— those stores are reserved for facts about the user (preferences, role, -communication style) and codebase onboarding pointers, respectively. Before -creating a rule, call list_always_on_rules and list_rules(project_id=...) to -avoid duplicates. - -Choose a rule's home by WHO it should bind, and keep each home's rules at the -right altitude: - - Always-on rulebook (a rulebook flagged always_on) — universal norms that - bind EVERY one of your projects. Reserve for cross-project standards. - - Subscribed rulebook (always_on off; projects opt in via - subscribe_project_to_rulebook) — a reusable, THEMED module of general - rules that binds only the projects which subscribe. Its rules must make - sense for every project that could subscribe, never one specific project - (e.g. a code-review checklist, or a compliance regime a category of - projects shares — no rule names a single app). - - Project rule (create_project_rule) — anything specific to ONE project. -Both rulebook tiers are SHARED, so their rules stay general; the difference -between them is REACH (all projects vs opt-in by theme), not generality. Rule -of thumb: names a specific project's files/paths/quirks -> project rule; a -standard a CATEGORY of projects shares -> subscribed rulebook; a universal -norm -> always-on rulebook. Coordinate with the operator on which home fits. - -Before writing a rule, check whether another entity already models the thing. -A rule is prose an agent must remember and apply; the other entities are -structure a tool can resolve, render and check. Visual standards are a DESIGN -SYSTEM, not a rulebook — a token can be inherited, resolved per mode, rendered -to a stylesheet and diffed against code, 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 genuinely is a standing instruction -about how to work, and nothing else can hold it. - -Rules are pull-only: CALL list_always_on_rules() YOURSELF at the start of a -session, before substantive work. Do not wait to be handed them. The plugin's -SessionStart hook also pushes the always-on rules + active-project context, but -that is a delivery optimisation, not the bridge — it can be absent (instance -unreachable, key not configured, or the hook itself silently inert, which is -what #2198 was) and its absence does not announce itself. When the push did -arrive, the pull costs one redundant call; when it didn't, skipping the pull -means working without the operator's binding rules and having no signal that -you are. Those two costs are not comparable, so pull. - -One thing NOT to do: don't bridge Scribe into a session by writing to the -host's native memory. That explicit pull IS the bridge — it is written into -every instruction surface the session already loads, so it needs no pointer on -disk. So do NOT create or refresh a "rules live in Scribe" pointer in -CLAUDE.md / AGENTS.md / ~/.claude memory, and do NOT keep rules, recall, or -plans in those stores in parallel with Scribe — Scribe holds the single copy, -and a pointer Scribe cannot update is one that goes stale without anyone -noticing. Native auto-memory stays for facts about the user; CLAUDE.md for -codebase onboarding. Never make Scribe's correctness depend on the operator -disabling a native function (e.g. autoMemoryEnabled): the plugin must work with -auto-memory at its default. If the plugin is ever removed the session loses both -the push and these instructions, and rebuilds context over time — an acceptable -cost, and far better than a silent settings change the operator may not know -about. - -When you are working on a specific project, call enter_project(project_id) -ONCE at session start (or whenever the active project changes). It returns the -project, its applicable_rules + project_rules + subscribed_rulebooks, milestone -summary, open tasks, and recent notes — everything you need to know the lay of -the land before mutating. Don't call get_project + get_applicable_rules + a -search separately when enter_project already composes them. - -Don't wait to be told which project you're in. At the start of a session that -touches Scribe — or the moment work clearly belongs to a project but none is in -scope — bootstrap project context proactively: search for a related existing -project (search / list_projects, matching on the work's subject, the repo or -directory name, and recent activity). If you find a confident match, propose it -and call enter_project once the operator confirms. If nothing matches, offer to -create a project, confirming its name and goal first. Always confirm before -adopting or creating — never do either silently, and never guess a project into -existence. Once a project is in scope, the enter_project handshake and the -host-memory pointer step above both apply. - -When work DOES get a plan, Scribe is the plan's canonical home: it is a -milestone (see the Plan entry above), created with start_planning and written -into with update_milestone + child tasks. If a habit tells you to save a plan or -spec to a local `.md` file, that's superseded here — the milestone is the -record, not a file on disk. Whether a given piece of work wants a plan at all is -a separate question, answered by the arc test above and by the writing-plans -skill; these instructions do not mandate one. - -Deletes are recoverable: every delete_* tool moves the entity (and its -descendants) to the trash and returns a deleted_batch_id. Use list_trash() to -see trashed batches, restore(deleted_batch_id) to undo a deletion, and -purge_trash(deleted_batch_id, confirmed=True) for a permanent delete. Trash -auto-purges after the operator's retention window. - -Scribe stores reusable Processes — saved prompts/workflows (note_type -"process"), e.g. a drift audit or a DRY pass. When the operator says "run the -X process" or otherwise references a saved process, call list_processes() / -get_process(name) and follow the returned prompt verbatim, including any -"clarify first" steps it contains. Author a new one with create_process(title, -body); edit with update_process; retire one with delete_process (recoverable — -it goes to the trash like anything else). A near-duplicate is refused at create -time, because every Process becomes a skill file that auto-surfaces on the -operator's machine: two near-identical procedures don't merely bloat the record, -they compete to be followed. - -Scribe also stores Snippets — reusable functions/components recorded once for -recall (note_type "snippet"): a name, language, signature, canonical location -(repo · path · symbol), a one-line "when to reach for it", and the code. They -are ordinary embedded notes, so a recorded snippet also surfaces through the -same search + proactive recall as everything else. Two reflexes: (1) before you -write a new helper/utility/component, search first (list_snippets(q=...) or -search) — reuse the prior art with get_snippet(id) instead of re-deriving a -one-off; (2) the moment you build or notice something reusable, record it with -create_snippet(name, code, when_to_use, language, signature, repo, path, symbol, -project_id, system_ids) so a later session is offered it. Make when_to_use sharp -— it becomes the title, which is what a recall menu shows. Edit an existing one -with update_snippet rather than recording a second copy; when the same reusable -thing already exists as several one-offs, unify them into one canonical record -with merge_snippets (it folds every call site in as a location and trashes the -duplicates). Keep the record honest: a snippet whose details have gone stale can -be corrected with update_snippet (an empty string clears a field), and one that -is wrong or obsolete should be retired with delete_snippet — a bad snippet keeps -being offered as prior art, which costs more than none at all. - -Scribe is multi-user, so some records belong to other people. Anything another -user owns comes back marked `shared: true` with an `owner`. Treat a shared -record as THAT PERSON'S SUGGESTION, never as the operator's settled practice: -weigh it on its merits, attribute it when you reference it, and ask before -adopting it or acting on it. This matters most for a shared Process — do not run -one as written; describe what it would do and get the operator's go-ahead. -Records shared directly with the operator are also deliberately search-only: -they surface when you look for them (pass a query), not in plain lists, so -nobody else's material arrives unasked. Editing another user's record needs an -editor or admin share from them; a read-only share is refused, and the right -answer is usually to record the operator's own version rather than to push. - -When developing Scribe itself, honor its multi-user sharing ACL: scope every -read and mutation of user data by owner + shares — never assume a single -operator. "Works for one user" is not done. +This is only a map — the client injects ~2k chars and cuts the rest. Each +tool's description carries its full contract: read it when you load the +tool, and trust it over habit. """ diff --git a/src/scribe/mcp/tools/design_systems.py b/src/scribe/mcp/tools/design_systems.py index 63bb7e3..c7370e8 100644 --- a/src/scribe/mcp/tools/design_systems.py +++ b/src/scribe/mcp/tools/design_systems.py @@ -39,6 +39,12 @@ async def create_design_system( ) -> dict: """Create a design system, optionally inheriting from another. + This — not a rulebook — is where visual standards live: a token can be + inherited, resolved per mode, rendered to a stylesheet and checked against + code, and none of that survives being written as rule prose. Once a + project points at one (set_project_design_system), treat it as binding for + that project's UI work. + Args: title: What this system is — a house style, or one app within it (required). diff --git a/src/scribe/mcp/tools/notes.py b/src/scribe/mcp/tools/notes.py index f1de8b5..c4d2d0b 100644 --- a/src/scribe/mcp/tools/notes.py +++ b/src/scribe/mcp/tools/notes.py @@ -14,6 +14,7 @@ Sentinel conventions (inherited from existing fable-mcp tools): from __future__ import annotations from scribe.mcp._context import current_user_id +from scribe.mcp.tools import systems as systems_tools from scribe.services import access as access_svc from scribe.services import dedup as dedup_svc from scribe.services import notes as notes_svc @@ -149,7 +150,10 @@ async def create_note( Returns the created note object including its assigned id, OR — when a near-duplicate is found and force is false — {"duplicate": true, - "existing_id": ..., "message": ...} and nothing is created. + "existing_id": ..., "message": ...} and nothing is created. Created + untagged in a project that has Systems, the response carries a + `systems_hint` naming them — answer it: tag the record, create the missing + System, or deliberately leave it untagged. """ uid = current_user_id() if not force: @@ -180,6 +184,10 @@ async def create_note( data["systems"] = [ s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id) ] + elif project_id: + hint = await systems_tools.untagged_systems_hint(uid, project_id) + if hint: + data["systems_hint"] = hint await _attach_supersession(uid, note.id, data) return data diff --git a/src/scribe/mcp/tools/projects.py b/src/scribe/mcp/tools/projects.py index c8bfddc..7b6d971 100644 --- a/src/scribe/mcp/tools/projects.py +++ b/src/scribe/mcp/tools/projects.py @@ -70,6 +70,19 @@ async def enter_project(project_id: int) -> dict: departures from it) plus a summary of the token set — treat it as binding for any UI you write, and pull the values with resolve_design_system or get_design_system_stylesheet before reaching for a literal. + + Entering a project also SCOPES the session: reference and offer work on + this project only, and pass its id to search / list_* so results stay + inside it. If something clearly belongs to a different project, say so and + ask before switching — never silently operate cross-project. The active + project does not stick on the server (each call is self-contained); + carrying its id forward is on you. + + Don't wait to be told which project is in scope. When work clearly belongs + to a project but none is entered, look for a match yourself (list_projects + / search on the repo or subject), propose it, and enter it once the + operator confirms; if nothing matches, offer to create one — confirming + name and goal first, never guessing a project into existence. """ uid = current_user_id() project = await projects_svc.get_project(uid, project_id) diff --git a/src/scribe/mcp/tools/rulebooks.py b/src/scribe/mcp/tools/rulebooks.py index 55ff7dd..1ddf1b3 100644 --- a/src/scribe/mcp/tools/rulebooks.py +++ b/src/scribe/mcp/tools/rulebooks.py @@ -271,6 +271,14 @@ async def create_rule( rulebook+topic ceremony). If it's a standard a CATEGORY of projects shares, put it in a themed subscribed rulebook, not the always-on one. + Before writing a rule at all, check whether another entity already models + the thing. A rule is prose an agent must remember and apply; the others + are structure a tool can resolve, render and check. Visual standards are a + DESIGN SYSTEM (tokens inherit, resolve per mode, render to a stylesheet — + none of that survives being prose). A repeatable procedure is a PROCESS. + Reusable code is a SNIPPET. Reach for a rule only when the thing genuinely + is a standing instruction about how to work and nothing else can hold it. + Args: topic_id: The topic to attach the rule to. title: A short imperative title (e.g. "dev is home"). diff --git a/src/scribe/mcp/tools/snippets.py b/src/scribe/mcp/tools/snippets.py index 187792a..f4aec0c 100644 --- a/src/scribe/mcp/tools/snippets.py +++ b/src/scribe/mcp/tools/snippets.py @@ -11,6 +11,7 @@ on create, System association passthrough). from __future__ import annotations from scribe.mcp._context import current_user_id +from scribe.mcp.tools import systems as systems_tools from scribe.services import access as access_svc from scribe.services import dedup as dedup_svc from scribe.services import snippets as snippets_svc @@ -132,7 +133,9 @@ async def create_snippet( and it really is the same reusable thing found in another place, prefer merge_snippets(existing_id, [new...]) — or record then merge — to unify them into ONE canonical record (which then carries every call site as a location), - rather than forcing a second copy with force=true. + rather than forcing a second copy with force=true. Created untagged in a + project that has Systems, the response carries a `systems_hint` naming + them — answer it: tag, create the missing System, or deliberately skip. WHAT THE GATE MATCHES ON. Exact identity first — an existing snippet at the same repo · path · symbol, or holding byte-identical code. Those are certain, @@ -175,6 +178,10 @@ async def create_snippet( data["systems"] = [ s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id) ] + elif project_id: + hint = await systems_tools.untagged_systems_hint(uid, project_id) + if hint: + data["systems_hint"] = hint return data diff --git a/src/scribe/mcp/tools/systems.py b/src/scribe/mcp/tools/systems.py index af1cdd8..7e82482 100644 --- a/src/scribe/mcp/tools/systems.py +++ b/src/scribe/mcp/tools/systems.py @@ -16,6 +16,30 @@ from scribe.mcp._context import current_user_id from scribe.services import systems as systems_svc +async def untagged_systems_hint(user_id: int, project_id: int) -> str | None: + """Nudge text for a record created untagged in a project that has Systems. + + Not a tool. create_task / create_note / create_snippet attach this to + their responses so the tagging question arrives in-band at the exact write + it applies to — instruction prose alone demonstrably doesn't fire at write + time, while in-band behavior (the duplicate gate) does (#2562). + """ + # Fail-open like the dedup gate: a hint must never break a create. + try: + systems = await systems_svc.list_systems(user_id, project_id) + except Exception: + return None + if not systems: + return None + names = ", ".join(f"#{s.id} {s.name}" for s in systems) + return ( + f"Created untagged. This project's Systems: {names}. If this record is " + "about one of those areas, tag it (update it with system_ids=[...]); if " + "its area is missing, create_system it and tag; if it is about no " + "particular area, leave it untagged." + ) + + async def create_system( project_id: int, name: str, @@ -27,6 +51,13 @@ async def create_system( Associate records with it via the `system_ids` arg on create/update_task and create/update_note. + Create one the moment two records would share an area that has no System + yet — the same two-or-more test snippets use. Don't wait to be asked to + name an area that plainly exists in the code; an unmodelled area means + every record about it stays untaggable. Give it a one-paragraph charter, + not just a label: the description is what tells a later session whether a + record belongs here. + Args: project_id: The project this system belongs to (required). name: Short label (required). diff --git a/src/scribe/mcp/tools/tasks.py b/src/scribe/mcp/tools/tasks.py index 8d633f0..b0ec78d 100644 --- a/src/scribe/mcp/tools/tasks.py +++ b/src/scribe/mcp/tools/tasks.py @@ -19,6 +19,7 @@ Sentinels (preserved from existing fable-mcp): from __future__ import annotations from scribe.mcp._context import current_user_id +from scribe.mcp.tools import systems as systems_tools from scribe.services import access as access_svc from scribe.services import dedup as dedup_svc from scribe.services import notes as notes_svc @@ -151,7 +152,9 @@ async def create_task( Returns the created task, OR — when a near-duplicate is found and force is false — {"duplicate": true, "existing_id": ..., "message": ...} (nothing - created). + created). Created untagged in a project that has Systems, the response + carries a `systems_hint` naming them — answer it: tag the record, create + the missing System, or deliberately leave it untagged. """ uid = current_user_id() if kind == "plan": @@ -188,6 +191,10 @@ async def create_task( data["systems"] = [ s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id) ] + elif project_id: + hint = await systems_tools.untagged_systems_hint(uid, project_id) + if hint: + data["systems_hint"] = hint return data diff --git a/tests/test_instruction_surfaces_agree.py b/tests/test_instruction_surfaces_agree.py index 87358c7..6e851fb 100644 --- a/tests/test_instruction_surfaces_agree.py +++ b/tests/test_instruction_surfaces_agree.py @@ -85,6 +85,95 @@ def test_every_session_start_surface_states_the_pull(): ) +def _instructions_text() -> str: + """The _INSTRUCTIONS literal from server.py, as the client would see it.""" + import re + src = (ROOT / "src" / "scribe" / "mcp" / "server.py").read_text() + match = re.search(r'_INSTRUCTIONS = """(.*?)"""', src, re.S) + assert match, "server.py no longer defines _INSTRUCTIONS as a triple-quoted literal" + return match.group(1).strip() + + +# Claude Code injects only the first ~2,048 characters of an MCP server's +# instructions and silently cuts the rest mid-word (#2562: observed live — +# the previous 20k-char version delivered ~10% of itself, and none of the +# Systems tagging guidance ever reached a session). 2,000 leaves margin. +INSTRUCTIONS_BUDGET = 2000 + + +def test_instructions_fit_the_fold(): + text = _instructions_text() + assert len(text) <= INSTRUCTIONS_BUDGET, ( + f"_INSTRUCTIONS is {len(text)} chars; the client injects only ~2,048 " + f"and silently cuts the rest (#2562). This block is a MAP — move the " + f"detail to the tool's docstring (delivered at reach-for time), the " + f"plugin static context (always delivered), or a skill; see the " + f"comment above _INSTRUCTIONS." + ) + + +def test_floor_states_the_systems_reflex(): + """Write-time tagging guidance must live on the surface that always arrives. + + #2562's behavioral finding: with the guidance only in tool descriptions, + sessions filed records untagged. The static context is the delivery floor, + so the tag-as-you-write reflex has to be stated there. + """ + floor = (ROOT / "plugin" / "hooks" / "scribe_static_context.md").read_text() + for needle in ("system_ids", "create_system"): + assert needle in floor, ( + f"plugin/hooks/scribe_static_context.md no longer mentions " + f"{needle} — the Systems tagging reflex must be stated on the " + f"floor, not only in tool descriptions (#2562)." + ) + + +# Topics displaced from _INSTRUCTIONS when it was cut to fit the fold. Each +# must remain stated on at least one DELIVERED surface: a tool docstring +# (arrives with the tool schema), the plugin static context (always arrives), +# or a bundled skill (arrives on trigger match). Keyed by a phrase distinctive +# enough that its disappearance means the guidance is gone, not reworded — +# update the phrase alongside a deliberate rewording. +DISPLACED_TOPICS = { + "supersedes": "supersedes", + "trash is recoverable": "deleted_batch_id", + "duplicate gate": "duplicate", + "systems tag-as-you-write": "system_ids", + "reference note vs dev-log": "reference note", + "work-logs over body rewrites": "add_task_log", + "rule homes / altitude": "create_project_rule", + "rules vs other entities": "standing instruction", + "shared records are suggestions": "shared", + "processes run verbatim": "verbatim", + "snippet reuse reflex": "when_to_use", + "compaction at seams": "compact", + "plans are milestones": "start_planning", + "scope to the entered project": "cross-project", + "project bootstrap needs confirmation": "never guessing a project", +} + + +def test_displaced_topics_live_on_a_delivered_surface(): + corpus = "" + for p in (ROOT / "src" / "scribe" / "mcp" / "tools").glob("*.py"): + corpus += p.read_text() + corpus += (ROOT / "plugin" / "hooks" / "scribe_static_context.md").read_text() + for p in (ROOT / "plugin" / "skills").rglob("SKILL.md"): + corpus += p.read_text() + corpus = corpus.lower() + missing = [ + f"{topic} (phrase: {phrase!r})" + for topic, phrase in DISPLACED_TOPICS.items() + if phrase.lower() not in corpus + ] + assert not missing, ( + f"guidance displaced from _INSTRUCTIONS has fallen off every delivered " + f"surface (tool docstrings / static context / skills): {missing}. It " + f"was cut from _INSTRUCTIONS deliberately (#2562) on the premise it " + f"lives elsewhere — restore it somewhere that delivers." + ) + + def test_no_surface_names_the_push_without_stating_the_pull(): """The exact shape #2497 took. diff --git a/tests/test_mcp_tool_systems.py b/tests/test_mcp_tool_systems.py index 1b01cc4..60e8436 100644 --- a/tests/test_mcp_tool_systems.py +++ b/tests/test_mcp_tool_systems.py @@ -73,3 +73,64 @@ async def test_create_task_issue_sets_kind_provenance_and_systems(): assert kwargs["arose_from_id"] == 9 systems_svc.set_record_systems.assert_awaited_once_with(1, 50, [2, 3]) assert result["id"] == 50 + + +@pytest.mark.asyncio +async def test_untagged_hint_names_the_projects_systems(): + sys_a = MagicMock(); sys_a.id = 1; sys_a.name = "workers" + sys_b = MagicMock(); sys_b.id = 2; sys_b.name = "scrape-pipeline" + with patch("scribe.mcp.tools.systems.systems_svc") as svc: + svc.list_systems = AsyncMock(return_value=[sys_a, sys_b]) + from scribe.mcp.tools.systems import untagged_systems_hint + hint = await untagged_systems_hint(1, 5) + assert "workers" in hint and "scrape-pipeline" in hint + assert "system_ids" in hint + assert "create_system" in hint + + +@pytest.mark.asyncio +async def test_untagged_hint_absent_without_systems_and_fails_open(): + from scribe.mcp.tools.systems import untagged_systems_hint + with patch("scribe.mcp.tools.systems.systems_svc") as svc: + svc.list_systems = AsyncMock(return_value=[]) + assert await untagged_systems_hint(1, 5) is None + with patch("scribe.mcp.tools.systems.systems_svc") as svc: + # A hint must never break a create — DB failure degrades to no hint. + svc.list_systems = AsyncMock(side_effect=RuntimeError("db down")) + assert await untagged_systems_hint(1, 5) is None + + +@pytest.mark.asyncio +async def test_create_task_untagged_in_project_with_systems_carries_hint(): + note = MagicMock(); note.id = 60; note.to_dict.return_value = {"id": 60} + sys_a = MagicMock(); sys_a.id = 4; sys_a.name = "plugin-hooks" + with patch("scribe.mcp.tools.tasks.current_user_id", return_value=1), \ + patch("scribe.mcp.tools.tasks.notes_svc") as notes_svc, \ + patch("scribe.mcp.tools.tasks.dedup_svc") as dedup_svc, \ + patch("scribe.mcp.tools.systems.systems_svc") as hint_svc: + notes_svc.create_note = AsyncMock(return_value=note) + dedup_svc.find_duplicate_note = AsyncMock(return_value=None) + hint_svc.list_systems = AsyncMock(return_value=[sys_a]) + from scribe.mcp.tools.tasks import create_task + result = await create_task(title="untagged", project_id=5) + assert "plugin-hooks" in result["systems_hint"] + + +@pytest.mark.asyncio +async def test_create_task_tagged_or_projectless_carries_no_hint(): + note = MagicMock(); note.id = 61; note.to_dict.return_value = {"id": 61} + with patch("scribe.mcp.tools.tasks.current_user_id", return_value=1), \ + patch("scribe.mcp.tools.tasks.notes_svc") as notes_svc, \ + patch("scribe.mcp.tools.tasks.dedup_svc") as dedup_svc, \ + patch("scribe.mcp.tools.tasks.systems_svc") as systems_svc, \ + patch("scribe.mcp.tools.systems.systems_svc") as hint_svc: + notes_svc.create_note = AsyncMock(return_value=note) + dedup_svc.find_duplicate_note = AsyncMock(return_value=None) + systems_svc.set_record_systems = AsyncMock() + systems_svc.list_record_systems = AsyncMock(return_value=[]) + hint_svc.list_systems = AsyncMock(return_value=[MagicMock()]) + from scribe.mcp.tools.tasks import create_task + tagged = await create_task(title="tagged", project_id=5, system_ids=[4]) + orphan = await create_task(title="orphan", project_id=0) + assert "systems_hint" not in tagged + assert "systems_hint" not in orphan