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
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
383 lines
19 KiB
Python
383 lines
19 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 import report_check as report_check_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) — the project named directly, and the only
|
|
key a caller outside a git repo has (#4085): the plugin's hooks
|
|
send it when a `.scribe` marker file names a project. Takes
|
|
precedence over `repo`. Access-checked like any other read — an id
|
|
this account cannot read loads no project rather than failing.
|
|
"""
|
|
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.
|
|
exclude_rule_ids — comma-separated rule ids already surfaced this
|
|
(opt) session. SHARED with /prior-art and /tool-rules on
|
|
purpose: one session keeps ONE rule ledger, so a
|
|
rule named by any arm is not re-announced by
|
|
another. Ages out (#3751), so salience decays.
|
|
held_rule_ids — comma-separated rule ids the session actually
|
|
(opt) OPENED, observed from the `get_rule` call itself
|
|
rather than claimed. A rule on exclude_rule_ids
|
|
was NAMED; a rule here was READ, and the two say
|
|
different things about what the reader holds —
|
|
so they get different lines (#4100). Shares the
|
|
ledger directory and the same clear-on-compact.
|
|
|
|
TWO ARMS, TWO SETS OF GATES. Rules ride the same hook and the same query
|
|
but nothing else: the notes menu can be disabled, thresholded and top-k'd
|
|
by the operator without touching whether a rule reaches them. Composed
|
|
here rather than inside either builder so neither one's early return can
|
|
silently suppress the other.
|
|
|
|
Rules come FIRST in the payload. A rule or preference governing the answer
|
|
is more consequential than a menu of things that might be worth reading,
|
|
and a reader who stops after the first block should have stopped after
|
|
the right one.
|
|
"""
|
|
q = (request.args.get("q") or "").strip()
|
|
project_id, _repo, _unbound = await _project_scope()
|
|
exclude_ids = _int_list(request.args.get("exclude_ids"))
|
|
exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
|
|
held_rule_ids = _int_list(request.args.get("held_rule_ids"))
|
|
|
|
rules = await plugin_ctx_svc.build_prompt_rule_hint(
|
|
g.user.id, q, project_id=project_id, exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids
|
|
)
|
|
result = await plugin_ctx_svc.build_autoinject_hint(
|
|
g.user.id, q, project_id=project_id, exclude_ids=exclude_ids
|
|
)
|
|
blocks = [b for b in (rules["context"], result["context"]) if b]
|
|
result["context"] = "\n\n".join(blocks)
|
|
result["rule_ids"] = rules["rule_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.
|
|
held_rule_ids (opt) — rule ids the session actually OPENED, as
|
|
against merely named. Named and read are
|
|
different claims about the reader's
|
|
context, so they get different lines
|
|
(#4100).
|
|
|
|
Returns `context`, `rule_ids`, and `checkpoint` (#4214, milestone 419).
|
|
|
|
`checkpoint` IS THE ONE PART OF THIS RESPONSE THAT IS NOT A HINT. It is
|
|
empty on almost every call. When present it carries `rule_id`, `title`,
|
|
`trigger`, `score` and a rendered `reason`, and it means the hook should
|
|
DENY the call rather than annotate it — because every other line this
|
|
endpoint returns is delivered by Claude Code alongside the tool RESULT,
|
|
so it reaches the reader after the act is composed and reads as
|
|
commentary on a decision already made.
|
|
|
|
It is raised only for a rule (never a preference, which claims no such
|
|
force), only for the ranker's top hit, only above the checkpoint
|
|
threshold — well above this arm's own floor — and only when the session
|
|
has NOT opened that rule. The remedy is one `get_rule` call, and the act
|
|
may then be re-submitted unchanged; the hook caps stops at one per rule
|
|
and five per session so a mis-set floor degrades to noise rather than to
|
|
a session that cannot proceed.
|
|
"""
|
|
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"))
|
|
held_rule_ids = _int_list(request.args.get("held_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, held_rule_ids=held_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.
|
|
held_rule_ids (opt) — RULE ids the session actually OPENED, as
|
|
against merely named; drives the third
|
|
reference wording (#4100).
|
|
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.
|
|
(Returns a `checkpoint` block on the same contract as /tool-rules —
|
|
see that endpoint. The write-path HOOK deliberately does not act on
|
|
it: scribe_prior_art.sh carries a tested property that it never
|
|
returns a permissionDecision, on the operator's decision that a recall
|
|
aid may not stand in the way of a write. The block is computed and
|
|
returned so that decision can be revisited with evidence rather than
|
|
re-argued, and so a change of mind is a hook edit and not a feature.)
|
|
|
|
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"))
|
|
held_rule_ids = _int_list(request.args.get("held_rule_ids"))
|
|
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, held_rule_ids=held_rule_ids,
|
|
)
|
|
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("/report-check")
|
|
@login_required
|
|
async def report_check():
|
|
"""Record what a Stop hook found in a reply that closed a task (milestone 409 step 5).
|
|
|
|
The hook decides which completion sections the reply lacks — a local check
|
|
of text it can read — and reports the outcome here. For `blocked` the
|
|
response carries the `reason` to send the agent back with: the words are
|
|
the server's, so every client's hook says the same thing (plugin/PACKAGING.md).
|
|
A hook blocks only on a `reason` it received, which means only on a block
|
|
that was recorded.
|
|
|
|
A GET for the reason every plugin endpoint is one: a read-scoped key must
|
|
be enough to run the plugin, and this records telemetry the way /retrieve
|
|
records a retrieval log.
|
|
|
|
Query:
|
|
outcome (str) — passed | blocked | passed_after_rewrite |
|
|
missing_after_rewrite. Anything else is a 400.
|
|
missing (opt) — comma-separated sections the reply lacked:
|
|
"where it sits", "needs you", "next".
|
|
task_ids (opt) — comma-separated ids of the tasks the turn closed.
|
|
repo (opt) — working repo remote, resolved like the other arms.
|
|
"""
|
|
outcome = (request.args.get("outcome") or "").strip()
|
|
if outcome not in report_check_svc.OUTCOMES:
|
|
return jsonify({"error": f"outcome must be one of {list(report_check_svc.OUTCOMES)}"}), 400
|
|
missing = [m for m in (request.args.get("missing") or "").split(",") if m.strip()]
|
|
task_ids = _int_list(request.args.get("task_ids"))[:20]
|
|
project_id, _repo, _unbound = await _project_scope()
|
|
await report_check_svc.record_report_check(
|
|
g.user.id, outcome, missing=missing, task_ids=task_ids, project_id=project_id or None,
|
|
)
|
|
body: dict = {"status": "ok"}
|
|
if outcome == "blocked":
|
|
body["reason"] = report_check_svc.block_reason(missing)
|
|
return jsonify(body)
|
|
|
|
|
|
@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"})
|