CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 47s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Failing after 59s
CI & Build / Build & push image (push) Skipped
Everything else Scribe gives an agent arrives before the reply is written. A
Stop hook is the one moment the finished reply exists, so it is the last
chance to fix a report the operator can't read, and the only place adherence
to the shape can be measured.
- plugin/hooks/scribe_report_check.sh (Stop): deterministic, no model call.
1. Did this turn close a task? That means an update_task/create_task call
with status "done" since the turn's prompt, whose tool_result is not an
error. Otherwise it stays silent, which covers most turns (one grep).
2. Does the reply that ends the turn say where the work sits (a record by
id and title, or step N of M), what needs the operator, and what comes
next? Matched on those words, not on exact headings.
3. If sections are missing, it blocks once. With stop_hook_active set, a
rewrite is recorded (passed_after_rewrite / missing_after_rewrite) and
never blocked again. A block loop started by another plugin (no marker
from this hook) is left alone.
- Measured: every checked reply is reported to GET /api/plugin/report-check
(passed / blocked / after rewrite). Turns that close nothing are not
reported; they would cost a request per turn and add nothing to the rate.
Outcomes go to app_logs as category "plugin", action "report_check".
- It blocks only when the block was recorded, and only in the server's words.
The endpoint returns the block reason, so the hook carries timing and
transport only (PACKAGING.md), and an unconfigured or unreachable instance
never stops a session.
- The transcript format is read from real transcripts and marked in the hook
as observed rather than documented. The Stop contract (transcript_path,
stop_hook_active, decision/reason, no matcher, SubagentStop separate) was
checked against the Claude Code hooks docs. A prompt-type hook was not
needed: the deterministic check passed a real completion report from this
session and blocked a stripped one.
- A pipefail trap was caught while exercising the hook: `tail | grep -q`
reports failure exactly when grep matches, because tail dies of SIGPIPE.
The prefilter reads through process substitution; the section checks use
here-strings.
- Tests: an end-to-end hook suite over synthetic transcripts and the shared
HTTP sink (silence, pass, server-worded block, rewrite recorded, foreign
loop, errored write, earlier turn, unwritten reply, no recorded check, bare
id), and service tests for the reason wording and the outcome record. Smoke
event added to check_plugin; README and PACKAGING list the hook and
endpoint. Plugin version minted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
336 lines
16 KiB
Python
336 lines
16 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) — 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.
|
|
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.
|
|
|
|
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"))
|
|
|
|
rules = await plugin_ctx_svc.build_prompt_rule_hint(
|
|
g.user.id, q, project_id=project_id, exclude_rule_ids=exclude_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.
|
|
"""
|
|
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.
|
|
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"))
|
|
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,
|
|
)
|
|
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"})
|