"""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_` 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": [, ...]}` — 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": "", "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:`) 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 `pathkindname` 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"})