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