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
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:
+48
-99
@@ -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.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user