feat(instructions): fit the delivery fold — 2k server map, floor Systems reflex, write-time systems_hint
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 28s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 28s
Claude Code injects only the first ~2,048 chars of an MCP server's instructions and silently cuts the rest mid-word (#2562, observed live): _INSTRUCTIONS was 20,002 chars, so ~90% — including all Systems tagging guidance — never reached any session. Rearchitect delivery around what each surface actually delivers: - _INSTRUCTIONS becomes a 1,997-char purpose-sorted map, with a header comment stating the budget and where detail belongs instead. - Tool docstrings keep the per-tool HOW (audit: nearly all displaced topics were already duplicated there); backfill the four gaps — enter_project session scoping + project bootstrap, create_rule entity-vs-rule test, create_design_system not-a-rulebook, create_system two-records test. - The plugin static context (the delivery floor) gains the tag-to-Systems reflex and a surfaces-layering statement; plugin 0.1.25 -> 0.1.26 so the executing cache refreshes (#2209). - create_task / create_note / create_snippet return a systems_hint when a record is created untagged in a project that has Systems — in-band at the exact write it applies to, fail-open like the dedup gate. - Guards: _INSTRUCTIONS length budget, floor-states-the-reflex, and a displaced-topics sweep asserting every cut topic still lives on a delivered surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "scribe",
|
||||
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
|
||||
"version": "0.1.25",
|
||||
"version": "0.1.26",
|
||||
"author": { "name": "Bryan Van Deusen" },
|
||||
"mcpServers": {
|
||||
"scribe": {
|
||||
|
||||
@@ -37,6 +37,14 @@ for the operator's work, and as your own working memory across sessions.
|
||||
moment it's complete. When you **fix** something — even in passing — record it
|
||||
as its own issue (`create_task(kind="issue")`), not as a work-log line on an
|
||||
unrelated open task.
|
||||
- **Tag to Systems as you write** — `enter_project` lists the project's
|
||||
Systems (its named subsystems/areas). When you create or meaningfully update
|
||||
a record, ask which area it is about and pass `system_ids`; if the area has
|
||||
no System yet, create it with `create_system` (name + a one-paragraph
|
||||
charter) rather than leaving it unmodelled. A record about no particular
|
||||
area takes none — don't force it. Untagged writes in a project that has
|
||||
Systems come back with a `systems_hint` naming them — treat it as the tagging
|
||||
question asked at exactly the right moment, not as noise to skip past.
|
||||
- **Reuse before rebuilding** — before writing a new helper/utility/component,
|
||||
search recorded **snippets** (reusable code recorded once for recall) and
|
||||
reuse the prior art instead of re-solving it; when you build something
|
||||
@@ -51,6 +59,14 @@ for the operator's work, and as your own working memory across sessions.
|
||||
`/compact` (name what you logged). You can't run it yourself — surface the
|
||||
recommendation and let them decide. Suggest it at seams, not every turn.
|
||||
|
||||
**How the instruction surfaces divide the work:** this file carries the
|
||||
session-level reflexes (WHEN to reach for Scribe); each tool's own description
|
||||
carries its full contract (HOW to call it — read it when you load the tool);
|
||||
the bundled skills carry process arcs (planning, debugging, verification). The
|
||||
MCP server's instruction block is deliberately only a map — the client injects
|
||||
roughly its first 2,000 characters and silently cuts the rest, so nothing
|
||||
load-bearing lives below that fold.
|
||||
|
||||
**If two Scribe instruction surfaces disagree** — this file, the MCP server's
|
||||
tool instructions, the `using-scribe` skill — **follow the one that assumes
|
||||
least about its own delivery.** This file is the floor: it ships with the
|
||||
|
||||
+54
-296
@@ -5,306 +5,64 @@ 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
|
||||
#
|
||||
# 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).
|
||||
#
|
||||
# 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.
|
||||
_INSTRUCTIONS = """
|
||||
Scribe is the user's self-hosted second-brain and project-management data
|
||||
store, and your own system of record for their work. You (Claude) are the
|
||||
assistant: record what you do here — tasks, work-logs, decisions, notes — and
|
||||
recall from here before acting. Do not keep the user's project work in local
|
||||
files (CLAUDE.md, scratch/auto memory) in parallel; Scribe holds the single copy.
|
||||
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.
|
||||
|
||||
Hierarchy: Project -> Milestone -> Task/Note.
|
||||
Hierarchy: Project -> Milestone -> Task/Note. The map, by purpose:
|
||||
- ORIENT: enter_project(id) at session start — rules, open tasks, recent
|
||||
notes, Systems and design system in one call.
|
||||
- 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, before answering about the
|
||||
operator's work or opening a task — assume prior art exists, and 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 to work: rules are pull-only and binding — call list_always_on_rules()
|
||||
yourself at session start; a push that also delivered them was an
|
||||
optimisation, not the bridge.
|
||||
- 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. Saved procedures are Processes (follow verbatim). Deletes
|
||||
are trash-recoverable.
|
||||
|
||||
What each part is for, and when to reach for it:
|
||||
- Project: the top-level container for a body of work.
|
||||
- Milestone: groups related tasks within a project toward a goal (status
|
||||
active/done). A milestone is ALSO the home of a plan — its `body` holds the
|
||||
design/intent (Goal/Approach/Verification) and its child tasks are the steps.
|
||||
Use one when a chunk of work needs its own arc.
|
||||
- Task: a unit of actionable work with a lifecycle (status
|
||||
todo/in_progress/done/cancelled, optional priority). A task is a note with a
|
||||
status — reach for one when there is something to DO. Record progress over
|
||||
time with work-logs (add_task_log) rather than rewriting the body.
|
||||
- Issue: a task whose kind is corrective — a problem you fixed or are fixing, as
|
||||
opposed to productive `work`. Create it with create_task(kind="issue"); the
|
||||
body carries symptom → root cause → fix. It has the full task lifecycle, and
|
||||
can link the originating task it arose from (arose_from_id) and the System(s)
|
||||
it touches (system_ids). Reach for one whenever you fix something — even in
|
||||
passing — instead of burying the fix in another task's work-log.
|
||||
- Plan: a MILESTONE acting as a plan container — HOW you'll execute a chunk of
|
||||
work. The design/intent lives in the milestone `body`; each step is its own
|
||||
child task (create_task(milestone_id=...)), tracked with status + work-logs —
|
||||
NOT a checkbox buried in the body. Create one with start_planning when the
|
||||
work has an arc (same test as a milestone, above) and you want the approach
|
||||
reviewable before you start; read it back with get_milestone (body + steps).
|
||||
Work without an arc is a task, not a plan. (The old kind=plan task is retired
|
||||
— some historical plan-tasks still exist and remain readable, but don't
|
||||
create new ones.)
|
||||
- Note: durable free-form knowledge — reference material, decisions, logs of
|
||||
what happened.
|
||||
No lifecycle, not actionable. Reach for one to CAPTURE something worth keeping.
|
||||
- Design system: the visual standards a project's UI is built from — design
|
||||
tokens (name + value per mode) plus the prose a token table cannot hold
|
||||
(aesthetic, voice, what is out of scope). Systems INHERIT: a child holds only
|
||||
what it changes and the chain supplies the rest, so a family's house style and
|
||||
one app's departures from it are the same structure at two depths. A project
|
||||
points at one with set_project_design_system, and enter_project then hands it
|
||||
back with the guidance chain-merged. Treat it as binding for UI work: reach
|
||||
for a token (resolve_design_system / get_design_system_stylesheet) before
|
||||
writing a colour, size, radius or duration by hand. Do NOT record a design
|
||||
system as a rulebook — rules are for behaviour, and tokens kept as prose
|
||||
cannot be resolved, inherited, rendered to a stylesheet, or checked against
|
||||
code.
|
||||
- System: a per-project, reusable, self-describing subsystem/area — the
|
||||
project's vocabulary for WHERE work happens. enter_project returns the list.
|
||||
TAG AS YOU WRITE: when you create or meaningfully update a note, task, or
|
||||
snippet, ask which of those areas it is about and pass system_ids. The test:
|
||||
would someone investigating that subsystem want this record in the pile
|
||||
list_system_records returns? Cross-cutting records take several; a record
|
||||
about no particular area takes none — don't force it. If the area a record
|
||||
describes has no System yet, CREATE it (create_system: name + a one-paragraph
|
||||
charter) and tag the record — a subsystem that exists in the code deserves a
|
||||
System the moment two records would share it, the same two-or-more test
|
||||
snippets use; don't wait to be asked to name an area that plainly exists.
|
||||
Read a subsystem back with list_system_records, or search(system_id=...) for
|
||||
a ranked cut.
|
||||
- Reference note vs dev-log — STATE vs CHRONICLE. A dev-log records what
|
||||
HAPPENED: write it once, never rewrite it. A durable finding — how a
|
||||
subsystem works, a measured number, an architecture fact — belongs in that
|
||||
System's REFERENCE NOTE ("«System name» — reference", tagged to the System),
|
||||
which is UPDATED IN PLACE as the facts change. Updating loses nothing: every
|
||||
meaningful edit is snapshotted (note versions are the changelog). Create the
|
||||
reference note if the System lacks one; update it if it exists; have the
|
||||
dev-log [[link]] it rather than restating state. State smeared across dated
|
||||
logs is unreachable by search — sixteen near-identical dev-logs tie, and no
|
||||
ranking can pick the right one, because no right one exists.
|
||||
A task is a note with status; *_note tools for notes, *_task for tasks.
|
||||
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.
|
||||
|
||||
Mechanics:
|
||||
- Notes and Tasks share a model; tasks are notes with is_task=True.
|
||||
- Use the *_note tools for notes, the *_task tools for tasks. Don't mix them.
|
||||
- Tags are plain strings (no `#` prefix). Empty list clears tags; omit to leave
|
||||
unchanged on updates.
|
||||
- For optional integer FKs (project_id, milestone_id, parent_id), use 0 to mean
|
||||
"not set". On update_task, -1 clears an existing FK (e.g. milestone_id=-1
|
||||
removes the task from its milestone); 0 leaves it unchanged.
|
||||
|
||||
Reach for Scribe to RECALL, not just to record. Scribe is a second brain —
|
||||
its value is mostly in what it already holds, so make searching it a reflex,
|
||||
not something you wait to be asked for:
|
||||
- Before you answer a question about the user's work, or start a task, search
|
||||
Scribe first (search / list_tasks / list_notes). Assume relevant prior work
|
||||
already exists — a related task, an earlier decision, a prior note — and look
|
||||
before you re-derive it or open a duplicate.
|
||||
- Before creating a task, search for an existing one (search content_type=
|
||||
'task') — don't open a second task for work already tracked.
|
||||
- create_note / create_task enforce this: if a title- or meaning-similar record
|
||||
already exists in the same project, the call is BLOCKED and returns
|
||||
{"duplicate": true, "existing_id": ...} instead of creating. UPDATE that
|
||||
record (update_note / update_task / add_task_log) rather than duplicating.
|
||||
Only pass force=true when it's genuinely a distinct record — a duplicate both
|
||||
bloats the store and surfaces as a stale competing copy in later searches.
|
||||
- When a note genuinely IS new but overtakes an older one, say so: pass the
|
||||
older note's id in `supersedes` on create_note / update_note. Reach for it on
|
||||
a re-measurement, a decision that reverses an earlier one, a dev-log covering
|
||||
ground a previous one covered. The old note stays readable and keeps its
|
||||
place; it stops competing for the same question and arrives labelled. This is
|
||||
the third answer alongside update-instead and force: not everything that
|
||||
resembles an existing record should be folded into it, and not everything
|
||||
distinct should compete with it forever. If a result carries `superseded_by`,
|
||||
a later note claims to have brought it up to date — read it as what was true
|
||||
when written and open the newer one before acting.
|
||||
- Scope to the project in scope. When a project is active (you called
|
||||
enter_project), pass its project_id to search / list_tasks / list_notes so
|
||||
results stay inside that project. Querying with no project_id pulls in every
|
||||
project and bleeds unrelated work into the session — only do it for a
|
||||
deliberate cross-project sweep. get_recent takes no project filter and spans
|
||||
every project; when one is active, prefer the scoped list_* tools over it.
|
||||
And this is not only about reads: once a project is in scope, only reference
|
||||
or offer work on THAT project — don't surface or propose work from other
|
||||
projects unless the operator widens scope. If something clearly belongs to a
|
||||
different project, say so and ask before switching; never silently operate
|
||||
cross-project. The active project does not stick on the server (each call is
|
||||
self-contained); carrying its id forward is on you.
|
||||
|
||||
Keep task state honest — this is what makes the project a trustworthy record:
|
||||
- When you begin working a task, set it to in_progress (update_task
|
||||
status=in_progress).
|
||||
- Log progress as you go with add_task_log — at meaningful steps, not saved up
|
||||
for the end.
|
||||
- The moment a task's work is complete, set it done. Never leave finished work
|
||||
at todo/in_progress — an out-of-date status makes Scribe misrepresent what's
|
||||
left to do.
|
||||
- At a meaningful point — finishing a task, or hitting or discovering a problem
|
||||
that changes direction — write a short dated note on the project (create_note)
|
||||
capturing what happened (the pivots, not just the wins), and set the finished
|
||||
task to done.
|
||||
- When you fix a problem — even one solved in passing — record it as its own
|
||||
issue (create_task(kind="issue")) with symptom → root cause → fix in the body,
|
||||
NOT as a work-log line on whatever task happened to be open. An issue is
|
||||
corrective work with its own lifecycle; recording it discretely (optionally
|
||||
linked via arose_from_id to the task it came from, and system_ids to the
|
||||
subsystem it touches) is what makes it findable so it isn't diagnosed from
|
||||
scratch next time.
|
||||
|
||||
Compaction hygiene — recommend compacting at clean seams. Because you record
|
||||
progress as you go, a context compaction is SAFE: the durable state lives in
|
||||
Scribe (task status, work-logs, decision notes), not the transcript, so it
|
||||
survives the summary. Use this rather than letting auto-compaction fire mid-task:
|
||||
- At the end of a coherent block of work (a task closed, a plan phase finished)
|
||||
in a long session, first make sure in-flight state is actually in Scribe —
|
||||
update task status, add a work-log, capture any decision as a note. Surface
|
||||
the few things worth logging before suggesting the compact.
|
||||
- Then tell the operator it's a good, safe moment to /compact, naming what you
|
||||
logged ("logged to #X/#Y — safe to /compact, nothing will be lost"). You
|
||||
cannot run /compact yourself; surface the recommendation and let them decide.
|
||||
- Recommend it at genuine seams, not every turn. The next session's start will
|
||||
prompt you to reload your bearings from Scribe — so a clean-seam compact plus
|
||||
that reload loses nothing.
|
||||
|
||||
Scribe maintains a Rulebook system (Rulebook -> Topic -> Rule). Rules carry
|
||||
an actionable statement plus optional Why and How-to-apply context. At the
|
||||
start of any session that touches Scribe, call list_always_on_rules() to
|
||||
load the standing rules — treat them as binding. When you also have a project
|
||||
in scope, get_project(id) returns applicable_rules (rules from rulebooks the
|
||||
project subscribes to) and subscribed_rulebooks; consult those too. Full text
|
||||
(Why / How-to-apply) is available via get_rule(id).
|
||||
|
||||
Workflow and standards rules live in Scribe. When you notice a pattern
|
||||
worth codifying, call create_rule (cross-project, lands in a rulebook+topic)
|
||||
or create_project_rule (one project only, no rulebook ceremony). Do NOT add
|
||||
new engineering rules to CLAUDE.md or to ~/.claude/.../memory/feedback_*.md
|
||||
— those stores are reserved for facts about the user (preferences, role,
|
||||
communication style) and codebase onboarding pointers, respectively. Before
|
||||
creating a rule, call list_always_on_rules and list_rules(project_id=...) to
|
||||
avoid duplicates.
|
||||
|
||||
Choose a rule's home by WHO it should bind, and keep each home's rules at the
|
||||
right altitude:
|
||||
- Always-on rulebook (a rulebook flagged always_on) — universal norms that
|
||||
bind EVERY one of your projects. Reserve for cross-project standards.
|
||||
- Subscribed rulebook (always_on off; projects opt in via
|
||||
subscribe_project_to_rulebook) — a reusable, THEMED module of general
|
||||
rules that binds only the projects which subscribe. Its rules must make
|
||||
sense for every project that could subscribe, never one specific project
|
||||
(e.g. a code-review checklist, or a compliance regime a category of
|
||||
projects shares — no rule names a single app).
|
||||
- Project rule (create_project_rule) — anything specific to ONE project.
|
||||
Both rulebook tiers are SHARED, so their rules stay general; the difference
|
||||
between them is REACH (all projects vs opt-in by theme), not generality. Rule
|
||||
of thumb: names a specific project's files/paths/quirks -> project rule; a
|
||||
standard a CATEGORY of projects shares -> subscribed rulebook; a universal
|
||||
norm -> always-on rulebook. Coordinate with the operator on which home fits.
|
||||
|
||||
Before writing a rule, check whether another entity already models the thing.
|
||||
A rule is prose an agent must remember and apply; the other entities are
|
||||
structure a tool can resolve, render and check. Visual standards are 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 genuinely is a standing instruction
|
||||
about how to work, and nothing else can hold it.
|
||||
|
||||
Rules are pull-only: CALL list_always_on_rules() YOURSELF at the start of a
|
||||
session, before substantive work. Do not wait to be handed them. The plugin's
|
||||
SessionStart hook also pushes the always-on rules + active-project context, but
|
||||
that is a delivery optimisation, not the bridge — it can be absent (instance
|
||||
unreachable, key not configured, or the hook itself silently inert, which is
|
||||
what #2198 was) and its absence does not announce itself. When the push did
|
||||
arrive, the pull costs one redundant call; when it didn't, skipping the pull
|
||||
means working without the operator's binding rules and having no signal that
|
||||
you are. Those two costs are not comparable, so pull.
|
||||
|
||||
One thing NOT to do: don't bridge Scribe into a session by writing to the
|
||||
host's native memory. That explicit pull IS the bridge — it is written into
|
||||
every instruction surface the session already loads, so it needs no pointer on
|
||||
disk. So do NOT create or refresh a "rules live in Scribe" pointer in
|
||||
CLAUDE.md / AGENTS.md / ~/.claude memory, and do NOT keep rules, recall, or
|
||||
plans in those stores in parallel with Scribe — Scribe holds the single copy,
|
||||
and a pointer Scribe cannot update is one that goes stale without anyone
|
||||
noticing. Native auto-memory stays for facts about the user; CLAUDE.md for
|
||||
codebase onboarding. Never make Scribe's correctness depend on the operator
|
||||
disabling a native function (e.g. autoMemoryEnabled): the plugin must work with
|
||||
auto-memory at its default. If the plugin is ever removed the session loses both
|
||||
the push and these instructions, and rebuilds context over time — an acceptable
|
||||
cost, and far better than a silent settings change the operator may not know
|
||||
about.
|
||||
|
||||
When you are working on a specific project, call enter_project(project_id)
|
||||
ONCE at session start (or whenever the active project changes). It returns the
|
||||
project, its applicable_rules + project_rules + subscribed_rulebooks, milestone
|
||||
summary, open tasks, and recent notes — everything you need to know the lay of
|
||||
the land before mutating. Don't call get_project + get_applicable_rules + a
|
||||
search separately when enter_project already composes them.
|
||||
|
||||
Don't wait to be told which project you're in. At the start of a session that
|
||||
touches Scribe — or the moment work clearly belongs to a project but none is in
|
||||
scope — bootstrap project context proactively: search for a related existing
|
||||
project (search / list_projects, matching on the work's subject, the repo or
|
||||
directory name, and recent activity). If you find a confident match, propose it
|
||||
and call enter_project once the operator confirms. If nothing matches, offer to
|
||||
create a project, confirming its name and goal first. Always confirm before
|
||||
adopting or creating — never do either silently, and never guess a project into
|
||||
existence. Once a project is in scope, the enter_project handshake and the
|
||||
host-memory pointer step above both apply.
|
||||
|
||||
When work DOES get a plan, Scribe is the plan's canonical home: it is a
|
||||
milestone (see the Plan entry above), created with start_planning and written
|
||||
into with update_milestone + child tasks. If a habit tells you to save a plan or
|
||||
spec to a local `.md` file, that's superseded here — the milestone is the
|
||||
record, not a file on disk. Whether a given piece of work wants a plan at all is
|
||||
a separate question, answered by the arc test above and by the writing-plans
|
||||
skill; these instructions do not mandate one.
|
||||
|
||||
Deletes are recoverable: every delete_* tool moves the entity (and its
|
||||
descendants) to the trash and returns a deleted_batch_id. Use list_trash() to
|
||||
see trashed batches, restore(deleted_batch_id) to undo a deletion, and
|
||||
purge_trash(deleted_batch_id, confirmed=True) for a permanent delete. Trash
|
||||
auto-purges after the operator's retention window.
|
||||
|
||||
Scribe stores reusable Processes — saved prompts/workflows (note_type
|
||||
"process"), e.g. a drift audit or a DRY pass. When the operator says "run the
|
||||
X process" or otherwise references a saved process, call list_processes() /
|
||||
get_process(name) and follow the returned prompt verbatim, including any
|
||||
"clarify first" steps it contains. Author a new one with create_process(title,
|
||||
body); edit with update_process; retire one with delete_process (recoverable —
|
||||
it goes to the trash like anything else). A near-duplicate is refused at create
|
||||
time, because every Process becomes a skill file that auto-surfaces on the
|
||||
operator's machine: two near-identical procedures don't merely bloat the record,
|
||||
they compete to be followed.
|
||||
|
||||
Scribe also stores Snippets — reusable functions/components recorded once for
|
||||
recall (note_type "snippet"): a name, language, signature, canonical location
|
||||
(repo · path · symbol), a one-line "when to reach for it", and the code. They
|
||||
are ordinary embedded notes, so a recorded snippet also surfaces through the
|
||||
same search + proactive recall as everything else. Two reflexes: (1) before you
|
||||
write a new helper/utility/component, search first (list_snippets(q=...) or
|
||||
search) — reuse the prior art with get_snippet(id) instead of re-deriving a
|
||||
one-off; (2) the moment you build or notice something reusable, record it with
|
||||
create_snippet(name, code, when_to_use, language, signature, repo, path, symbol,
|
||||
project_id, system_ids) so a later session is offered it. Make when_to_use sharp
|
||||
— it becomes the title, which is what a recall menu shows. Edit an existing one
|
||||
with update_snippet rather than recording a second copy; when the same reusable
|
||||
thing already exists as several one-offs, unify them into one canonical record
|
||||
with merge_snippets (it folds every call site in as a location and trashes the
|
||||
duplicates). Keep the record honest: a snippet whose details have gone stale can
|
||||
be corrected with update_snippet (an empty string clears a field), and one that
|
||||
is wrong or obsolete should be retired with delete_snippet — a bad snippet keeps
|
||||
being offered as prior art, which costs more than none at all.
|
||||
|
||||
Scribe is multi-user, so some records belong to other people. Anything another
|
||||
user owns comes back marked `shared: true` with an `owner`. Treat a shared
|
||||
record as THAT PERSON'S SUGGESTION, never as the operator's settled practice:
|
||||
weigh it on its merits, attribute it when you reference it, and ask before
|
||||
adopting it or acting on it. This matters most for a shared Process — do not run
|
||||
one as written; describe what it would do and get the operator's go-ahead.
|
||||
Records shared directly with the operator are also deliberately search-only:
|
||||
they surface when you look for them (pass a query), not in plain lists, so
|
||||
nobody else's material arrives unasked. Editing another user's record needs an
|
||||
editor or admin share from them; a read-only share is refused, and the right
|
||||
answer is usually to record the operator's own version rather than to push.
|
||||
|
||||
When developing Scribe itself, honor its multi-user sharing ACL: scope every
|
||||
read and mutation of user data by owner + shares — never assume a single
|
||||
operator. "Works for one user" is not done.
|
||||
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.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -39,6 +39,12 @@ async def create_design_system(
|
||||
) -> dict:
|
||||
"""Create a design system, optionally inheriting from another.
|
||||
|
||||
This — not a rulebook — is where visual standards live: a token can be
|
||||
inherited, resolved per mode, rendered to a stylesheet and checked against
|
||||
code, and none of that survives being written as rule prose. Once a
|
||||
project points at one (set_project_design_system), treat it as binding for
|
||||
that project's UI work.
|
||||
|
||||
Args:
|
||||
title: What this system is — a house style, or one app within it
|
||||
(required).
|
||||
|
||||
@@ -14,6 +14,7 @@ Sentinel conventions (inherited from existing fable-mcp tools):
|
||||
from __future__ import annotations
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.mcp.tools import systems as systems_tools
|
||||
from scribe.services import access as access_svc
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
@@ -149,7 +150,10 @@ async def create_note(
|
||||
|
||||
Returns the created note object including its assigned id, OR — when a
|
||||
near-duplicate is found and force is false — {"duplicate": true,
|
||||
"existing_id": ..., "message": ...} and nothing is created.
|
||||
"existing_id": ..., "message": ...} and nothing is created. Created
|
||||
untagged in a project that has Systems, the response carries a
|
||||
`systems_hint` naming them — answer it: tag the record, create the missing
|
||||
System, or deliberately leave it untagged.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
if not force:
|
||||
@@ -180,6 +184,10 @@ async def create_note(
|
||||
data["systems"] = [
|
||||
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
|
||||
]
|
||||
elif project_id:
|
||||
hint = await systems_tools.untagged_systems_hint(uid, project_id)
|
||||
if hint:
|
||||
data["systems_hint"] = hint
|
||||
await _attach_supersession(uid, note.id, data)
|
||||
return data
|
||||
|
||||
|
||||
@@ -70,6 +70,19 @@ async def enter_project(project_id: int) -> dict:
|
||||
departures from it) plus a summary of the token set — treat it as binding
|
||||
for any UI you write, and pull the values with resolve_design_system or
|
||||
get_design_system_stylesheet before reaching for a literal.
|
||||
|
||||
Entering a project also SCOPES the session: reference and offer work on
|
||||
this project only, and pass its id to search / list_* so results stay
|
||||
inside it. If something clearly belongs to a different project, say so and
|
||||
ask before switching — never silently operate cross-project. The active
|
||||
project does not stick on the server (each call is self-contained);
|
||||
carrying its id forward is on you.
|
||||
|
||||
Don't wait to be told which project is in scope. When work clearly belongs
|
||||
to a project but none is entered, look for a match yourself (list_projects
|
||||
/ search on the repo or subject), propose it, and enter it once the
|
||||
operator confirms; if nothing matches, offer to create one — confirming
|
||||
name and goal first, never guessing a project into existence.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
project = await projects_svc.get_project(uid, project_id)
|
||||
|
||||
@@ -271,6 +271,14 @@ async def create_rule(
|
||||
rulebook+topic ceremony). If it's a standard a CATEGORY of projects shares,
|
||||
put it in a themed subscribed rulebook, not the always-on one.
|
||||
|
||||
Before writing a rule at all, check whether another entity already models
|
||||
the thing. A rule is prose an agent must remember and apply; the others
|
||||
are structure a tool can resolve, render and check. Visual standards are a
|
||||
DESIGN SYSTEM (tokens inherit, resolve per mode, render to a stylesheet —
|
||||
none of that survives being prose). A repeatable procedure is a PROCESS.
|
||||
Reusable code is a SNIPPET. Reach for a rule only when the thing genuinely
|
||||
is a standing instruction about how to work and nothing else can hold it.
|
||||
|
||||
Args:
|
||||
topic_id: The topic to attach the rule to.
|
||||
title: A short imperative title (e.g. "dev is home").
|
||||
|
||||
@@ -11,6 +11,7 @@ on create, System association passthrough).
|
||||
from __future__ import annotations
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.mcp.tools import systems as systems_tools
|
||||
from scribe.services import access as access_svc
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import snippets as snippets_svc
|
||||
@@ -132,7 +133,9 @@ async def create_snippet(
|
||||
and it really is the same reusable thing found in another place, prefer
|
||||
merge_snippets(existing_id, [new...]) — or record then merge — to unify them
|
||||
into ONE canonical record (which then carries every call site as a location),
|
||||
rather than forcing a second copy with force=true.
|
||||
rather than forcing a second copy with force=true. Created untagged in a
|
||||
project that has Systems, the response carries a `systems_hint` naming
|
||||
them — answer it: tag, create the missing System, or deliberately skip.
|
||||
|
||||
WHAT THE GATE MATCHES ON. Exact identity first — an existing snippet at the
|
||||
same repo · path · symbol, or holding byte-identical code. Those are certain,
|
||||
@@ -175,6 +178,10 @@ async def create_snippet(
|
||||
data["systems"] = [
|
||||
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
|
||||
]
|
||||
elif project_id:
|
||||
hint = await systems_tools.untagged_systems_hint(uid, project_id)
|
||||
if hint:
|
||||
data["systems_hint"] = hint
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,30 @@ from scribe.mcp._context import current_user_id
|
||||
from scribe.services import systems as systems_svc
|
||||
|
||||
|
||||
async def untagged_systems_hint(user_id: int, project_id: int) -> str | None:
|
||||
"""Nudge text for a record created untagged in a project that has Systems.
|
||||
|
||||
Not a tool. create_task / create_note / create_snippet attach this to
|
||||
their responses so the tagging question arrives in-band at the exact write
|
||||
it applies to — instruction prose alone demonstrably doesn't fire at write
|
||||
time, while in-band behavior (the duplicate gate) does (#2562).
|
||||
"""
|
||||
# Fail-open like the dedup gate: a hint must never break a create.
|
||||
try:
|
||||
systems = await systems_svc.list_systems(user_id, project_id)
|
||||
except Exception:
|
||||
return None
|
||||
if not systems:
|
||||
return None
|
||||
names = ", ".join(f"#{s.id} {s.name}" for s in systems)
|
||||
return (
|
||||
f"Created untagged. This project's Systems: {names}. If this record is "
|
||||
"about one of those areas, tag it (update it with system_ids=[...]); if "
|
||||
"its area is missing, create_system it and tag; if it is about no "
|
||||
"particular area, leave it untagged."
|
||||
)
|
||||
|
||||
|
||||
async def create_system(
|
||||
project_id: int,
|
||||
name: str,
|
||||
@@ -27,6 +51,13 @@ async def create_system(
|
||||
Associate records with it via the `system_ids` arg on create/update_task and
|
||||
create/update_note.
|
||||
|
||||
Create one the moment two records would share an area that has no System
|
||||
yet — the same two-or-more test snippets use. Don't wait to be asked to
|
||||
name an area that plainly exists in the code; an unmodelled area means
|
||||
every record about it stays untaggable. Give it a one-paragraph charter,
|
||||
not just a label: the description is what tells a later session whether a
|
||||
record belongs here.
|
||||
|
||||
Args:
|
||||
project_id: The project this system belongs to (required).
|
||||
name: Short label (required).
|
||||
|
||||
@@ -19,6 +19,7 @@ Sentinels (preserved from existing fable-mcp):
|
||||
from __future__ import annotations
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.mcp.tools import systems as systems_tools
|
||||
from scribe.services import access as access_svc
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
@@ -151,7 +152,9 @@ async def create_task(
|
||||
|
||||
Returns the created task, OR — when a near-duplicate is found and force is
|
||||
false — {"duplicate": true, "existing_id": ..., "message": ...} (nothing
|
||||
created).
|
||||
created). Created untagged in a project that has Systems, the response
|
||||
carries a `systems_hint` naming them — answer it: tag the record, create
|
||||
the missing System, or deliberately leave it untagged.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
if kind == "plan":
|
||||
@@ -188,6 +191,10 @@ async def create_task(
|
||||
data["systems"] = [
|
||||
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
|
||||
]
|
||||
elif project_id:
|
||||
hint = await systems_tools.untagged_systems_hint(uid, project_id)
|
||||
if hint:
|
||||
data["systems_hint"] = hint
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -85,6 +85,95 @@ def test_every_session_start_surface_states_the_pull():
|
||||
)
|
||||
|
||||
|
||||
def _instructions_text() -> str:
|
||||
"""The _INSTRUCTIONS literal from server.py, as the client would see it."""
|
||||
import re
|
||||
src = (ROOT / "src" / "scribe" / "mcp" / "server.py").read_text()
|
||||
match = re.search(r'_INSTRUCTIONS = """(.*?)"""', src, re.S)
|
||||
assert match, "server.py no longer defines _INSTRUCTIONS as a triple-quoted literal"
|
||||
return match.group(1).strip()
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
|
||||
def test_floor_states_the_systems_reflex():
|
||||
"""Write-time tagging guidance must live on the surface that always arrives.
|
||||
|
||||
#2562's behavioral finding: with the guidance only in tool descriptions,
|
||||
sessions filed records untagged. The static context is the delivery floor,
|
||||
so the tag-as-you-write reflex has to be stated there.
|
||||
"""
|
||||
floor = (ROOT / "plugin" / "hooks" / "scribe_static_context.md").read_text()
|
||||
for needle in ("system_ids", "create_system"):
|
||||
assert needle in floor, (
|
||||
f"plugin/hooks/scribe_static_context.md no longer mentions "
|
||||
f"{needle} — the Systems tagging reflex must be stated on the "
|
||||
f"floor, not only in tool descriptions (#2562)."
|
||||
)
|
||||
|
||||
|
||||
# Topics displaced from _INSTRUCTIONS when it was cut to fit the fold. Each
|
||||
# must remain stated on at least one DELIVERED surface: a tool docstring
|
||||
# (arrives with the tool schema), the plugin static context (always arrives),
|
||||
# or a bundled skill (arrives on trigger match). Keyed by a phrase distinctive
|
||||
# enough that its disappearance means the guidance is gone, not reworded —
|
||||
# update the phrase alongside a deliberate rewording.
|
||||
DISPLACED_TOPICS = {
|
||||
"supersedes": "supersedes",
|
||||
"trash is recoverable": "deleted_batch_id",
|
||||
"duplicate gate": "duplicate",
|
||||
"systems tag-as-you-write": "system_ids",
|
||||
"reference note vs dev-log": "reference note",
|
||||
"work-logs over body rewrites": "add_task_log",
|
||||
"rule homes / altitude": "create_project_rule",
|
||||
"rules vs other entities": "standing instruction",
|
||||
"shared records are suggestions": "shared",
|
||||
"processes run verbatim": "verbatim",
|
||||
"snippet reuse reflex": "when_to_use",
|
||||
"compaction at seams": "compact",
|
||||
"plans are milestones": "start_planning",
|
||||
"scope to the entered project": "cross-project",
|
||||
"project bootstrap needs confirmation": "never guessing a project",
|
||||
}
|
||||
|
||||
|
||||
def test_displaced_topics_live_on_a_delivered_surface():
|
||||
corpus = ""
|
||||
for p in (ROOT / "src" / "scribe" / "mcp" / "tools").glob("*.py"):
|
||||
corpus += p.read_text()
|
||||
corpus += (ROOT / "plugin" / "hooks" / "scribe_static_context.md").read_text()
|
||||
for p in (ROOT / "plugin" / "skills").rglob("SKILL.md"):
|
||||
corpus += p.read_text()
|
||||
corpus = corpus.lower()
|
||||
missing = [
|
||||
f"{topic} (phrase: {phrase!r})"
|
||||
for topic, phrase in DISPLACED_TOPICS.items()
|
||||
if phrase.lower() not in corpus
|
||||
]
|
||||
assert not missing, (
|
||||
f"guidance displaced from _INSTRUCTIONS has fallen off every delivered "
|
||||
f"surface (tool docstrings / static context / skills): {missing}. It "
|
||||
f"was cut from _INSTRUCTIONS deliberately (#2562) on the premise it "
|
||||
f"lives elsewhere — restore it somewhere that delivers."
|
||||
)
|
||||
|
||||
|
||||
def test_no_surface_names_the_push_without_stating_the_pull():
|
||||
"""The exact shape #2497 took.
|
||||
|
||||
|
||||
@@ -73,3 +73,64 @@ async def test_create_task_issue_sets_kind_provenance_and_systems():
|
||||
assert kwargs["arose_from_id"] == 9
|
||||
systems_svc.set_record_systems.assert_awaited_once_with(1, 50, [2, 3])
|
||||
assert result["id"] == 50
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_untagged_hint_names_the_projects_systems():
|
||||
sys_a = MagicMock(); sys_a.id = 1; sys_a.name = "workers"
|
||||
sys_b = MagicMock(); sys_b.id = 2; sys_b.name = "scrape-pipeline"
|
||||
with patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
||||
svc.list_systems = AsyncMock(return_value=[sys_a, sys_b])
|
||||
from scribe.mcp.tools.systems import untagged_systems_hint
|
||||
hint = await untagged_systems_hint(1, 5)
|
||||
assert "workers" in hint and "scrape-pipeline" in hint
|
||||
assert "system_ids" in hint
|
||||
assert "create_system" in hint
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_untagged_hint_absent_without_systems_and_fails_open():
|
||||
from scribe.mcp.tools.systems import untagged_systems_hint
|
||||
with patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
||||
svc.list_systems = AsyncMock(return_value=[])
|
||||
assert await untagged_systems_hint(1, 5) is None
|
||||
with patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
||||
# A hint must never break a create — DB failure degrades to no hint.
|
||||
svc.list_systems = AsyncMock(side_effect=RuntimeError("db down"))
|
||||
assert await untagged_systems_hint(1, 5) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_untagged_in_project_with_systems_carries_hint():
|
||||
note = MagicMock(); note.id = 60; note.to_dict.return_value = {"id": 60}
|
||||
sys_a = MagicMock(); sys_a.id = 4; sys_a.name = "plugin-hooks"
|
||||
with patch("scribe.mcp.tools.tasks.current_user_id", return_value=1), \
|
||||
patch("scribe.mcp.tools.tasks.notes_svc") as notes_svc, \
|
||||
patch("scribe.mcp.tools.tasks.dedup_svc") as dedup_svc, \
|
||||
patch("scribe.mcp.tools.systems.systems_svc") as hint_svc:
|
||||
notes_svc.create_note = AsyncMock(return_value=note)
|
||||
dedup_svc.find_duplicate_note = AsyncMock(return_value=None)
|
||||
hint_svc.list_systems = AsyncMock(return_value=[sys_a])
|
||||
from scribe.mcp.tools.tasks import create_task
|
||||
result = await create_task(title="untagged", project_id=5)
|
||||
assert "plugin-hooks" in result["systems_hint"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_tagged_or_projectless_carries_no_hint():
|
||||
note = MagicMock(); note.id = 61; note.to_dict.return_value = {"id": 61}
|
||||
with patch("scribe.mcp.tools.tasks.current_user_id", return_value=1), \
|
||||
patch("scribe.mcp.tools.tasks.notes_svc") as notes_svc, \
|
||||
patch("scribe.mcp.tools.tasks.dedup_svc") as dedup_svc, \
|
||||
patch("scribe.mcp.tools.tasks.systems_svc") as systems_svc, \
|
||||
patch("scribe.mcp.tools.systems.systems_svc") as hint_svc:
|
||||
notes_svc.create_note = AsyncMock(return_value=note)
|
||||
dedup_svc.find_duplicate_note = AsyncMock(return_value=None)
|
||||
systems_svc.set_record_systems = AsyncMock()
|
||||
systems_svc.list_record_systems = AsyncMock(return_value=[])
|
||||
hint_svc.list_systems = AsyncMock(return_value=[MagicMock()])
|
||||
from scribe.mcp.tools.tasks import create_task
|
||||
tagged = await create_task(title="tagged", project_id=5, system_ids=[4])
|
||||
orphan = await create_task(title="orphan", project_id=0)
|
||||
assert "systems_hint" not in tagged
|
||||
assert "systems_hint" not in orphan
|
||||
|
||||
Reference in New Issue
Block a user