feat(410): the server orients with a client-neutral index; the live context carries live state only (#4030)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 54s
CI & Build / Python tests (push) Successful in 1m28s
CI & Build / Build & push image (push) Successful in 27s

Step 3 of milestone 410 "One owner per piece of guidance".

_INSTRUCTIONS is rewritten as an index for every MCP client (1,597 of 2,000
chars): orient, rules, recall, record, plan, ids, reuse, UI, report. Each
line names its tool, and the block says every reflex is stated in full in
the using-scribe skill and in each tool description.
- names no client: CLAUDE.md, auto-memory and "the client injects ~2k
  chars" are gone
- gains the two reflexes it lacked: records that cite each other go through
  create_records with {{ref:N}}, and reports start from `placement`

The comment block above it now explains ownership (decision #4027) instead of
accumulating per-milestone trade history, and keeps the budget and its
reason (#2562).

build_session_context states only what the server knows about this session:
the active project and open work, its design system, an unbound-repo hint,
or that no project is bound. Removed: the "you are not holding the
operator's rules" section, the closing "Reflex: search Scribe" line, the
design-system usage sentence, and the plugin-specific header. using-scribe
owns all of that. The truncation note no longer restates the rules ask.

Tests: a pin that the live context carries no rules reflex; the cap test
drives truncation through the unbound-repo hint, since a bare session is now
one line; the budget test message describes the index.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:41:08 -04:00
co-authored by Claude Opus 5
parent 0a29252f9b
commit 76bfd92c21
4 changed files with 80 additions and 145 deletions
+48 -99
View File
@@ -7,111 +7,60 @@ from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings from mcp.server.transport_security import TransportSecuritySettings
from quart import Quart 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 # ONE OWNER PER PIECE OF GUIDANCE (decision #4027, milestone 410). Scribe's
# instructions into the system prompt; the rest is silently cut mid-word # guidance to agents lives where it can be delivered, and each topic is stated
# (#2562 — the cut was observed live at exactly offset 2,048, and ~90% of the # in full exactly once:
# previous 20k-char version of this block never reached any session). So this # - Tool docstrings: each tool's contract, delivered with its schema.
# block is deliberately a MAP, not a manual, and a test pins it under the # - In-band tool responses: behaviour prose cannot be trusted to trigger —
# fold (test_instruction_surfaces_agree.py::test_instructions_fit_the_fold). # 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: # THIS BLOCK IS THE SERVER'S ORIENTATION, WRITTEN AS AN INDEX. It reaches
# - Tool docstrings: the per-tool HOW. Delivered with the tool schema, at # every MCP client, so it names no client, and it points at where each reflex
# reach-for time when the client defers tools. Guidance about one tool # is stated rather than restating it. A new topic gets a line here only if it
# belongs there, not here. # is a session-start reflex; its full statement goes to its owner.
# - 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.
# #
# Milestone 409 step 3 (reporting back to the operator in a readable shape) # BUDGET: at most 2,000 characters (test_instructions_fit_the_fold). Claude
# took NO line here, deliberately: there is no room without trading out a # Code injects only the first ~2,048 characters of a server's instructions and
# session-start reflex, and the moment it applies is when a task closes. So it # cuts the rest mid-word (#2562, observed live), and other clients differ, so
# rides in-band instead — update_task returns `placement` and a one-line # nothing load-bearing may sit past the fold. The history of what was traded
# `report_back` cue on done/cancelled, which every MCP client sees — with the # for space before the ownership split (milestones 317, 333, 409) is in
# full shapes in the reporting-back skill and the static context. # decision #4027 and the notes it supersedes.
#
# 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.
_INSTRUCTIONS = """ _INSTRUCTIONS = """
Scribe is the operator's self-hosted second brain and system of record — and Scribe is the operator's system of record for their work, and yours: recall
yours: recall from it before acting, record as you go. Keep no parallel copy from it before acting, record in it as you go, and keep one copy here rather
in local files (CLAUDE.md, auto-memory); Scribe holds the single copy. than in local memory files.
Hierarchy: Project -> Milestone -> Task/Note. The map, by purpose: Every reflex below is stated in full in the using-scribe skill (if your
- ORIENT: enter_project(id) at session start — rules, open tasks, recent client reads Agent Skills) and in each tool's description. The index:
notes, Systems, design system. `inception`: ask what the project - ORIENT: enter_project(id) loads the project, open work, Systems and design
inherits, then decide_project_inception. system. An `inception` key: ask what it inherits, then
- DO: create_task. Fixed a problem? kind="issue" (symptom -> root cause -> decide_project_inception.
fix), never a work-log line on an unrelated task. Log with add_task_log; - RULES: nothing preloads; a rule arrives when your work matches it. Before a
keep status honest — in_progress on start, done on finish. consequential act, search(content_type="rule"). Silence means
- PLAN work with an arc: start_planning. The plan IS a milestone; each step is nothing matched, not none. Rules bind; preferences guide.
a child task, not a checkbox. No local plan .md files. - RECALL: search before acting, scoped with the active project_id.
- CAPTURE: create_note. RECALL: search first — prior art exists; pass the - RECORD: create_task; a fix is kind="issue". add_task_log as you go; status
active project_id to stay in scope. in_progress on start, done on finish. Tag system_ids as you write.
- WHERE work happens: Systems. Tag records with system_ids as you write; - PLAN work with an arc: start_planning(steps=[...]). The plan is a milestone
create_system when the area is unmodelled. and each step a task.
- HOW: rules bind; preferences guide. Nothing preloads — a rule arrives - IDS exist only once a create returns them. Records that cite each other go
when your work matches it. Before a consequential act, through create_records, writing {{ref:N}} for the Nth record.
search(content_type="rule"); silence means nothing matched, not none. - REUSE: search snippets before building; create_snippet what you build.
- UI: the project's design system is binding — resolve_design_system / - UI: the project's design system binds; resolve_design_system before
get_design_system_stylesheet before hand-writing a value. hand-writing a value.
- REUSE: search snippets before writing a helper; record what you build with - REPORT back from the `placement` a task write returns: where the work sits,
create_snippet; classify shapes against canon (classify_shapes) — a what changed, what needs the operator, what comes next.
consumer map is rows, never prose.
A task is a note with status (*_note vs *_task tools). Creates are duplicate-gated: a near-match returns the existing id to update.
Creates are duplicate-gated: a near-match BLOCKS and returns the existing shared:true records are another user's suggestion, not settled practice.
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.
""" """
+15 -35
View File
@@ -2160,30 +2160,18 @@ async def build_session_context(
on each write so the server could say whether the resident rules had 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 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. re-retrieved at the moment it applies rather than held and aged.
`context` is markdown ready to drop into `additionalContext`; it is capped `context` is markdown an adapter can drop into its session verbatim; it
at _MAX_CHARS with an explicit truncation note so the hook can pass it is capped at _MAX_CHARS with an explicit truncation note.
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.",
]
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 project_dict: dict | None = None
if project_id: if project_id:
@@ -2222,9 +2210,7 @@ async def build_session_context(
f"(id {design['id']}){inherits}", f"(id {design['id']}){inherits}",
f"{design['token_count']} tokens" f"{design['token_count']} tokens"
+ (f" across {groups}" if groups else "") + (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"Values: `resolve_design_system({design['id']})` · "
f"stylesheet: `get_design_system_stylesheet({design['id']})` " f"stylesheet: `get_design_system_stylesheet({design['id']})` "
f"· the prose (aesthetic, voice, where the accent may " 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 " "(call `list_projects` to find the id) and future sessions here will "
"auto-load that project's context.", "auto-load that project's context.",
] ]
else:
lines += [ lines += ["", "No Scribe project is bound to this working directory."]
"",
"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.",
]
context = "\n".join(line for line in lines if line is not None) context = "\n".join(line for line in lines if line is not None)
if len(context) > _MAX_CHARS: if len(context) > _MAX_CHARS:
context = context[:_MAX_CHARS].rstrip() + \ context = context[:_MAX_CHARS].rstrip() + "\n\n…(truncated)"
"\n\n…(truncated — ask with search(content_type=\"rule\"))"
return { return {
"context": context, "context": context,
+7 -7
View File
@@ -166,11 +166,11 @@ INSTRUCTIONS_BUDGET = 2000
def test_instructions_fit_the_fold(): def test_instructions_fit_the_fold():
text = _instructions_text() text = _instructions_text()
assert len(text) <= INSTRUCTIONS_BUDGET, ( assert len(text) <= INSTRUCTIONS_BUDGET, (
f"_INSTRUCTIONS is {len(text)} chars; the client injects only ~2,048 " f"_INSTRUCTIONS is {len(text)} chars; Claude Code injects only ~2,048 "
f"and silently cuts the rest (#2562). This block is a MAP — move the " f"and silently cuts the rest (#2562). This block is an INDEX — state "
f"detail to the tool's docstring (delivered at reach-for time), the " f"the topic in full on its owner (a skill or the tool's docstring, "
f"plugin static context (always delivered), or a skill; see the " f"decision #4027) and give it at most a line here; see the comment "
f"comment above _INSTRUCTIONS." 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 # 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 # 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 # 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 # that reaches clients with no plugin at all. Since milestone 410 the server's
# purpose — server.py's comment block records why. # index carries a REPORT line too, pointing at `placement`.
REPORT_REFLEX = "report back in a shape the operator can read" REPORT_REFLEX = "report back in a shape the operator can read"
REPORT_SURFACES = ( REPORT_SURFACES = (
+10 -4
View File
@@ -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 # No design system on the project -> no design block at all. An install with
# none is the ordinary case, not a degraded one. # none is the ordinary case, not a degraded one.
assert "## Design system" not in out["context"] 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 @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 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 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 from scribe.services import plugin_context as pc
with patch.object(pc, "_MAX_CHARS", 120): with patch.object(pc, "_MAX_CHARS", 60):
out = await pc.build_session_context(user_id=7) 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"], ( assert "truncated" in out["context"], (
"the block was cut without saying so — a reader cannot tell a " "the block was cut without saying so — a reader cannot tell a "
"truncated context from a short one" "truncated context from a short one"