CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Failing after 1m27s
CI & Build / integration (push) Successful in 2m1s
CI & Build / Build & push image (push) Skipped
plugin/hooks/grimoire_project_fence.sh runs as PreToolUse on editor writes, Bash and Grimoire's tools. A session belongs to the project its starting directory resolves to (CLAUDE_PROJECT_DIR: a .grimoire marker, else the git remote's binding). A session that starts outside any project belongs to the first one it enters, which PostToolUse on enter_project records. These acts stop on permissionDecision "ask", with the server's reason naming the other project: - an edit in another project's repo or a worktree of it; - a git commit, push, merge, rebase, pull, cherry-pick, revert, tag, am, reset or worktree add there, found through git -C, a cd before it, or the shell's current directory; - a Grimoire write that lands in another project. A live probe showed "ask" holds in bypassPermissions. Filing an issue and every read go ahead. An edit or git command in the session's own repo, and any tool named like a read, never calls the server. Grimoire writes send only their short arguments, so the URL stays small. When the instance doesn't answer, the act goes ahead and the hook says once per outage that it couldn't check. check_plugin.py now holds one outage line per hook and has a smoke event for the fence. GET /api/plugin/write-scope takes fallback_project_id for the entered project. tests/test_project_fence.py drives the hook against real git repos and a stand-in server. tests/test_write_scope.py holds the hook's FENCE_READ_PREFIXES equal to services/write_scope.READ_PREFIXES. The plugin README and PACKAGING describe the fence, and the plugin is minted to 2026.11.05.0245. Milestone 583, step 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
746 lines
36 KiB
Python
746 lines
36 KiB
Python
"""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
|
|
|
|
import json
|
|
|
|
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 import write_scope as write_scope_svc
|
|
from grimoire.services.access import can_read_project
|
|
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 `.grimoire` 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: grimoire_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 `grimoire_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("/write-scope")
|
|
@login_required
|
|
async def write_scope():
|
|
"""May this session do this act? Asked by `grimoire_project_fence.sh`
|
|
before a file edit, a git command or a Grimoire write.
|
|
|
|
A session belongs to the project its starting directory resolves to
|
|
(`repo` / `project_id`, as every plugin endpoint reads them), or when
|
|
that resolves to none, to `fallback_project_id`: the first project the
|
|
session entered. The act is
|
|
either a directory (`target_repo` / `target_project_id`) or an MCP call
|
|
(`tool`, with `input` as a JSON object of the call's short arguments: the
|
|
ids, kind and status that say where it writes). `act` words the act for the
|
|
reason, e.g. "git push in".
|
|
|
|
Returns `{allowed, session_project, targets, issue, reason}` from
|
|
services/write_scope.check_act. A GET, so a read-scoped key still fences
|
|
file edits and git commands.
|
|
"""
|
|
session_project, _repo, _unbound = await _project_scope()
|
|
if not session_project:
|
|
# A session that started outside any project belongs to the first one
|
|
# it entered; the hook remembers it.
|
|
try:
|
|
session_project = int(request.args.get("fallback_project_id") or 0)
|
|
except ValueError:
|
|
session_project = 0
|
|
if session_project and not await can_read_project(g.user.id, session_project):
|
|
session_project = 0
|
|
tool = (request.args.get("tool") or "").strip()
|
|
if tool.startswith("mcp__"):
|
|
tool = tool.rsplit("__", 1)[-1]
|
|
args: dict = {}
|
|
if tool:
|
|
try:
|
|
parsed = json.loads(request.args.get("input") or "{}")
|
|
except ValueError:
|
|
return jsonify({"error": "input must be a JSON object"}), 400
|
|
if not isinstance(parsed, dict):
|
|
return jsonify({"error": "input must be a JSON object"}), 400
|
|
args = parsed
|
|
target_project = 0
|
|
if not tool:
|
|
try:
|
|
target_project = int(request.args.get("target_project_id") or 0)
|
|
except ValueError:
|
|
target_project = 0
|
|
target_repo = (request.args.get("target_repo") or "").strip()
|
|
if not target_project and target_repo:
|
|
target_project = await repo_bindings_svc.resolve_project(g.user.id, target_repo) or 0
|
|
result = await write_scope_svc.check_act(
|
|
g.user.id, session_project, tool=tool, args=args,
|
|
target_project=target_project, act=(request.args.get("act") or "")[:80],
|
|
)
|
|
result["session_project"] = session_project or None
|
|
return jsonify(result)
|
|
|
|
|
|
@plugin_bp.get("/release-session")
|
|
@login_required
|
|
async def release_session():
|
|
"""Release the claims a session held, as it ends.
|
|
|
|
Called by `grimoire_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 `grimoire_sync_processes.sh` (run at SessionStart and via the
|
|
`/grimoire: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"})
|