Merge pull request 'Server instructions orient the workflow; using-scribe splits into reference files (#4389, #4398)' (#187) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m46s
CI & Build / Build & push image (push) Successful in 14s

This commit was merged in pull request #187.
This commit is contained in:
2026-09-24 11:30:02 -04:00
13 changed files with 430 additions and 249 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
"version": "2026.09.24.1042",
"version": "2026.09.24.1358",
"author": {
"name": "Bryan Van Deusen"
},
+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. |
+19 -180
View File
@@ -17,19 +17,8 @@ If the working directory maps to a Scribe project, call `enter_project(id)` —
it returns the project's goal, the milestones and open tasks worked on most
recently, its Systems and the titles of its own rules in one shot.
**A directory does not have to be a git repo to have a project.** A repo is
bound by its remote (`list_repo_bindings` shows the bindings). Anything else —
a notes folder, a server's config directory, a scratch directory — is bound by
a `.scribe` file naming the project:
{"instance": "https://scribe.example.com", "project_id": 2, "project": "Homelab"}
`instance` is what makes the id trustworthy. An id means nothing on its own —
it is a different project on every Scribe — so a marker that has travelled to
another instance is ignored rather than followed to the wrong project. A bare
`2` also works when writing the file by hand. When work plainly belongs to a
project and the directory names none, offer to write the marker;
`list_projects` has the id.
Binding a directory that is not a git repo, or starting a new project, has
its own steps: read [projects.md](projects.md).
Then **ask before you act**: before anything hard to reverse or outward-facing,
search the rules for what you are about to do. Reflex 2 below is why asking,
@@ -120,17 +109,9 @@ Two constraints on *how* that's achieved:
learned is that something MUST be done a certain way, that is a rule to
propose, not a preference to harden in place.
**A lesson grows each time it proves itself.** When one arrives and the
situation it names is the one you are actually in, you are the single
reader placed to tell whether its trigger is keyed right and whether its
claim covers what you are seeing. `update_lesson` takes what you now know:
another incident added to what taught it, the claim stated more exactly,
or — the edit worth most — a trigger re-keyed to the situation that really
fired. A lesson nobody reaches is seldom wrong; far more often it is
waiting in a situation nobody is in. One claim that has met the same
failure four times is worth more than four claims that each met it once,
so when a near-duplicate create hands back an existing id, that is the
record to grow.
**A lesson grows each time it proves itself.** When one arrives and names the
situation you are actually in, `update_lesson` it with what you now know;
[writing-records.md](writing-records.md) says what is worth adding.
**Preferences shape how work is done, never what gets recorded.** They
govern your conduct — how you report, how carefully you pace, which form
@@ -246,47 +227,10 @@ Two constraints on *how* that's achieved:
re-measurement, a reversed decision), pass the old id in `supersedes` so the
stale record is demoted and labelled rather than left competing.
10. **A few notes assert a FACT, and those can carry their own check.**
Supersession only fires once somebody has read a note and disagreed — which
is the case where it was already believed. A note asserting something about
*someone else's* software — what a service does on a duplicate upload, how a
forge numbers its CI runs, what an updater compares — can instead carry
`verify_with` (how to check it) and `expires_when` (the STATE that ends it:
"when the forge numbers runs per workflow", never "in six months").
`notes_due_for_verification` lists them least-recently-confirmed first, with
never-checked at the top; `mark_note_verified` records what you found, and
`still_true=False` deliberately writes nothing — a note whose check failed
is wrong rather than in a state worth recording, so it keeps its place.
**The test is one question: could this note become false without anyone
editing it?** If no, leave both fields empty. That is the normal case, and
an empty `verify_with` is the positive marker for "this is a decision, there
is nothing to go and check" — not an unfinished record. The sweep is only
worth reading while almost nothing is on it, so a check added out of
tidiness costs the whole surface, not just that note.
**The sharper form of the same test: is the thing this note describes yours
to change?** If yes it is a decision — editing your own software is how it
changes, and you will know you did it. Measured against a real corpus, every
note that earned a check was about somebody ELSE's software: a signing
service, a forge, a hub, an SDK, a model, a dependency set.
**Three that look like candidates and are not:**
- **Resume pointers and "current state" notes.** They go stale fastest of
anything, which is exactly why they tempt — but the cure is to update or
delete them, not to schedule a check. A sweep full of pointers is a sweep
nobody reads.
- **Measurements of your own system.** They go false because you changed
something, and you knew. A measurement earns a check only when what it
measures is outside your control.
- **A decision that RESTS on somebody else's behaviour.** The decision is
still a decision. Put the check on the note asserting the fact, and link
the decision to it.
Not for tasks — a task's decay is its status, and a done issue records what
happened rather than asserting something that can go false. Not for snippets
either: `verify_snippet` compares the recorded location and code against the
repo, which is richer and already wired to drift detection.
10. **A note that asserts a fact about someone else's software can carry its
own check** (`verify_with`, `expires_when`), swept by
`notes_due_for_verification`. Most notes should not: read
[writing-records.md](writing-records.md) before filling either field.
11. **Report back in a shape the operator can read.** They were not there while
you worked, so organise the reply around **where the work stands**, not the
@@ -311,80 +255,18 @@ bound — confine the session to it:
- If something clearly belongs to a *different* project, say so and **ask before
switching** — never silently operate cross-project.
## Starting a project: decide what it inherits
## Read these when the moment comes
A project's inheritance is a **decision, not a default**. Before
`create_project`, ask the operator the two inception questions and pass the
answers — never create a project bare by default:
Each is part of this skill, kept in its own file because it matters at one
moment rather than on every turn:
- which **design system** its UI is built from (`list_design_systems`; or
none) → `design_system_id=<id | -1>`
- whether to **seed the standard starter Systems** so records can be tagged
from day one → `seed_systems=true|false`
Rules are not an inception question: a global rule already applies to every
project, and a project's own rules are written on it as they come up.
If `enter_project` returns an `inception` key, the project was never decided
(it inherits its defaults silently): raise that ask once, with the defaults it
carries, then `decide_project_inception(project_id, …)`. Existing projects
were stamped "legacy" and do not ask; any project can be re-decided. The
design-system and Systems tools still work one at a time — inception is the
moment they are decided together, and the record of why.
## Where a new rule goes
A rule has one of two homes, and the home IS its reach:
- **Global** — in a rulebook (`create_rule` into a topic). It applies in every
project, and reaches a session wherever the work matches it. A rulebook is a
*themed* grouping of general rules (e.g. a review checklist), not a list of
projects it binds — there is no subscribing a project to one.
- **Project** (`create_project_rule`) — anything specific to one project (its
files, paths, quirks). It reaches only that project's sessions.
Names one project's specifics → project rule; a standard that holds wherever
the kind of work it describes happens → global. Never put project-specific
detail in a rulebook — it would reach every other project. A project that
departs from a global rule writes its own and links it with
`relate_rules(kind="overrides")`, which says why. A rule that turns out to be
in the wrong home — a project rule that holds everywhere, a global one only a
single project needs — moves with `move_rule`, which keeps its id, history,
areas and edges. Propose the move and make it on a yes.
**Whichever home it gets, a rule needs `when_to_apply`.** It is the only thing
that decides whether the rule is ever seen: nothing is preloaded, so a rule
with no trigger is not a quiet rule, it is an unreachable one. Write the moment
in the words a session actually produces — the command, the error, the
half-formed ask — not the category it belongs to.
**Before writing one, ask what already covers that moment.**
`what_might_apply("the moment you are about to write a record for")` — fifty
candidates and no bar, so an existing record cannot hide under a threshold the
way it can from `search`. When something already covers the moment, the reach
is that record: improve its trigger or its statement rather than standing a
second one beside it. Two records describing the same moment compete in one
ranked list against one budget, and the slot they take from each other is the
third candidate that would have said something different. Two records may
legitimately share a moment and say *different* things — a rule for what must
happen, a preference for how to report it. What this catches is the same thing
said twice at two strengths, which is worse than either alone: a session that
retrieves the softer copy has been told that binding guidance is optional.
**First ask whether it's a rule at all.** A rule is prose you have to remember
and apply; Scribe's other entities are structure a tool can resolve and check.
Visual standards belong in a **design system**, not a rulebook — a token can be
inherited, resolved per mode, rendered to a stylesheet and diffed against code,
and none of that survives being written as a rule. A repeatable procedure is a
**process**; reusable code is a **snippet**. Reach for a rule when the thing
really is a standing instruction about how to work.
Then ask what force it carries, by the question above. A standing instruction
that merely costs consistency is a **preference** (`create_preference`), and a
transferable insight that costs time is a **lesson** (`create_lesson`), keyed
to the situation it applies to so a later session meets it there. Both are
first-class outcomes of noticing something, not what's left when a rule
proposal fails.
- [projects.md](projects.md) — a directory with no project; starting a project
(`decide_project_inception` when `enter_project` returns `inception`).
- [writing-records.md](writing-records.md) — before writing a rule, preference
or lesson (where it goes, its `when_to_apply`, what already covers the
moment), and before giving a note a check.
- [missed-retrieval.md](missed-retrieval.md) — a rule that missed the moment it
governed, or keeps arriving where it doesn't apply.
## You are the judge of what the record says
@@ -415,49 +297,6 @@ stored, so when you fix how judgments are made, go and look at the ones
already there. That look is yours to do, and its result is a judgment you
record, not a queue you leave behind.
## When a record doesn't reach the moment it should
Retrieval misjudging is ordinary, and it is fixable — but only by whoever
notices. **Either direction counts:** a rule that should have governed a moment
and never arrived, and a rule that arrives on every turn and never applies. So
does **either noticer**: the operator saying *"that should have fired"*, and you
noticing it yourself — you reached for a rule nobody offered you, or you were
handed the same rule five times and set it aside five times.
**Take it to the record first and the dial second.** A rule's `when_to_apply`
IS the text its similarity score is computed against, so when a rule misses a
moment it governs, the overwhelmingly likely cause is that its trigger does not
describe that moment in the words a session actually produces. Rewording one
trigger changes one rule's reach. Moving a floor changes what every record on
that surface does, and a floor cannot tell a badly-worded trigger from a
genuinely distant record — so one lowered to rescue a single rule admits
everything else that was sitting in the same band.
1. **Read the refused records.** `retrieval_telemetry(days=N,
near_miss_samples=5)` names by id what each surface refused and by how much.
Open them with `get_rule` / `get_note`. This is the step that carries the
answer: the statistic says a record was close, and only the record says
whether it was *right*.
2. **Fix the trigger.** `update_rule(when_to_apply=...)`, written as the
symptom — what the session was doing or saying at the moment it needed this
rule — not the situation the rule belongs to. Then check that it worked:
`what_might_apply("the moment, in the operator's own words")` and read where
the rule now ranks. The change is measurable, so measure it, and say the
before and after when you report it.
3. **Then consider the dial.** `retrieval_surfaces` shows what is in force per
arm and whether the number is still calibrated; `tune_retrieval` moves it.
`reason` is required and has to say what you read, because it is what lets
the operator disagree with a number they did not choose.
Reaching for `tune_retrieval` before opening a single record is the wrong move,
and it is the one that feels efficient. Worked example, measured on this
install: a rule granting a routine push scored 0.6515 and ranked 5th for the
moment it governed, behind three rules that *restrained* the same act. Every
percentile said "lower the floor" — and lowering it would have delivered those
three restraints and still not the rule. Rewriting the trigger to lead with the
symptom moved the same rule to 1st at 0.7130, ahead of all three. Only then was
the floor worth touching.
## Building UI: the project's design system binds
`enter_project` names the project's `design_system` when it has one. Before
@@ -0,0 +1,46 @@
# When a record doesn't reach the moment it should
Part of the using-scribe skill. Read it when a rule should have governed a
moment and never arrived, when one arrives on every turn and never applies,
or before touching a retrieval floor.
Retrieval misjudging is ordinary, and it is fixable — but only by whoever
notices. **Either direction counts:** a rule that should have governed a moment
and never arrived, and a rule that arrives on every turn and never applies. So
does **either noticer**: the operator saying *"that should have fired"*, and you
noticing it yourself — you reached for a rule nobody offered you, or you were
handed the same rule five times and set it aside five times.
**Take it to the record first and the dial second.** A rule's `when_to_apply`
IS the text its similarity score is computed against, so when a rule misses a
moment it governs, the overwhelmingly likely cause is that its trigger does not
describe that moment in the words a session actually produces. Rewording one
trigger changes one rule's reach. Moving a floor changes what every record on
that surface does, and a floor cannot tell a badly-worded trigger from a
genuinely distant record — so one lowered to rescue a single rule admits
everything else that was sitting in the same band.
1. **Read the refused records.** `retrieval_telemetry(days=N,
near_miss_samples=5)` names by id what each surface refused and by how much.
Open them with `get_rule` / `get_note`. This is the step that carries the
answer: the statistic says a record was close, and only the record says
whether it was *right*.
2. **Fix the trigger.** `update_rule(when_to_apply=...)`, written as the
symptom — what the session was doing or saying at the moment it needed this
rule — not the situation the rule belongs to. Then check that it worked:
`what_might_apply("the moment, in the operator's own words")` and read where
the rule now ranks. The change is measurable, so measure it, and say the
before and after when you report it.
3. **Then consider the dial.** `retrieval_surfaces` shows what is in force per
arm and whether the number is still calibrated; `tune_retrieval` moves it.
`reason` is required and has to say what you read, because it is what lets
the operator disagree with a number they did not choose.
Reaching for `tune_retrieval` before opening a single record is the wrong move,
and it is the one that feels efficient. Worked example, measured on this
install: a rule granting a routine push scored 0.6515 and ranked 5th for the
moment it governed, behind three rules that *restrained* the same act. Every
percentile said "lower the floor" — and lowering it would have delivered those
three restraints and still not the rule. Rewriting the trigger to lead with the
symptom moved the same rule to 1st at 0.7130, ahead of all three. Only then was
the floor worth touching.
+41
View File
@@ -0,0 +1,41 @@
# Projects: binding a directory, and starting one
Part of the using-scribe skill. Read it when a working directory maps to no
project, or when a new project is about to be created.
## Binding a directory to a project
**A directory does not have to be a git repo to have a project.** A repo is
bound by its remote (`list_repo_bindings` shows the bindings). Anything else —
a notes folder, a server's config directory, a scratch directory — is bound by
a `.scribe` file naming the project:
{"instance": "https://scribe.example.com", "project_id": 2, "project": "Homelab"}
`instance` is what makes the id trustworthy. An id means nothing on its own —
it is a different project on every Scribe — so a marker that has travelled to
another instance is ignored rather than followed to the wrong project. A bare
`2` also works when writing the file by hand. When work plainly belongs to a
project and the directory names none, offer to write the marker;
`list_projects` has the id.
## Starting a project: decide what it inherits
A project's inheritance is a **decision, not a default**. Before
`create_project`, ask the operator the two inception questions and pass the
answers — never create a project bare by default:
- which **design system** its UI is built from (`list_design_systems`; or
none) → `design_system_id=<id | -1>`
- whether to **seed the standard starter Systems** so records can be tagged
from day one → `seed_systems=true|false`
Rules are not an inception question: a global rule already applies to every
project, and a project's own rules are written on it as they come up.
If `enter_project` returns an `inception` key, the project was never decided
(it inherits its defaults silently): raise that ask once, with the defaults it
carries, then `decide_project_inception(project_id, …)`. Existing projects
were stamped "legacy" and do not ask; any project can be re-decided. The
design-system and Systems tools still work one at a time — inception is the
moment they are decided together, and the record of why.
@@ -0,0 +1,121 @@
# Writing a rule, a lesson, or a note that asserts a fact
Part of the using-scribe skill. Read it before `create_rule`,
`create_project_rule`, `create_preference` or `create_lesson`; when a lesson
arrives that names the situation you are actually in; and before filling
`verify_with` or `expires_when` on a note.
## Contents
- Where a new rule goes — its home, its trigger, what already covers the moment
- A lesson grows each time it proves itself
- A note that asserts a fact can carry its own check
## Where a new rule goes
A rule has one of two homes, and the home IS its reach:
- **Global** — in a rulebook (`create_rule` into a topic). It applies in every
project, and reaches a session wherever the work matches it. A rulebook is a
*themed* grouping of general rules (e.g. a review checklist), not a list of
projects it binds — there is no subscribing a project to one.
- **Project** (`create_project_rule`) — anything specific to one project (its
files, paths, quirks). It reaches only that project's sessions.
Names one project's specifics → project rule; a standard that holds wherever
the kind of work it describes happens → global. Never put project-specific
detail in a rulebook — it would reach every other project. A project that
departs from a global rule writes its own and links it with
`relate_rules(kind="overrides")`, which says why. A rule that turns out to be
in the wrong home — a project rule that holds everywhere, a global one only a
single project needs — moves with `move_rule`, which keeps its id, history,
areas and edges. Propose the move and make it on a yes.
**Whichever home it gets, a rule needs `when_to_apply`.** It is the only thing
that decides whether the rule is ever seen: nothing is preloaded, so a rule
with no trigger is not a quiet rule, it is an unreachable one. Write the moment
in the words a session actually produces — the command, the error, the
half-formed ask — not the category it belongs to.
**Before writing one, ask what already covers that moment.**
`what_might_apply("the moment you are about to write a record for")` — fifty
candidates and no bar, so an existing record cannot hide under a threshold the
way it can from `search`. When something already covers the moment, the reach
is that record: improve its trigger or its statement rather than standing a
second one beside it. Two records describing the same moment compete in one
ranked list against one budget, and the slot they take from each other is the
third candidate that would have said something different. Two records may
legitimately share a moment and say *different* things — a rule for what must
happen, a preference for how to report it. What this catches is the same thing
said twice at two strengths, which is worse than either alone: a session that
retrieves the softer copy has been told that binding guidance is optional.
**First ask whether it's a rule at all.** A rule is prose you have to remember
and apply; Scribe's other entities are structure a tool can resolve and check.
Visual standards belong in a **design system**, not a rulebook — a token can be
inherited, resolved per mode, rendered to a stylesheet and diffed against code,
and none of that survives being written as a rule. A repeatable procedure is a
**process**; reusable code is a **snippet**. Reach for a rule when the thing
really is a standing instruction about how to work.
Then ask what force it carries, by the question SKILL.md's reflex 2 asks: what
happens if someone doesn't do this? A standing instruction that merely costs
consistency is a **preference** (`create_preference`), and a transferable
insight that costs time is a **lesson** (`create_lesson`), keyed to the
situation it applies to so a later session meets it there. Both are first-class
outcomes of noticing something, not what's left when a rule proposal fails.
## A lesson grows each time it proves itself
When one arrives and the situation it names is the one you are actually in, you
are the single reader placed to tell whether its trigger is keyed right and
whether its claim covers what you are seeing. `update_lesson` takes what you
now know: another incident added to what taught it, the claim stated more
exactly, or — the edit worth most — a trigger re-keyed to the situation that
really fired. A lesson nobody reaches is seldom wrong; far more often it is
waiting in a situation nobody is in. One claim that has met the same failure
four times is worth more than four claims that each met it once, so when a
near-duplicate create hands back an existing id, that is the record to grow.
## A note that asserts a fact can carry its own check
**A few notes assert a FACT, and those can carry their own check.**
Supersession only fires once somebody has read a note and disagreed — which
is the case where it was already believed. A note asserting something about
*someone else's* software — what a service does on a duplicate upload, how a
forge numbers its CI runs, what an updater compares — can instead carry
`verify_with` (how to check it) and `expires_when` (the STATE that ends it:
"when the forge numbers runs per workflow", never "in six months").
`notes_due_for_verification` lists them least-recently-confirmed first, with
never-checked at the top; `mark_note_verified` records what you found, and
`still_true=False` deliberately writes nothing — a note whose check failed
is wrong rather than in a state worth recording, so it keeps its place.
**The test is one question: could this note become false without anyone
editing it?** If no, leave both fields empty. That is the normal case, and
an empty `verify_with` is the positive marker for "this is a decision, there
is nothing to go and check" — not an unfinished record. The sweep is only
worth reading while almost nothing is on it, so a check added out of
tidiness costs the whole surface, not just that note.
**The sharper form of the same test: is the thing this note describes yours
to change?** If yes it is a decision — editing your own software is how it
changes, and you will know you did it. Measured against a real corpus, every
note that earned a check was about somebody ELSE's software: a signing
service, a forge, a hub, an SDK, a model, a dependency set.
**Three that look like candidates and are not:**
- **Resume pointers and "current state" notes.** They go stale fastest of
anything, which is exactly why they tempt — but the cure is to update or
delete them, not to schedule a check. A sweep full of pointers is a sweep
nobody reads.
- **Measurements of your own system.** They go false because you changed
something, and you knew. A measurement earns a check only when what it
measures is outside your control.
- **A decision that RESTS on somebody else's behaviour.** The decision is
still a decision. Put the check on the note asserting the fact, and link
the decision to it.
Not for tasks — a task's decay is its status, and a done issue records what
happened rather than asserting something that can go false. Not for snippets
either: `verify_snippet` compares the recorded location and code against the
repo, which is richer and already wired to drift detection.
+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.
+16
View File
@@ -475,3 +475,19 @@ async def rule_row(rule_id: int):
async with async_session() as s:
return await s.get(Rule, rule_id)
def skill_text(name: str) -> str:
"""Everything a bundled skill states: its SKILL.md, then each reference file.
A skill keeps what matters on most turns in SKILL.md and moves what matters
at one moment into sibling files it links (Agent Skills progressive
disclosure, #4398). Both are the skill's own statement, so a test asking
"does this skill still say X" reads them together rather than pinning X to
whichever file it happens to sit in today.
"""
import pathlib
folder = pathlib.Path(__file__).resolve().parents[1] / "plugin" / "skills" / name
refs = sorted(p for p in folder.glob("*.md") if p.name != "SKILL.md")
return "\n\n".join(p.read_text() for p in [folder / "SKILL.md", *refs])
+13 -8
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
@@ -45,6 +48,8 @@ import pathlib
import re
from typing import NamedTuple
from tests.helpers import skill_text
ROOT = pathlib.Path(__file__).resolve().parents[1]
@@ -72,7 +77,7 @@ def delivered_surfaces() -> dict[str, str]:
The one definition of "delivered" for this module:
- `instructions` — the MCP server's `_INSTRUCTIONS` (every MCP client)
- `docstrings` — the MCP tool modules (tool descriptions, every client)
- `skill:<name>` — each bundled Agent Skill
- `skill:<name>` — each bundled Agent Skill, reference files included
- `static` — the Claude Code adapter's static session context
- `commands` — the Claude Code adapter's slash commands
- `live` — the live session context the server builds
@@ -87,8 +92,10 @@ def delivered_surfaces() -> dict[str, str]:
"commands": "".join(p.read_text() for p in sorted((ROOT / "plugin/commands").glob("*.md"))),
"live": _live_session_context_source(),
}
# A skill is SKILL.md plus the reference files it links (#4398): one
# owner, however many files it is split across.
for skill in sorted((ROOT / "plugin/skills").glob("*/SKILL.md")):
surfaces[f"skill:{skill.parent.name}"] = skill.read_text()
surfaces[f"skill:{skill.parent.name}"] = skill_text(skill.parent.name)
return {label: _norm(text) for label, text in surfaces.items()}
@@ -174,8 +181,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 +199,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 +231,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"),
+12 -7
View File
@@ -45,11 +45,12 @@ def _all_surfaces() -> list[tuple[str, str]]:
the push channel to the operator installing the plugin, and telling a human
what the hook does is not the same act as telling an agent it need not pull.
The boundary is "does a session read this", which is skills (loaded by
description match), the hook-injected static context, and the MCP server's
description match, and each reference file a skill links is read on its
own, so it is its own surface — #4398), the hook-injected static context, and the MCP server's
own instructions.
"""
found = [(str(p.relative_to(ROOT)), p.read_text())
for p in (ROOT / "plugin" / "skills").rglob("SKILL.md")]
for p in (ROOT / "plugin" / "skills").rglob("*.md")]
found += [(str(p.relative_to(ROOT)), p.read_text())
for p in (ROOT / "plugin" / "hooks").glob("*.md")]
server = ROOT / "src" / "scribe" / "mcp" / "server.py"
@@ -69,8 +70,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 +185,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"
+16 -15
View File
@@ -41,7 +41,6 @@ import pathlib
import re
ROOT = pathlib.Path(__file__).resolve().parents[1]
SKILL = ROOT / "plugin/skills/using-scribe/SKILL.md"
# The three tools that shipped with no route to them. Named together because
# the gap was all three at once, and a partial fix would leave the loop broken
@@ -51,7 +50,10 @@ TUNING_TOOLS = ("retrieval_telemetry", "update_rule", "retrieval_surfaces",
def _skill() -> str:
return SKILL.read_text()
# The route lives in missed-retrieval.md, a reference file of using-scribe
# (#4398); the skill is SKILL.md and its references read together.
from tests.helpers import skill_text
return skill_text("using-scribe")
def _instructions() -> str:
@@ -139,20 +141,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
+109
View File
@@ -0,0 +1,109 @@
"""Every bundled skill keeps the shape Agent Skills can load well (#4398).
WHY THIS EXISTS
Anthropic's skill authoring guidance
(platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices):
keep the SKILL.md body under 500 lines, split into reference files as it
nears that, and link each reference file ONE level deep from SKILL.md —
Claude may only preview a file reached through another reference file.
using-scribe reached 478 lines because every new practice lands there, the
same squeeze `_INSTRUCTIONS` was in before #4389. It was split: what matters
on most turns stays in SKILL.md, what matters at one moment (placing a rule,
a missed retrieval, starting a project) moved to files SKILL.md names with
the moment to open them. These tests keep that shape from quietly undoing
itself.
WHAT IT PINS
1. A BUDGET BELOW THE GUIDELINE. 500 is where loading degrades; the budget
sits under it so the next addition is a choice about what moves out, not
a squeeze past the line.
2. EVERY REFERENCE FILE IS LINKED FROM SKILL.md. A file nothing links is
never read — it states guidance that reaches nobody.
3. NO REFERENCE FILE LINKS ANOTHER. One level deep, per the guidance.
4. A LONG REFERENCE FILE OPENS WITH ITS CONTENTS, so a partial read still
shows what the file covers.
"""
from __future__ import annotations
import pathlib
import re
SKILLS = pathlib.Path(__file__).resolve().parents[1] / "plugin" / "skills"
SKILL_LINE_BUDGET = 350
TOC_AFTER_LINES = 100
_MD_LINK = re.compile(r"\]\(([^)#\s]+\.md)\)")
def _skill_dirs() -> list[pathlib.Path]:
return sorted(p.parent for p in SKILLS.glob("*/SKILL.md"))
def _refs(folder: pathlib.Path) -> list[pathlib.Path]:
return sorted(p for p in folder.glob("*.md") if p.name != "SKILL.md")
def over_budget(texts: dict[str, str], budget: int) -> list[str]:
return [f"{k}: {len(t.splitlines())} lines" for k, t in texts.items()
if len(t.splitlines()) > budget]
def unlinked(skill_md: str, ref_names: list[str]) -> list[str]:
linked = set(_MD_LINK.findall(skill_md))
return [n for n in ref_names if n not in linked]
def nested(refs: dict[str, str]) -> list[str]:
return [f"{name} → {target}" for name, text in refs.items()
for target in _MD_LINK.findall(text)]
def missing_contents(refs: dict[str, str], after: int) -> list[str]:
return [name for name, text in refs.items()
if len(text.splitlines()) > after and "## Contents" not in text]
def test_every_skill_md_fits_its_budget():
found = over_budget({d.name: (d / "SKILL.md").read_text() for d in _skill_dirs()},
SKILL_LINE_BUDGET)
assert not found, (
f"SKILL.md over {SKILL_LINE_BUDGET} lines: {found}. Move what matters at "
f"one moment into a reference file SKILL.md links with that moment "
f"(#4398), rather than raising the budget toward 500."
)
def test_every_reference_file_is_linked_from_skill_md():
found = [f"{d.name}/{n}" for d in _skill_dirs()
for n in unlinked((d / "SKILL.md").read_text(), [p.name for p in _refs(d)])]
assert not found, (
f"reference files no SKILL.md links: {found}. Nothing reads a file "
f"nothing names — link it from SKILL.md with the moment to open it."
)
def test_reference_files_are_one_level_deep():
found = [f"{d.name}/{x}" for d in _skill_dirs()
for x in nested({p.name: p.read_text() for p in _refs(d)})]
assert not found, (
f"reference files linking other files: {found}. Link each from SKILL.md "
f"directly; a file reached through another may only be previewed."
)
def test_long_reference_files_open_with_contents():
found = [f"{d.name}/{n}" for d in _skill_dirs()
for n in missing_contents({p.name: p.read_text() for p in _refs(d)},
TOC_AFTER_LINES)]
assert not found, f"reference files over {TOC_AFTER_LINES} lines with no '## Contents': {found}"
def test_the_guards_can_fail():
"""Rule 167: each guard bites on the failure it exists for."""
assert over_budget({"s": "x\n" * 400}, SKILL_LINE_BUDGET) == ["s: 400 lines"]
assert unlinked("see [a](a.md)", ["a.md", "b.md"]) == ["b.md"]
assert nested({"a.md": "then [b](b.md)"}) == ["a.md → b.md"]
assert missing_contents({"a.md": "x\n" * 150}, TOC_AFTER_LINES) == ["a.md"]
+4 -5
View File
@@ -91,12 +91,11 @@ def test_the_skill_carries_the_test_a_writer_can_actually_apply():
read while deciding what to write. The one-question form has to be in the
second place too, or the guidance only reaches callers who already opened
the tool."""
import pathlib
from tests.helpers import skill_text
skill = pathlib.Path(__file__).resolve().parents[1] / (
"plugin/skills/using-scribe/SKILL.md"
)
text = " ".join(skill.read_text().split())
# Reference files included: the full statement sits in writing-records.md
# and SKILL.md keeps the pointer (#4398).
text = " ".join(skill_text("using-scribe").split())
assert "could this note become false without anyone editing it" in text.lower(), (
"the using-scribe skill no longer carries the one-question test. That "
"question is what makes the distinction applicable rather than merely "