Files
FabledScribe/src/scribe/routes/plugin.py
T
bvandeusenandClaude Opus 5 2ee24b9d2b
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 32s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m9s
CI & Build / Build & push image (push) Successful in 25s
feat(rules): rules before tools — a PreToolUse arm keyed on the action (#3476)
The only just-in-time rule surface was registered on `Write|Edit` and queried
with `code or path`, so a rule could be retrieved at the moment of a code
write and nowhere else. Every rule about which tool to reach for — don't curl
the forge, don't stand up a stack, don't run the suite locally, don't branch —
was unreachable exactly when it mattered, and residency in the always-on
preload was the only surface it had. That is the pressure that grew the
resident set to 31 against #3089's ceiling of ~23; it was never a judgment
anybody made.

A reflex generates no query, so an instruction to check the rules cannot catch
one. A mechanical trigger can: the tool call IS the query, and a reflex has to
become a tool call before it can do anything.

`build_tool_rule_hint` is deliberately tool-agnostic — a name and a string —
so widening the matcher later is a hooks.json edit with no server change. The
hook starts on Bash, which is where the action reflexes live.

The two pre-tool arms share ONE session ledger of already-named rules
(`<state>/<sid>.rules.ids`). Two ledgers would mean a rule named by one arm
gets re-offered by the other, and the hint that fires most often is exactly
the one that must not repeat itself. A test asserts both scripts build the
same path, and another checks the shell hook and the Python route agree on
every query-arg name (rule 33) — a rename there fails silently, looking like
a surface that never finds anything rather than a broken one.

Deliberately silent on outage, unlike the prior-art hook: a write is
occasional, a Bash call is not, and an outage line before every command is
what gets a channel muted.

`tier="conditional"` matches the write arm and is the transition point — an
always-on rule is already resident, so re-tier one and it starts arriving here
instead of in every session's preamble. `pre_tool_rule` joins RANKED_SOURCES:
this arm chose what it showed, so a pull can settle whether the choice landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
2026-09-02 23:31:22 -04:00

279 lines
13 KiB
Python

"""Scribe-plugin support endpoints.
Consumed by the Scribe Claude Code plugin (not the web UI). `GET /api/plugin/
context` is curled by the plugin's SessionStart hook to push the operator's
always-on rules + active-project context into the session — Scribe's own push
channel. Auth is the standard `@login_required` path, which accepts a `Bearer
fmcp_<key>` API key (a read-scoped key suffices for this GET).
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from scribe.auth import admin_required, get_current_user_id, login_required
from scribe.config import Config
from scribe.services import plugin_context as plugin_ctx_svc
from scribe.services import repo_bindings as repo_bindings_svc
from scribe.services.settings import get_admin_setting, set_setting
plugin_bp = Blueprint("plugin", __name__, url_prefix="/api/plugin")
# Setting key for the git URL of the marketplace that serves this plugin (the
# Scribe app's own repo). Instance-global (stored on an admin account) so every
# user's Settings page shows the real, copyable install command.
_MARKETPLACE_KEY = "plugin_marketplace_url"
def _int_list(raw: str | None) -> list[int]:
"""A comma-separated id list from the query string; non-ints dropped."""
return [int(p) for p in (raw or "").split(",") if p.strip().isdigit()]
async def _project_scope() -> tuple[int, str, str]:
"""(project_id, repo, unbound_repo) from the request's `project_id` /
`repo` query args — the one resolution every plugin endpoint shares.
An explicit project_id wins; otherwise the repo remote is resolved
through the caller's bindings, and a remote nobody bound comes back as
`unbound_repo` (normalised) so /context can say "bind this repo".
"""
try:
project_id = int(request.args.get("project_id", 0) or 0)
except (TypeError, ValueError):
project_id = 0
repo = (request.args.get("repo") or "").strip()
unbound_repo = ""
if repo and not project_id:
resolved = await repo_bindings_svc.resolve_project(g.user.id, repo)
if resolved:
project_id = resolved
else:
unbound_repo = repo_bindings_svc.normalize_repo_key(repo)
return project_id, repo, unbound_repo
@plugin_bp.get("/context")
@login_required
async def session_context():
"""Return SessionStart context (always-on rules + active project).
Query:
repo (optional str) — the working repo's git remote. The server
resolves it to the bound project (see services/repo_bindings); an
unbound repo yields a "bind this repo" hint instead. This is how the
active project is determined — the plugin never pins a project id.
project_id (optional int) — explicit override, mainly for manual/ad-hoc
curl testing; takes precedence over `repo` when set.
"""
project_id, _repo, unbound_repo = await _project_scope()
result = await plugin_ctx_svc.build_session_context(
g.user.id, project_id, unbound_repo=unbound_repo
)
return jsonify(result)
@plugin_bp.get("/retrieve")
@login_required
async def autoinject_retrieve():
"""Title-first knowledge auto-inject for the plugin's UserPromptSubmit hook.
Given the user's prompt (`q`), returns a compact awareness hint — note
titles + scores only, never bodies — for the top hits that clear the
per-user gates (see services.plugin_context.build_autoinject_hint). Returns
empty context (most of the time) when disabled or nothing is relevant.
Query:
q (str) — the user's prompt to retrieve against.
repo (optional) — working repo remote; resolved to the bound project
to scope the search (mirrors /context). Unbound or
absent → searches all the user's notes.
project_id (opt) — explicit project scope override (ad-hoc/testing).
exclude_ids (opt) — comma-separated note ids already injected this
session; skipped so each note injects at most once.
"""
q = (request.args.get("q") or "").strip()
project_id, _repo, _unbound = await _project_scope()
exclude_ids = _int_list(request.args.get("exclude_ids"))
result = await plugin_ctx_svc.build_autoinject_hint(
g.user.id, q, project_id=project_id, exclude_ids=exclude_ids
)
return jsonify(result)
@plugin_bp.get("/tool-rules")
@login_required
async def pre_tool_rules():
"""Standing rules for the plugin's PreToolUse hook on ACTIONS (#3476).
Answers "does a recorded rule speak to the command about to be run?" — the
sibling of /prior-art, which can only answer that question about a code
write. Rules about which tool to reach for (don't curl the forge, don't
stand up a stack, don't run the suite locally) had no retrieval surface at
all before this, which is why they all had to live in the resident preload.
Titles + trigger only, never the statement: the hint says a rule may apply
and hands over `get_rule(id)`. One rule at most (RULEHINT_LIMIT), and empty
most of the time.
Query:
tool (str) — the tool about to run, e.g. `Bash`. Used in
the hint's wording, not in the search: a
rule is about the action, not the harness.
command (str) — the command about to run; the semantic query.
Absent or blank → empty, no search.
repo (optional) — working repo remote, resolved to the bound
project exactly as /retrieve and /prior-art.
exclude_rule_ids (opt) — comma-separated rule ids already surfaced
this session. SHARED with /prior-art's
ledger on purpose: one session keeps one
list, so a rule named by either arm is not
re-offered by the other.
"""
tool = (request.args.get("tool") or "tool").strip()
command = request.args.get("command") or ""
project_id, _repo, _unbound = await _project_scope()
exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
result = await plugin_ctx_svc.build_tool_rule_hint(
g.user.id, tool, command,
project_id=project_id, exclude_rule_ids=exclude_rule_ids,
)
return jsonify(result)
@plugin_bp.get("/prior-art")
@login_required
async def write_path_prior_art():
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
Answers "what is already recorded for the file about to be written?" — by
place (a snippet at this path or in its directory) and by meaning (snippets
resembling the code about to be written). Titles only, gated, and empty most
of the time. See services.plugin_context.build_write_path_hint.
Query:
path (str) — the target file, REPO-RELATIVE, matching the
convention snippet locations are recorded in. An
absolute path simply won't match anything.
code (optional) — the code about to be written; the semantic query.
Omit it and only the location arm runs.
repo (optional) — working repo remote; resolved to the bound project
to scope the search, exactly as /retrieve does. It
is NOT used as the location `repo` filter — see the
service docstring for why those two differ.
project_id (opt) — explicit project scope override (ad-hoc/testing).
exclude_ids (opt) — comma-separated REUSE-class ids already surfaced
this session.
exclude_sync_ids (opt) — comma-separated SYNC-class ids (snippets
recorded AT the edited file, #2708) already
surfaced. A separate channel on purpose: a reuse
hint shown early must not suppress the record-sync
nudge when the recorded file is edited later.
exclude_rule_ids (opt) — comma-separated RULE ids already surfaced
this session. Its own channel like the three
above, and for the same reason: a rule named
twenty turns ago should not be re-offered on
every subsequent write.
exclude_derive (opt) — comma-separated derive keys (a derive group id
or `canon:<snippet_id>`) already named this
session by the ledger arm (#2900); its own
channel, like the two above.
rules_etag (opt) — the marker the session was given when it loaded
its always-on rules (milestone 323). Sent back
so the server can say whether those rules have
MOVED since. Absent means the hook has nothing
stored, which is silence, not a mismatch.
shapes (opt) — comma-separated `kind:name` definitions the hook
found in (or enclosing) the payload, kind being
css|sym. The shape ledger's write-path feed
(#2791): when the session recently PULLED a
snippet this payload references or resembles,
these land as instance rows (classified_by=hook).
Honoured only for a caller allowed to write — a
read-scoped key still gets the hint, and never
changes accounting on a GET.
"""
path = (request.args.get("path") or "").strip()
code = request.args.get("code") or ""
project_id, repo, _unbound = await _project_scope()
exclude_ids = _int_list(request.args.get("exclude_ids"))
exclude_sync_ids = _int_list(request.args.get("exclude_sync_ids"))
exclude_derive = [
p.strip() for p in (request.args.get("exclude_derive") or "").split(",") if p.strip()
]
exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
rules_etag = (request.args.get("rules_etag") or "").strip()
shapes = _parse_shapes(request.args.get("shapes") or "")
api_key = getattr(g, "api_key", None)
may_stamp = api_key is None or getattr(api_key, "scope", "") == "write"
result = await plugin_ctx_svc.build_write_path_hint(
g.user.id, path, code=code, project_id=project_id,
exclude_ids=exclude_ids, exclude_sync_ids=exclude_sync_ids,
stamp_shapes=shapes if may_stamp else None,
repo_key=repo_bindings_svc.normalize_repo_key(repo) if repo else "",
exclude_derive=exclude_derive,
exclude_rule_ids=exclude_rule_ids,
rules_etag=rules_etag,
)
return jsonify(result)
# The hook names at most a dozen definitions per write; anything past that is
# a generated file, not a shape being instantiated.
_SHAPES_CAP = 12
def _parse_shapes(raw: str) -> list[tuple[str, str]]:
"""`css:btn-primary,sym:onTrash` → [("css", "btn-primary"), ("sym", "onTrash")].
Unknown kinds and empty names are dropped, duplicates collapse, and the
list is capped — the hook's own cap, re-applied so the contract holds
for any caller."""
out: list[tuple[str, str]] = []
for part in raw.split(","):
kind, _sep, name = part.strip().partition(":")
kind, name = kind.strip(), name.strip()
if kind in ("css", "sym") and name and (kind, name) not in out:
out.append((kind, name))
if len(out) >= _SHAPES_CAP:
break
return out
@plugin_bp.get("/processes")
@login_required
async def process_manifest():
"""Stored Processes as skill-stub specs for the plugin's sync script.
The plugin's `scribe_sync_processes.sh` (run at SessionStart and via the
`/scribe:sync` command) curls this and writes one auto-surfacing local skill
per Process into ~/.claude/skills/. See services/plugin_context.
build_process_manifest. A read-scoped API key suffices.
"""
result = await plugin_ctx_svc.build_process_manifest(g.user.id)
return jsonify(result)
@plugin_bp.get("/marketplace-url")
@login_required
async def get_marketplace_url():
"""The plugin marketplace git URL, readable by any logged-in user so their
Settings page can show a copyable install command. Falls back to the
config default (this deployment's own repo) when no admin override is set."""
url = await get_admin_setting(_MARKETPLACE_KEY, default=Config.PLUGIN_MARKETPLACE_URL)
return jsonify({"marketplace_url": url})
@plugin_bp.put("/marketplace-url")
@admin_required
async def set_marketplace_url():
"""Admin-set the marketplace git URL (e.g. this app's own repo)."""
data = await request.get_json() or {}
url = (data.get("marketplace_url") or "").strip()
if url:
scheme = url.split("://")[0].lower() if "://" in url else ""
if scheme not in ("http", "https"):
return jsonify({"error": "Marketplace URL must use http or https"}), 400
await set_setting(get_current_user_id(), _MARKETPLACE_KEY, url)
return jsonify({"status": "ok"})