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 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.
"""
+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
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,
+7 -7
View File
@@ -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 = (
+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
# 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"