diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index 0c16c6f..d2128ed 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -7,111 +7,60 @@ 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 +## What this block is — read before editing it # -# 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). +# ONE OWNER PER PIECE OF GUIDANCE (decision #4027, milestone 410). Scribe's +# guidance to agents lives where it can be delivered, and each topic is stated +# in full exactly once: +# - Tool docstrings: each tool's contract, delivered with its schema. +# - In-band tool responses: behaviour prose cannot be trusted to trigger — +# the duplicate gate, the guessed-id refusal, `systems_hint`, +# `placement` and `report_back` — at the moment it applies. +# - The bundled skills (Agent Skills, client-neutral): every reflex in full. +# `using-scribe` owns the working reflexes; the process skills own arcs. +# - Client adapters (the Claude Code plugin today): timing and that +# client's own conventions, never a copy of the above. +# tests/test_guidance_ownership.py holds the topic registry that enforces it. # -# 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. -# 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. +# THIS BLOCK IS THE SERVER'S ORIENTATION, WRITTEN AS AN INDEX. It reaches +# every MCP client, so it names no client, and it points at where each reflex +# is stated rather than restating it. A new topic gets a line here only if it +# is a session-start reflex; its full statement goes to its owner. # -# Milestone 409 step 3 (reporting back to the operator in a readable shape) -# took NO line here, deliberately: there is no room without trading out a -# session-start reflex, and the moment it applies is when a task closes. So it -# rides in-band instead — update_task returns `placement` and a one-line -# `report_back` cue on done/cancelled, which every MCP client sees — with the -# full shapes in the reporting-back skill and the static context. -# -# 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. -# 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. -# -# Milestone 333 step 3 (2026-09-04) bought the HOW bullet's second clause — -# search(content_type="rule") before a consequential act — by TRADING OUT -# "Processes are saved procedures (follow verbatim)" and "Deletes are -# trash-recoverable". Recorded so the trade is not silently reversed: -# - Both were already in test_instruction_surfaces_agree's DISPLACED_TOPICS -# and already stated on a delivered surface, so nothing fell off: the -# process reflex is in every scribe-proc-* skill listing (each says the -# process governs and is followed verbatim), and trash recovery is in the -# delete_*/list_trash/restore docstrings, which is where per-tool guidance -# belongs by this block's own doctrine. -# - What it bought is not per-tool guidance and has nowhere else to live at -# session-start altitude. Rules were retrievable only by RESIDENCY: the -# always-on preload put them in front of the agent, and nothing told a -# session to go looking for one it had not been handed. That preload is -# gone (milestone 394), which makes this line LOAD-BEARING rather than -# supplementary: retrieval is now the only delivery, and retrieval fires -# only if something asks. A session that waits to be handed a rule is -# handed nothing. A tool-choice reflex asks least of all (#3476, #161). -# - It also has to carry what absence MEANS. "No rule arrived" is now the -# ordinary state rather than the exceptional one, and reading it as -# "there is no rule" is the #3720 defect at session scale. Rule 119 makes -# these surfaces the -# specification, so the same sentence lands on all three session-start -# surfaces, and test_instruction_surfaces_agree pins it. +# BUDGET: at most 2,000 characters (test_instructions_fit_the_fold). Claude +# Code injects only the first ~2,048 characters of a server's instructions and +# cuts the rest mid-word (#2562, observed live), and other clients differ, so +# nothing load-bearing may sit past the fold. The history of what was traded +# for space before the ownership split (milestones 317, 333, 409) is in +# decision #4027 and the notes it supersedes. _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 -in local files (CLAUDE.md, auto-memory); Scribe holds the single copy. +Scribe is the operator's system of record for their work, and yours: recall +from it before acting, record in it as you go, and keep one copy here rather +than in local memory files. -Hierarchy: Project -> Milestone -> Task/Note. The map, by purpose: -- ORIENT: enter_project(id) at session start — rules, open tasks, recent - notes, Systems, design system. `inception`: ask what the project - inherits, then decide_project_inception. -- 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 — prior art exists; 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: rules bind; preferences guide. Nothing preloads — a rule arrives - when your work matches it. Before a consequential act, - search(content_type="rule"); silence means nothing matched, not none. -- 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; classify shapes against canon (classify_shapes) — a - consumer map is rows, never prose. +Every reflex below is stated in full in the using-scribe skill (if your +client reads Agent Skills) and in each tool's description. The index: +- ORIENT: enter_project(id) loads the project, open work, Systems and design + system. An `inception` key: ask what it inherits, then + decide_project_inception. +- RULES: nothing preloads; a rule arrives when your work matches it. Before a + consequential act, search(content_type="rule"). Silence means + nothing matched, not none. Rules bind; preferences guide. +- 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: start_planning(steps=[...]). The plan is a milestone + and 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. +- UI: the project's design system binds; resolve_design_system before + hand-writing a value. +- REPORT back from the `placement` a task write returns: where the work sits, + what changed, what needs the operator, what comes next. -A task is a note with status (*_note vs *_task tools). -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. - -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. +Creates are duplicate-gated: a near-match returns the existing id to update. +shared:true records are another user's suggestion, not settled practice. """ diff --git a/src/scribe/services/plugin_context.py b/src/scribe/services/plugin_context.py index a6f1b5d..14fe9e5 100644 --- a/src/scribe/services/plugin_context.py +++ b/src/scribe/services/plugin_context.py @@ -2160,30 +2160,18 @@ async def build_session_context( on each write so the server could say whether the resident rules had moved; nothing is resident now, so nothing can have moved, and a rule is re-retrieved at the moment it applies rather than held and aged. - `context` is markdown ready to drop into `additionalContext`; it is capped - at _MAX_CHARS with an explicit truncation note so the hook can pass it - through verbatim. - """ - lines: list[str] = [ - "# Scribe — standing session context (auto-injected by the Scribe plugin)", - "", - "You are working with Scribe, the operator's self-hosted second brain.", - "", - "## You are not holding the operator's rules", - "", - "No rule has been loaded into this session, and that is deliberate. " - "Rules arrive when something you are about to do makes one relevant — " - "a command you are about to run, code you are writing, or what the " - "operator just asked for. On most turns none will, and that is the " - "surface working rather than failing.", - "", - "**\"No rule arrived\" means \"nothing matched\" — never \"there is no " - "rule.\"** Before a consequential act, one that is hard to reverse or " - "outward-facing, `search(content_type=\"rule\")` is how you ask. " - "Retrieval runs on its own and is a convenience; asking is what you do " - "when it matters and nothing has spoken.", - ] + `context` is markdown an adapter can drop into its session verbatim; it + is capped at _MAX_CHARS with an explicit truncation note. + LIVE STATE ONLY (decision #4027, milestone 410). This used to open with the + rules reflex and close with a recall reflex — a fifth copy of guidance the + using-scribe skill owns, arriving in every session beside the other four. + It now says only what the server alone knows about THIS session: the + active project, its open work, its design system, or that the working repo + is unbound. How to work with Scribe is the skill's to say, and it says it + once. + """ + lines: list[str] = ["# Scribe — live session state"] project_dict: dict | None = None if project_id: @@ -2222,9 +2210,7 @@ async def build_session_context( f"(id {design['id']}){inherits}", f"{design['token_count']} tokens" + (f" across {groups}" if groups else "") - + ". This project's UI is built from these, not from " - "literals — reach for a token before writing a colour, " - "size, radius or duration by hand.", + + ".", f"Values: `resolve_design_system({design['id']})` · " f"stylesheet: `get_design_system_stylesheet({design['id']})` " f"· the prose (aesthetic, voice, where the accent may " @@ -2241,18 +2227,12 @@ async def build_session_context( "(call `list_projects` to find the id) and future sessions here will " "auto-load that project's context.", ] - - lines += [ - "", - "Reflex: search Scribe (search / list_tasks / list_notes, scoped to the " - "active project) before answering or starting work; prefer UPDATING an " - "existing note/rule over creating a new one.", - ] + else: + lines += ["", "No Scribe project is bound to this working directory."] context = "\n".join(line for line in lines if line is not None) if len(context) > _MAX_CHARS: - context = context[:_MAX_CHARS].rstrip() + \ - "\n\n…(truncated — ask with search(content_type=\"rule\"))" + context = context[:_MAX_CHARS].rstrip() + "\n\n…(truncated)" return { "context": context, diff --git a/tests/test_instruction_surfaces_agree.py b/tests/test_instruction_surfaces_agree.py index c3c29ef..98a9224 100644 --- a/tests/test_instruction_surfaces_agree.py +++ b/tests/test_instruction_surfaces_agree.py @@ -166,11 +166,11 @@ 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." + f"_INSTRUCTIONS is {len(text)} chars; Claude Code injects only ~2,048 " + f"and silently cuts the rest (#2562). This block is an INDEX — state " + f"the topic in full on its owner (a skill or the tool's docstring, " + f"decision #4027) and give it at most a line here; see the comment " + f"above _INSTRUCTIONS." ) @@ -289,8 +289,8 @@ def test_a_surface_claiming_rules_bind_also_names_what_does_not(): # The reporting-back skill carries the shapes, but a skill only helps if it # fires. The reflex that sends a session to it lives on the two plugin # surfaces a session always reads; the in-band cue on update_task is the half -# that reaches clients with no plugin at all. _INSTRUCTIONS took no line, on -# purpose — server.py's comment block records why. +# that reaches clients with no plugin at all. Since milestone 410 the server's +# index carries a REPORT line too, pointing at `placement`. REPORT_REFLEX = "report back in a shape the operator can read" REPORT_SURFACES = ( diff --git a/tests/test_services_plugin_context.py b/tests/test_services_plugin_context.py index b895d42..15a7af4 100644 --- a/tests/test_services_plugin_context.py +++ b/tests/test_services_plugin_context.py @@ -114,6 +114,10 @@ async def test_build_session_context_includes_project_when_scoped(): # No design system on the project -> no design block at all. An install with # none is the ordinary case, not a degraded one. assert "## Design system" not in out["context"] + # Live state only (decision #4027): how to work with Scribe is the + # using-scribe skill's to say. A restated reflex here is a copy that drifts. + assert 'content_type="rule"' not in out["context"] + assert "Reflex:" not in out["context"] @pytest.mark.asyncio @@ -246,14 +250,16 @@ async def test_build_session_context_caps_length(): Patching the cap rather than manufacturing 9,000 characters keeps the test about the TRUNCATION PATH — that it cuts, and that it says it cut — which - is the part a reader depends on. + is the part a reader depends on. The unbound-repo hint supplies the text: + since milestone 410 the block carries live state only, and a bare session + is a one-liner. """ from scribe.services import plugin_context as pc - with patch.object(pc, "_MAX_CHARS", 120): - out = await pc.build_session_context(user_id=7) + with patch.object(pc, "_MAX_CHARS", 60): + out = await pc.build_session_context(user_id=7, unbound_repo="host/owner/repo") - assert len(out["context"]) <= 120 + 60 # cap + truncation note + assert len(out["context"]) <= 60 + 20 # cap + truncation note assert "truncated" in out["context"], ( "the block was cut without saying so — a reader cannot tell a " "truncated context from a short one"