rename(547): the server is grimoire — package, image, DB defaults, backups
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s

Step 2 of the Scribe -> Grimoire rename.

- src/scribe -> src/grimoire, and every import, patch target, module path,
  hypercorn entry point and `python -m grimoire.restore` with it; pyproject,
  uv.lock and the frontend package are named grimoire.
- Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service
  runs as grimoire / grimoire_test.
- Compose files default the DB user, database and (prod) volume to grimoire,
  each overridable from .env, so an install created as Scribe keeps its data
  by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME.
- Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar.
  Archives named scribe-* with the scribe-backup-tar manifest still restore
  and are still counted at the destination (folder and S3), with tests.
- Exports, the MCP auth realm, the ASGI scope key and the persistent logger
  carry the new name.

Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1",
because every stored secret was derived under it.

Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is
step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 10:50:45 -04:00
co-authored by Claude Opus 5.5
parent e99f1169f7
commit 0f5ef0be63
447 changed files with 3611 additions and 3558 deletions
+683
View File
@@ -0,0 +1,683 @@
"""Grimoire-plugin support endpoints.
Consumed by the Grimoire 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 — Grimoire'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 grimoire.auth import admin_required, get_current_user_id, login_required
from grimoire.config import Config
from grimoire.services import lesson_rules as lesson_rules_svc
from grimoire.services import moment_delivery as moment_delivery_svc
from grimoire.services import plugin_context as plugin_ctx_svc
from grimoire.services import reply_shapes as reply_shapes_svc
from grimoire.services import repo_bindings as repo_bindings_svc
from grimoire.services import report_check as report_check_svc
from grimoire.services import rule_moment_judgments as judgments_svc
from grimoire.services import shape_check as shape_check_svc
from grimoire.services import task_claims as task_claims_svc
from grimoire.services.embeddings import memoized_query_embeddings
from grimoire.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
# Grimoire 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: 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.
source (optional str) — the host's SessionStart source (startup,
resume, compact, clear, fork); decides what the claim section says.
session_id (optional str) — the session's id, so a claim bound to it
reads as this session's own.
"""
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,
source=(request.args.get("source") or "").strip()[:20],
session_id=(request.args.get("session_id") or "").strip()[:200],
)
return jsonify(result)
@plugin_bp.get("/retrieve")
@login_required
@memoized_query_embeddings
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.
ctx (opt) — the tail of the last assistant reply. The
notes AND rule arms append it to a short prompt,
so a follow-up like "yes do that" still names
what it is about — and a rule or lesson arrives
while the work is under way, before the operator
has to call it out.
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, 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. Shares the
ledger directory and the same clear-on-compact.
shapes_seen (opt) — comma-separated reply-shape keys this session was
already shown in full. The core
goes out in full when `core` is absent, as its
one-line reminder when present; the hook's
ledger is swept on compaction, which is how the
full core comes back after one.
THE TURN'S REPLY SHAPE COMES FIRST. Every turn ends
in a reply and this is the last point before it is written, so the core
shape and whatever is mounted on `reply.report` lead the payload. Fresh
shape keys come back as `shape_keys` for the ledger.
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"))
ctx = request.args.get("ctx") or ""
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,
context=ctx,
)
result = await plugin_ctx_svc.build_autoinject_hint(
g.user.id, q, project_id=project_id, exclude_ids=exclude_ids, context=ctx,
)
# The one place both arms' lines meet, so the one place a lesson and a
# rule arriving TOGETHER can be counted. Keyed on the operator's
# prompt — the situation — and fails open to "".
proposal = await lesson_rules_svc.co_surfaced(
g.user.id, result.get("lesson_ids") or [], rules.get("shown_rule_ids") or [],
arm="p", situation=q, project_id=project_id or None,
)
# The reply mounts go through the same rule ledger; a rule the prompt arm
# just named is not quoted a second time by the turn's delivery.
turn = await moment_delivery_svc.deliver_for_turn(
g.user.id, seen=reply_shapes_svc.parse_seen(request.args.get("shapes_seen")),
project_id=project_id,
exclude=frozenset(exclude_rule_ids) | frozenset(rules["rule_ids"]),
held=frozenset(held_rule_ids),
)
blocks = [b for b in (turn["context"], rules["context"], result["context"], proposal) if b]
result["context"] = "\n\n".join(blocks)
result["rule_ids"] = list(dict.fromkeys([*rules["rule_ids"], *turn["rule_ids"]]))
result["shape_keys"] = [k for k, form in turn["shape_forms"].items()
if form == reply_shapes_svc.FULL]
return jsonify(result)
@plugin_bp.get("/tool-rules")
@login_required
@memoized_query_embeddings
async def pre_tool_rules():
"""Standing rules for the plugin's PreToolUse hook on ACTIONS.
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) have no other retrieval
surface: no code write ever happens for them to ride on.
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.
root, cwd (opt) — the repo's absolute root and the command's
working directory. They turn the paths the
command names into repo-relative ones, for
the rulings arm.
seen_ruling_systems (opt) — comma-separated System ids whose rulings
were already shown this session, by either
arm; those get a one-line reference. SHARED
with /prior-art, like exclude_rule_ids.
Returns `context`, `rule_ids`, and `checkpoint`,
plus `ruling_system_ids` — the Systems whose rulings were shown in full on
this call — when there were any.
`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,
root=(request.args.get("root") or "").strip(),
cwd=(request.args.get("cwd") or "").strip(),
seen_ruling_systems=_int_list(request.args.get("seen_ruling_systems")),
)
return jsonify(result)
@plugin_bp.get("/moment-tools")
@login_required
async def moment_tools():
"""The tools whose calls can deliver a mounted rule.
Read by the plugin's catch-all PreToolUse hook once per session window and
kept on disk, so a call that cannot reach a mounted rule costs no request.
`tools` are tool KEYS — bare, lowercased, no MCP server prefix — the same
keys the moment mappings are written in. Empty on an install that has
mounted nothing, which keeps that hook entirely off the wire.
"""
return jsonify({"tools": await moment_delivery_svc.reachable_tools(g.user.id)})
@plugin_bp.post("/moment")
@login_required
async def moment():
"""The rules mounted on the moments this tool call reaches.
Body: the PreToolUse event as Claude Code delivers it — `tool_name` and
`tool_input` are read, nothing else. Sent whole because which input field
a mapping matches on is the mapping's business, not the hook's.
Query: `repo` / `project_id` for the scope, and the shared session ledger —
`exclude_rule_ids` (named this session: a repeat is cited, not quoted) and
`held_rule_ids` (opened) — exactly as /tool-rules takes them.
Returns `context` (the lines, each naming the moment and the action that
reached it, so a misfire is visible and can be unmapped in-session),
`rule_ids` (the FRESH ones, for the hook to append to the ledger) and
`moments` (the names reached). A lookup, not a ranked search: no
retrieval_logs row; each fresh rule is recorded surfaced with its moment.
A moment that carries a default reply shape brings it too,
ahead of the rules: in full unless `shapes_seen` names it, then as its
reminder. `shape_keys` are the ones sent in full, for the hook's ledger.
"""
event = await request.get_json(silent=True) or {}
tool = str(event.get("tool_name") or "").strip()
tool_input = event.get("tool_input")
if not tool:
return jsonify({"context": "", "rule_ids": [], "moments": [], "shape_keys": []})
project_id, _repo, _unbound = await _project_scope()
reached, result = await moment_delivery_svc.deliver_for_act(
g.user.id, tool, tool_input if isinstance(tool_input, dict) else {},
project_id=project_id,
exclude=frozenset(_int_list(request.args.get("exclude_rule_ids"))),
held=frozenset(_int_list(request.args.get("held_rule_ids"))),
)
shapes, forms = moment_delivery_svc.shapes_for_act(
reached, reply_shapes_svc.parse_seen(request.args.get("shapes_seen")),
)
await reply_shapes_svc.record_delivery(g.user.id, forms, via="hook")
context = "\n\n".join(b for b in ("\n\n".join(shapes), "\n".join(result.lines)) if b)
return jsonify({
"context": context,
"rule_ids": result.rule_ids,
"moments": [hit["moment"] for hit in reached],
"shape_keys": [k for k, form in forms.items() if form == reply_shapes_svc.FULL],
})
@plugin_bp.post("/rule-opened")
@login_required
async def rule_opened():
"""A session opened a rule shortly after these acts.
Body: `{"rule_id", "session_id", "acts": [<PreToolUse event>, ...]}` —
the calls the plugin saw in the window before the `get_rule`. Each is
resolved to its moments, and the open is counted as evidence that the
rule belongs on them; once a pair has repeated across enough distinct
sessions, `context` carries the line asking the reader to offer the
mount. Nothing is mounted here. `repo` / `project_id` scope the evidence.
"""
data = await request.get_json(silent=True) or {}
if not isinstance(data, dict):
return jsonify({"moments": [], "context": ""})
try:
rule_id = int(data.get("rule_id") or 0)
except (TypeError, ValueError):
rule_id = 0
acts = data.get("acts")
if rule_id <= 0 or not isinstance(acts, list):
return jsonify({"moments": [], "context": ""})
project_id, _repo, _unbound = await _project_scope()
return jsonify(await judgments_svc.opened_after(
g.user.id, rule_id, acts, session_id=str(data.get("session_id") or ""),
project_id=project_id or None,
))
@plugin_bp.post("/reply-rules")
@login_required
@memoized_query_embeddings
async def reply_rules():
"""Whether the reply that ends a turn is held for one read.
Called by the plugin's Stop hook with the finished reply. Two halves: the
rules MOUNTED on the reply moments (`reply.report`, and `reply.ask` when
the reply asks something), and the reply text against every rule's
trigger — the backstop for whatever the earlier arms missed.
ONE END-OF-TURN REQUEST: a reply that closed tasks is also checked here
for the completion sections (services/report_check). Both holds fold
into one `reason`; `report_check` says what the section check recorded, so
the hook knows the rewrite is one to report.
Body: `{"reply": "<text>", "closed": 1, "closed_task_ids": [41],
"rewrite": false}` — the last three only when the turn closed a task.
`closed` is the count and decides whether the check runs (a task created
already done has no id to list); `rewrite` marks the reply written after a
section hold, which is recorded and never held.
Query: `repo` / `project_id` for the scope;
`held_rule_ids` (opened this session — exempt, as at the act checkpoint);
`exclude_rule_ids` (named this session — not re-counted as surfaced);
`stopped_rule_ids` (already held a reply or an act this session — a rule
holds once, and the per-session cap counts these).
Returns `reason` — the words the hook blocks with, empty when nothing
holds — plus `rule_ids` for the hook's ledger, `moments` reached and
`report_check` (the recorded outcome, or "" when nothing was checked).
"""
data = await request.get_json(silent=True) or {}
if not isinstance(data, dict):
data = {}
reply = str(data.get("reply") or "")
# `type(...) is int`, not isinstance: JSON `true` would otherwise read as 1.
closed = data.get("closed") if type(data.get("closed")) is int else 0
ids = data.get("closed_task_ids")
task_ids = [i for i in ids if type(i) is int and i > 0][:20] if isinstance(ids, list) else []
rewrite = data.get("rewrite") is True
project_id, _repo, _unbound = await _project_scope()
checked: dict = {}
if closed > 0:
checked = await report_check_svc.check_reply(
g.user.id, reply, task_ids=task_ids, rewrite=rewrite, project_id=project_id or None,
)
# The rewrite after a hold goes out as written: recorded above, held by nothing.
held: dict = {}
if not rewrite:
held = await moment_delivery_svc.reply_hold(
g.user.id, reply, project_id=project_id,
exclude=frozenset(_int_list(request.args.get("exclude_rule_ids"))),
held=frozenset(_int_list(request.args.get("held_rule_ids"))),
stopped=frozenset(_int_list(request.args.get("stopped_rule_ids"))),
)
reasons = [r for r in (checked.get("reason", ""), held.get("reason", "")) if r]
return jsonify({
"reason": "\n\n".join(reasons),
"rule_ids": held.get("rule_ids", []),
"moments": held.get("moments", []),
"report_check": checked.get("outcome", ""),
})
@plugin_bp.get("/prior-art")
@login_required
@memoized_query_embeddings
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) 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.
exclude_derive (opt) — comma-separated derive keys (a derive group id
or `canon:<snippet_id>`) already named this
session by the ledger arm; its own
channel, like the two above.
seen_ruling_systems (opt) — System ids whose rulings were already shown
this session; shared with
/tool-rules. The response's `ruling_system_ids`
are the ones shown in full on this call.
(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, because a recall aid must 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:
when the session recently PULLED a
snippet this payload references or resembles,
these carry it as a proposal — evidence for the
agent's own end-of-turn judgment, never a
verdict. Honoured only for a
caller allowed to write — a read-scoped key still
gets the hint, and never changes the ledger 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,
seen_ruling_systems=_int_list(request.args.get("seen_ruling_systems")),
)
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("/shape-check")
@login_required
async def shape_check():
"""The end-of-turn question: what did this turn write that
nobody has judged?
The client's write hooks keep a ledger of the definitions each turn wrote;
its Stop hook sends them here. The server decides which carry no judgment
— against the ledger and the project's recorded snippets — records the
outcome, and for a block returns the `reason` the agent is sent back with.
A hook blocks only on a reason it received, so only on a block that was
recorded, and never on the stop that follows one (`phase=after`).
A GET like every plugin endpoint: a read-scoped key runs the plugin, and
this records telemetry the way /reply-rules records its section check. It changes no ledger
row — the agent's own classify_shapes call does that.
Query:
written (str) — newline-separated `path<TAB>kind<TAB>name` lines,
repo-relative, kind css|sym|file.
phase (str) — check | after. Anything else is a 400.
repo (opt) — working repo remote, resolved like the other arms.
project_id (opt) — explicit scope override.
"""
phase = (request.args.get("phase") or "check").strip()
if phase not in shape_check_svc.PHASES:
return jsonify({"error": f"phase must be one of {list(shape_check_svc.PHASES)}"}), 400
written = shape_check_svc.parse_written(request.args.get("written") or "")
project_id, repo, _unbound = await _project_scope()
body: dict = {"status": "ok", "unjudged": []}
if not project_id or not written:
return jsonify(body)
from grimoire.services import access
from grimoire.services import coverage as coverage_svc
from grimoire.services import shape_ledger as shape_ledger_svc
if not await access.can_read_project(g.user.id, project_id):
return jsonify(body)
repo_key = repo_bindings_svc.normalize_repo_key(repo) if repo else ""
unjudged = await shape_ledger_svc.unjudged_shapes(project_id, written, repo_key=repo_key)
# A snippet recorded AT a shape is the strongest verdict there is — the
# next refresh stamps that row canonical. Until then the ledger cannot
# know, so the recorded locations answer for it: an agent who just
# recorded what it built is not asked again.
if unjudged:
recorded = {
(path, symbol)
for _sid, path, symbol in await coverage_svc._recorded_locations(g.user.id, project_id)
}
unjudged = [u for u in unjudged if (u["path"], u["symbol"]) not in recorded
and not (u["kind"] == "file" and (u["path"], "") in recorded)]
outcome = shape_check_svc.outcome_for(phase, unjudged)
await shape_check_svc.record_shape_check(
g.user.id, outcome, written=len(written), unjudged=len(unjudged),
project_id=project_id,
)
body["outcome"] = outcome
body["unjudged"] = unjudged
if outcome == "blocked":
body["reason"] = shape_check_svc.block_reason(
unjudged, project_id=project_id, repo=repo_key or repo,
)
return jsonify(body)
@plugin_bp.get("/claim-session")
@login_required
async def claim_session():
"""Bind the harness's session id to the caller's live claim on a task.
Called by `scribe_claim_session.sh` after `update_task` / `add_task_log`.
The server has already stamped the claim on that write; this only says
WHICH session made it — an id the harness reported, not one the model
asserted. A GET for the reason every plugin endpoint is one: a read-scoped
key must be enough to run the plugin.
Query:
task_id (int) — the task the tool call named.
session_id (str) — the Claude Code session id from the hook event.
Returns the claim when one was bound, `{"claim": null}` when there was
nothing to bind to (no live claim of the caller's, or not writable).
"""
try:
task_id = int(request.args.get("task_id") or "")
except ValueError:
return jsonify({"error": "task_id must be an integer"}), 400
session_id = (request.args.get("session_id") or "").strip()
if not session_id:
return jsonify({"error": "session_id is required"}), 400
claim = await task_claims_svc.bind_session(g.user.id, task_id, session_id)
return jsonify({"claim": claim})
@plugin_bp.get("/release-session")
@login_required
async def release_session():
"""Release the claims a session held, as it ends.
Called by `scribe_session_end.sh`. Best-effort by design: SessionEnd does
not fire on a crash, so the claim's lease — not this call — is what makes a
dead session's claim read as dead. This only makes the common case tidy.
Query:
session_id (str) — the ending session's id, from the hook event.
"""
session_id = (request.args.get("session_id") or "").strip()
if not session_id:
return jsonify({"error": "session_id is required"}), 400
released = await task_claims_svc.release_session(g.user.id, session_id)
return jsonify({"released": released})
@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"})