Files
FabledScribe/plugin
bvandeusenandClaude Opus 5 91bc0fb01e
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Failing after 1m13s
CI & Build / Build & push image (push) Skipped
feat(plugin): a high-confidence rule is put in front of a command, not beside its result (#4214)
Milestone 419 step 3, the pre-act checkpoint. Every rule surface in this
plugin returns `additionalContext`, which Claude Code delivers alongside the
tool RESULT — so the rule is read after the call is written and lands as
commentary on a decision already made. That is the milestone's central
finding, measured over a session with seven misses, three caught by the
operator and none by this system.

The action arm can now return a `deny` instead. The act does not run, the
rule's text can be read before the call exists, and the remedy is one
`get_rule` call after which the act may be re-submitted unchanged. Nothing
reaches the operator: a deny is a message to the model.

"CONSEQUENTIAL" IS DERIVED, NOT ENUMERATED. The obvious implementation lists
act kinds — a write to product code, a schema change, a bulk classification, a
merge. Every one of those is consequential because THIS operator wrote rules
about it, and shipping that list is this instance's corpus hard-coded into the
product (rule 115). So the corpus decides: an act is consequential when the
install's own rules speak to it above the checkpoint bar. A fresh install with
no rules never stops anything.

FOUR CONDITIONS, EACH PREVENTING A DIFFERENT WRONG. Above the bar; a rule and
never a preference (which claims no such force); the band's top hit only (the
ranker's confidence claim attaches to its first element); and only a rule the
session has NOT opened — `held` is observable from the get_rule PostToolUse
hook (#4100), not self-report.

WHY "NOT OPENED" RATHER THAN "NO OUTCOME RECORDED". An outcome can be
satisfied with one cheap call asserting compliance without producing any, and
a checkpoint dismissible that way manufactures exactly the compliance data
step 2 was built to measure. Reading a rule cannot be faked in that direction:
after `get_rule` the statement is in context, which is the whole of what was
wanted.

THE BAR IS MEASURED. `retrieval_telemetry(days=30)`: write_path_rule p90
0.7628 max 0.8817; pre_tool_rule p90 0.7373 max 0.8293. 0.80 is above p90 on
both and below max on both, so it selects from the top decile of an already
selective arm and is still reachable. It ships as a setting with a Settings
card, because a cosine distance in one model's geometry over one corpus cannot
transfer.

TWO GUARDS ON THE WORST CASE: at most one hold per rule and five per session,
so a mis-set floor degrades to a noisy session rather than one that cannot
proceed. The ledger lives in the swept directory and is named `.ids`, so the
existing compaction-clear guards cover it.

WRITES ARE NOT HELD, AND THAT IS THE OPERATOR'S DECISION RATHER THAN MINE.
`scribe_prior_art.sh` carries a tested property that it never returns a
permissionDecision — a recall aid may not stand in the way of a write. Three
of the milestone's seven misses were file edits and none are reachable from
the command side, so there is a live argument for extending this; that
argument is exactly why the boundary is now asserted by a test rather than
left to memory. The write-path arm computes and returns the same block so the
decision can be revisited with evidence; the hook ignores it, and a change of
mind is a hook edit rather than a feature.

Verified by lifting `checkpoint_for` and `_rule_band` out of source with `ast`
and exercising the shipped functions over 17 populations, by running the
ledger and deny envelope in bash (10 cases, including that a refused hold is
not written and that a garbled rule id fails closed), and by
scripts/check_plugin.py — which caught the unminted plugin version, 0300 ->
0426.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
2026-09-21 00:26:54 -04:00
..

Scribe plugin for Claude Code

Turns a self-hosted Scribe instance into a first-class Claude Code extension:

  • MCP tools over your notes, tasks, projects, milestones, systems, and rulebook (the scribe server).
  • Session-start push channel — a SessionStart hook injects the active project's live state (from the server) and this adapter's short Claude Code guidance, so Scribe surfaces without being asked. Rules are never preloaded; they arrive by retrieval when your work matches one.
  • Prior-art recall on writes — a PreToolUse hook on Write/Edit checks the file about to be written against your recorded snippets (what's kept at that path, and what resembles the code) and offers them before the helper is rewritten. Titles only, never blocks the edit.
  • The shared Scribe skills — client-neutral Agent Skills, the same files any client's package would ship: using-scribe, writing-plans, reporting-back (reply to the operator in a shape that says where the work stands), systematic-debugging, verification, brainstorming, reusing-code (record and recall reusable code as snippets). Replaces superpowers.
  • Your Scribe Processes as skills — saved Processes are synced into local ~/.claude/skills/scribe-proc-* stubs that auto-surface by relevance; the stub fetches the live procedure via get_process. Refreshed each session and on demand with /scribe:sync.

It is designed so you can uninstall superpowers and depend on Scribe instead of auto-memory — leave auto-memory at its default; Scribe replaces its job by holding the one copy, not by switching it off.

How the pieces divide the work (decision #4027): the Scribe server orients every MCP client and serves live state; the skills in skills/ state every reflex in full and name no client; this plugin is the Claude Code adapter — hooks that deliver at the right moment, /scribe:sync, and the few things only Claude Code needs said (hooks/scribe_static_context.md). Packaging Scribe for another client: see PACKAGING.md.

Install

The plugin ships inside the Scribe app repo, so the marketplace is that repo — you always get the plugin version that matches your Scribe instance.

/plugin marketplace add https://git.fabledsword.com/bvandeusen/FabledScribe.git
/plugin install scribe@scribe-plugin

On install you'll be asked for:

Setting What
Scribe base URL e.g. https://scribe.example.com (no trailing slash)
Scribe API key an fmcp_ key from Settings → API Keys (stored in your OS keychain)
Active project id optional — numeric project id to scope the session-start context

What gets wired

  • plugin.json mcpServers → the scribe MCP server at <base URL>/mcp (Bearer auth).
  • hooks/hooks.json → SessionStart hook (hooks/scribe_session_context.sh), fail-open: if Scribe is unreachable it injects nothing and never blocks the session.
  • hooks/hooks.json → PreToolUse hook on Write|Edit (hooks/scribe_prior_art.sh) → GET /api/plugin/prior-art. Returns additionalContext with no permission decision, so it can inform the write but never stop it; silent when nothing is recorded, which is most of the time. Two framings: a REUSE menu (similar/nearby records), and a SYNC nudge when a snippet records the exact file being edited — "updating the record is part of the edit" — each with its own once-per-session dedup. A third, ledger-fed line names a duplicate family (no canon) or a canon recorded elsewhere for the names being written (its own dedup channel, exclude_derive). Fail-open but not fail-silent: a configured instance that does not answer in time is said, once per outage ("Scribe did not answer … this write went UNCHECKED"), so a session can tell "checked, nothing there" from "never checked"; an answer clears the marker. The local by-name arm needs no server and always runs. Toggle in Settings → Knowledge auto-inject.
  • hooks/hooks.json → PostToolUse hook on Bash (hooks/scribe_after_write.sh): code written through sed/heredocs/scripts never reaches the PreToolUse hook, so this one diffs the working tree after every Bash call (per-session path+blob snapshot; one git status when nothing changed) and runs the same arms on the definitions just written, through the same endpoint and the same dedup channels. additionalContext only; never blocks, and shares the pre-write hook's once-per-outage "did not answer" line (8 s budget here — it runs after the tool, so it gates nothing). The extractor, the prose/data skip list, the local by-name duplicate arm and the outage line are shared in hooks/scribe_defs.sh.
  • hooks/hooks.json → Stop hook (hooks/scribe_report_check.sh): when the turn closed a Scribe task (update_task/create_task with status done), checks the reply that ends it for the completion sections — where the work sits, what needs you, what comes next — and reports the outcome to GET /api/plugin/report-check. If sections are missing it blocks once with the reason the server returns, and records how the rewrite came out; it never blocks twice, and never blocks when the instance did not record the check (unconfigured or unreachable). Outcomes land in the admin logs under category plugin, action report_check.
  • skills/ → the universal process-skills, surfaced by description match.
  • hooks/scribe_sync_processes.sh (a 2nd SessionStart hook) + the /scribe:sync command → generate ~/.claude/skills/scribe-proc-* stubs from your Scribe Processes (via GET /api/plugin/processes); also fail-open, and pruned to match what exists in Scribe.

Notes

  • Do not hand-edit version in .claude-plugin/plugin.json. It is minted from the clock — run python3 scripts/mint_plugin_version.py (or make mint-plugin, where make is installed) after changing anything under plugin/, and commit the result. The installer decides whether to refresh the cache it executes from by comparing that string, so content that ships without a new version reaches the repo and stops there (#2209). CI fails the lane if you forget.
  • The session-start, auto-inject and prior-art hooks need only a read-scoped key; the MCP tools need write scope to create/update. Every hook is a GET for that reason — a read key cannot POST.