refactor(mcp): _INSTRUCTIONS orients the workflow, not a rulebook (#4389)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Failing after 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 54s
CI & Build / Python tests (push) Successful in 1m35s
CI & Build / Build & push image (push) Successful in 23s

Spike #4389 read the spec, Claude's docs and a dozen servers: the field is
for how the tools fit together, and the field runs ~600-1,600 characters.
Ours sat at the 2,048 cap as a keyword index that also carried stance.

- JUDGE, REPORT and MISSED leave the index. They fire mid-work, not at
  session start; using-scribe and reporting-back state them in full, and
  `placement`/`report_back` cue reporting in-band. No skill text changes.
- The rest is rewritten as plain practices (1,503 chars) and keeps every
  session-start marker the ownership registry pins.
- INSTRUCTIONS_BUDGET 2000 -> 1600; the three index markers are dropped
  from the registry; the miss-route index test now checks that the index
  keeps what_might_apply and stays off the route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 07:50:34 -04:00
co-authored by Claude Opus 5.5
parent c26b7f248e
commit 4fb53b844d
5 changed files with 60 additions and 57 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ another one means adding files, not moving or rewriting any.
| Piece | Where | What it is |
|---|---|---|
| **The skills** | `plugin/skills/*/SKILL.md` | Agent Skills (the open SKILL.md format). They state every Scribe reflex in full and name no client. `tests/test_guidance_ownership.py` fails if a skill names a particular client, or references anything outside its own folder. Every client package ships this folder verbatim. |
| **The MCP server** | `<base URL>/mcp` | HTTP, `Authorization: Bearer <fmcp_ key>`. Its `_INSTRUCTIONS` is a client-neutral index (≤2,000 chars); each tool's description carries its contract; in-band responses (`placement`, `report_back`, `systems_hint`, the duplicate gate, the guessed-id refusal) fire in every client. |
| **The MCP server** | `<base URL>/mcp` | HTTP, `Authorization: Bearer <fmcp_ key>`. Its `_INSTRUCTIONS` is a client-neutral orientation — the workflow across tools (≤1,600 chars, #4389); each tool's description carries its contract; in-band responses (`placement`, `report_back`, `systems_hint`, the duplicate gate, the guessed-id refusal) fire in every client. |
| **The adapter API** | `<base URL>/api/plugin/*` | Plain `GET` endpoints any client's hooks can call with the same key (read scope is enough): `context` (live session state), `retrieve` (rules, preferences and notes for a message), `prior-art` (records and shape-ledger hints for code being written), `tool-rules` (rules for a command about to run), `report-check` (records a completion-report check and returns the reason for a block), `processes` (stored Processes to expose as skills). |
| **The API key** | Scribe → Settings → API Keys | One `fmcp_` key per install. Read scope for hooks; write scope for the MCP tools. |
+31 -32
View File
@@ -23,49 +23,48 @@ from quart import Quart
# client's own conventions, never a copy of the above.
# tests/test_guidance_ownership.py holds the topic registry that enforces it.
#
# 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.
# THIS BLOCK IS THE SERVER'S ORIENTATION: the workflow across tools. It reaches
# every MCP client, so it names no client, and it points at where each practice
# is stated rather than restating it. What belongs here, per the spec and the
# field (spike #4389): how the tools fit together — which to call first, which
# before which. What does not: a tool's own contract (its description carries
# it), and stance — who judges, how to report, what a missed rule means. Those
# fire mid-work, not at the start; the skills state them and in-band responses
# (`placement`, `report_back`) cue them at the moment they apply. A new topic
# gets a line here only if it is a session-start practice.
#
# BUDGET: at most 2,000 characters (test_instructions_fit_the_fold). Claude
# BUDGET: at most 1,600 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
# nothing load-bearing may sit past the fold; the rest of the field runs
# ~600–1,600 (#4389), and a block pressed against the cap is being used as a
# rulebook. 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 system of record, and yours: recall before acting,
record as you go, keep one copy here rather than in local memory files.
record as you go, keep one copy here rather than in local memory files. Each
practice below is stated in full in the using-scribe skill (if your client
reads Agent Skills) and in each tool's description.
Each reflex is stated in full in the using-scribe skill (if your client reads
Agent Skills) and each tool's description. The index:
- ORIENT: enter_project(id) loads the project, open work, Systems and design
- Start with enter_project(id): 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, what_might_apply("what you are about to do") — fifty
ranked, no bar. search(content_type="rule") reads one you suspect. Silence
means nothing matched, not none. Rules bind; preferences guide and you keep
them current; lessons inform.
- MISSED: a rule that missed you is a trigger to fix, not a floor to move
(retrieval_telemetry).
- JUDGE: you are the judge of record — what a shape is, whether a finding
holds, whether work is done. Surfacing one for them to rule on is the
judgment not made. Escalate their acts, not your decisions.
- 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.
- PLAN with an arc: find the existing plan first
(search(content_type="milestone")) and add steps to it; else
start_planning(steps=[...]).
- IDS exist only once a create returns them. Records citing 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
- Rules are not preloaded; one arrives when your work matches it. Before a
consequential act, what_might_apply("what you are about to do");
search(content_type="rule") reads one you suspect. Silence means nothing
matched, not none. Rules bind; preferences guide and you keep them current;
lessons inform.
- Search before acting or building, scoped with the active project_id; start
from a recorded snippet, and create_snippet what you build.
- Work is tasks (a fix is kind="issue"): in_progress on start, add_task_log as
you go, done on finish; tag system_ids.
- A plan is a milestone: find the existing one
(search(content_type="milestone")) before start_planning.
- IDs exist only once a create returns them; records citing each other go
through create_records, writing {{ref:N}} for the Nth.
- In UI work the project's design system binds: resolve_design_system before
hand-writing a value.
- REPORT from the `placement` a task write returns: where it sits, what
changed, what needs them, what next.
Creates are duplicate-gated: a near-match returns the existing id to update.
shared:true records are another user's suggestion, not settled practice.
+7 -6
View File
@@ -25,7 +25,10 @@ WHAT IT PINS
moments declares that in `shared_with`, with the reason beside it.
3. THE INDEX NAMES THE SESSION-START REFLEXES. `_INSTRUCTIONS` is the one
surface every MCP client receives, so each reflex it indexes keeps its
`index` markers there — a one-line pointer, not a copy.
`index` markers there — a one-line pointer, not a copy. Only session-start
practices carry `index`: judging, reporting and the missed-rule route fire
mid-work, and #4389 took them out of the index (the skills and the in-band
responses carry them at the moment they apply).
WHAT IT CANNOT SEE
@@ -174,8 +177,7 @@ TOPICS: tuple[Topic, ...] = (
# returned zero events. Machinery with no route to it.
Topic("a missed rule is a trigger to fix before a floor to move", U,
("retrieval_telemetry", "tune_retrieval", "retrieval_surfaces"),
"take it to the record first and the dial second",
index=("retrieval_telemetry",)),
"take it to the record first and the dial second"),
Topic("ask what already covers a moment before writing a record", U,
("what_might_apply",), "ask what already covers that moment"),
Topic("reference notes update in place; dev-logs don't", U, ("reference note",),
@@ -193,7 +195,7 @@ TOPICS: tuple[Topic, ...] = (
("create_snippet", "when_to_use", "first build", "second copy"),
"prior art offered beside a write is not noise", index=("create_snippet",)),
Topic("report back where the work stands", "skill:reporting-back", ("reporting-back", "placement"),
"take the placement from the record", index=("placement",)),
"take the placement from the record"),
Topic("the operator's own reply shapes come first", "skill:reporting-back",
("reply_preferences", 'content_type="rule"'),
"the operator's own shapes come first"),
@@ -225,8 +227,7 @@ TOPICS: tuple[Topic, ...] = (
# that trimming one cannot quietly take the other with it.
Topic("the agent judges the record, and judging is attended", U,
("judge of record", "attended", "classify_shapes"),
"a record that is wrong stays wrong until something rewrites it",
index=("judge of record",)),
"a record that is wrong stays wrong until something rewrites it"),
Topic("a finding surfaced and not judged is a finding dropped",
"skill:reporting-back", ("you are the judge", "hard to reverse"),
"it reads as diligence and functions as a backlog"),
+9 -5
View File
@@ -69,8 +69,11 @@ def _instructions_text() -> str:
# Claude Code injects only the first ~2,048 characters of an MCP server's
# instructions and silently cuts the rest mid-word (#2562: observed live —
# the previous 20k-char version delivered ~10% of itself, and none of the
# Systems tagging guidance ever reached a session). 2,000 leaves margin.
INSTRUCTIONS_BUDGET = 2000
# Systems tagging guidance ever reached a session). The budget is set below
# the cap, not at it: the field runs ~600–1,600 characters (spike #4389), and
# a block pressed against 2,048 is being used for depth that belongs in a
# skill.
INSTRUCTIONS_BUDGET = 1600
def test_instructions_fit_the_fold():
@@ -181,9 +184,10 @@ def test_the_strength_guard_can_fail():
# ── Reporting back (milestone 409 step 3) ──────────────────────────────
#
# The reporting-back skill carries the shapes, but a skill only helps if it
# fires. The reflex that sends a session to it is stated in using-scribe; the
# server's index carries a REPORT line pointing at `placement`, and the in-band
# cue on update_task is the half that fires on its own in every client. The
# fires. The reflex that sends a session to it is stated in using-scribe, and
# the in-band cue on update_task (`placement`, `report_back`) is the half that
# fires on its own in every client. The server's index carried a REPORT line
# until #4389: reporting is a mid-work stance, not a session-start practice. The
# static context was a second plugin-side copy until milestone 410.
REPORT_REFLEX = "report back in a shape the operator can read"
+12 -13
View File
@@ -139,20 +139,19 @@ def test_writing_a_new_record_asks_what_already_covers_the_moment():
assert "no bar" in text
def test_the_index_points_at_the_route_without_restating_it():
def test_the_route_stays_off_the_index():
"""`_INSTRUCTIONS` is the only surface every MCP client gets (decision #4027).
It indexes; using-scribe states. A client with no Agent Skills support still
learns the ordering exists and which tool opens it.
It carried a one-line MISSED pointer to this route until #4389, which found
the index was being used as a rulebook: a missed rule is noticed mid-work,
not at session start, and the spec asks server instructions for the
workflow across tools, not stance. using-scribe states the route (the tests
above pin it); the index keeps what_might_apply, the tool that opens it.
Whitespace-flattened before matching. The block is hard-wrapped to fit a
2,000-character budget, so a phrase straddles a line break the moment
anything before it changes length — #4103 shipped a guard that broke
exactly that way, on text nobody had touched.
Whitespace-flattened before matching: the block is hard-wrapped, so a
phrase straddles a line break the moment anything before it changes length
(#4103).
"""
text = " ".join(_instructions().split())
assert "retrieval_telemetry" in text
assert "not a floor to move" in text
# The route itself must NOT be here — 2,000 characters is the whole budget
# and Claude Code cuts the rest mid-word (#2562).
assert "take it to the record first" not in text.lower()
text = " ".join(_instructions().split()).lower()
assert "what_might_apply" in text
assert "take it to the record first" not in text