CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 54s
CI & Build / Python tests (push) Successful in 1m45s
CI & Build / Build & push image (push) Successful in 30s
When one hook response puts a lesson and a rule in front of the reader, the pair is recorded as evidence on a SUGGESTED lesson_rule_links row; once the pair has arrived together in PROPOSE_SITUATIONS (3) distinct situations, the next co-arrival carries one line asking the reader to judge it with judge_lesson_link. Nothing about surfacing changes: a suggested link carries no rule anywhere (that is #4633, confirmed links only). - lesson_rules: co_surfaced (fail-open; only pairs the reader could confirm — a lesson they may write, a rule they own; judged pairs gather nothing; one proposal per response; PROPOSE_COOLDOWN 6h between asks), plus the pure counting rules: situation_key, add_evidence, proposal_due, evidence_summary. A situation is the prompt on /retrieve (word tokens, sorted and de-duplicated, so trivial rewordings count once) and the FILE on /prior-art (every edit to one file is one situation). - rules_for_lessons shows a suggested link's evidence counts. - plugin_context: build_autoinject_hint returns lesson_ids, build_prompt_rule_hint returns shown_rule_ids, build_write_path_hint records its own pair; routes/plugin /retrieve records the prompt pair. Shown lines, repeats included: relevance makes a co-arrival, not the session ledger. - Evidence lives in the existing evidence column — no migration, and backup already carries it. - Tests: counting rules (unit), the recorder against Postgres (bar, repeat, judged pairs, ownership, cooldown, evidence kept on confirm); conftest stubs co_surfaced for unit tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
522 lines
26 KiB
Python
522 lines
26 KiB
Python
"""Scribe-plugin support endpoints.
|
|
|
|
Consumed by the Scribe Claude Code plugin (not the web UI). `GET /api/plugin/
|
|
context` is curled by the plugin's SessionStart hook to push the operator's
|
|
always-on rules + active-project context into the session — Scribe's own push
|
|
channel. Auth is the standard `@login_required` path, which accepts a `Bearer
|
|
fmcp_<key>` API key (a read-scoped key suffices for this GET).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from quart import Blueprint, g, jsonify, request
|
|
|
|
from scribe.auth import admin_required, get_current_user_id, login_required
|
|
from scribe.config import Config
|
|
from scribe.services import lesson_rules as lesson_rules_svc
|
|
from scribe.services import plugin_context as plugin_ctx_svc
|
|
from scribe.services import repo_bindings as repo_bindings_svc
|
|
from scribe.services import report_check as report_check_svc
|
|
from scribe.services import shape_check as shape_check_svc
|
|
from scribe.services import task_claims as task_claims_svc
|
|
from scribe.services.settings import get_admin_setting, set_setting
|
|
|
|
plugin_bp = Blueprint("plugin", __name__, url_prefix="/api/plugin")
|
|
|
|
# Setting key for the git URL of the marketplace that serves this plugin (the
|
|
# Scribe app's own repo). Instance-global (stored on an admin account) so every
|
|
# user's Settings page shows the real, copyable install command.
|
|
_MARKETPLACE_KEY = "plugin_marketplace_url"
|
|
|
|
|
|
def _int_list(raw: str | None) -> list[int]:
|
|
"""A comma-separated id list from the query string; non-ints dropped."""
|
|
return [int(p) for p in (raw or "").split(",") if p.strip().isdigit()]
|
|
|
|
|
|
async def _project_scope() -> tuple[int, str, str]:
|
|
"""(project_id, repo, unbound_repo) from the request's `project_id` /
|
|
`repo` query args — the one resolution every plugin endpoint shares.
|
|
|
|
An explicit project_id wins; otherwise the repo remote is resolved
|
|
through the caller's bindings, and a remote nobody bound comes back as
|
|
`unbound_repo` (normalised) so /context can say "bind this repo".
|
|
"""
|
|
try:
|
|
project_id = int(request.args.get("project_id", 0) or 0)
|
|
except (TypeError, ValueError):
|
|
project_id = 0
|
|
repo = (request.args.get("repo") or "").strip()
|
|
unbound_repo = ""
|
|
if repo and not project_id:
|
|
resolved = await repo_bindings_svc.resolve_project(g.user.id, repo)
|
|
if resolved:
|
|
project_id = resolved
|
|
else:
|
|
unbound_repo = repo_bindings_svc.normalize_repo_key(repo)
|
|
return project_id, repo, unbound_repo
|
|
|
|
|
|
|
|
@plugin_bp.get("/context")
|
|
@login_required
|
|
async def session_context():
|
|
"""Return SessionStart context (always-on rules + active project).
|
|
|
|
Query:
|
|
repo (optional str) — the working repo's git remote. The server
|
|
resolves it to the bound project (see services/repo_bindings); an
|
|
unbound repo yields a "bind this repo" hint instead. This is how the
|
|
active project is determined — the plugin never pins a project id.
|
|
project_id (optional int) — the project named directly, and the only
|
|
key a caller outside a git repo has (#4085): the plugin's hooks
|
|
send it when a `.scribe` marker file names a project. Takes
|
|
precedence over `repo`. Access-checked like any other read — an id
|
|
this account cannot read loads no project rather than failing.
|
|
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 (milestone 381).
|
|
"""
|
|
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
|
|
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 (#4364). 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 (#3751), so salience decays.
|
|
held_rule_ids — comma-separated rule ids the session actually
|
|
(opt) OPENED, observed from the `get_rule` call itself
|
|
rather than claimed. A rule on exclude_rule_ids
|
|
was NAMED; a rule here was READ, and the two say
|
|
different things about what the reader holds —
|
|
so they get different lines (#4100). Shares the
|
|
ledger directory and the same clear-on-compact.
|
|
|
|
TWO ARMS, TWO SETS OF GATES. Rules ride the same hook and the same query
|
|
but nothing else: the notes menu can be disabled, thresholded and top-k'd
|
|
by the operator without touching whether a rule reaches them. Composed
|
|
here rather than inside either builder so neither one's early return can
|
|
silently suppress the other.
|
|
|
|
Rules come FIRST in the payload. A rule or preference governing the answer
|
|
is more consequential than a menu of things that might be worth reading,
|
|
and a reader who stops after the first block should have stopped after
|
|
the right one.
|
|
"""
|
|
q = (request.args.get("q") or "").strip()
|
|
project_id, _repo, _unbound = await _project_scope()
|
|
exclude_ids = _int_list(request.args.get("exclude_ids"))
|
|
exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
|
|
held_rule_ids = _int_list(request.args.get("held_rule_ids"))
|
|
|
|
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 (#4637). 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,
|
|
)
|
|
blocks = [b for b in (rules["context"], result["context"], proposal) if b]
|
|
result["context"] = "\n\n".join(blocks)
|
|
result["rule_ids"] = rules["rule_ids"]
|
|
return jsonify(result)
|
|
|
|
|
|
@plugin_bp.get("/tool-rules")
|
|
@login_required
|
|
async def pre_tool_rules():
|
|
"""Standing rules for the plugin's PreToolUse hook on ACTIONS (#3476).
|
|
|
|
Answers "does a recorded rule speak to the command about to be run?" — the
|
|
sibling of /prior-art, which can only answer that question about a code
|
|
write. Rules about which tool to reach for (don't curl the forge, don't
|
|
stand up a stack, don't run the suite locally) had no retrieval surface at
|
|
all before this, which is why they all had to live in the resident preload.
|
|
|
|
Titles + trigger only, never the statement: the hint says a rule may apply
|
|
and hands over `get_rule(id)`. One rule at most (RULEHINT_LIMIT), and empty
|
|
most of the time.
|
|
|
|
Query:
|
|
tool (str) — the tool about to run, e.g. `Bash`. Used in
|
|
the hint's wording, not in the search: a
|
|
rule is about the action, not the harness.
|
|
command (str) — the command about to run; the semantic query.
|
|
Absent or blank → empty, no search.
|
|
repo (optional) — working repo remote, resolved to the bound
|
|
project exactly as /retrieve and /prior-art.
|
|
exclude_rule_ids (opt) — comma-separated rule ids already surfaced
|
|
this session. SHARED with /prior-art's
|
|
ledger on purpose: one session keeps one
|
|
list, so a rule named by either arm is not
|
|
re-offered by the other.
|
|
held_rule_ids (opt) — rule ids the session actually OPENED, as
|
|
against merely named. Named and read are
|
|
different claims about the reader's
|
|
context, so they get different lines
|
|
(#4100).
|
|
|
|
Returns `context`, `rule_ids`, and `checkpoint` (#4214, milestone 419).
|
|
|
|
`checkpoint` IS THE ONE PART OF THIS RESPONSE THAT IS NOT A HINT. It is
|
|
empty on almost every call. When present it carries `rule_id`, `title`,
|
|
`trigger`, `score` and a rendered `reason`, and it means the hook should
|
|
DENY the call rather than annotate it — because every other line this
|
|
endpoint returns is delivered by Claude Code alongside the tool RESULT,
|
|
so it reaches the reader after the act is composed and reads as
|
|
commentary on a decision already made.
|
|
|
|
It is raised only for a rule (never a preference, which claims no such
|
|
force), only for the ranker's top hit, only above the checkpoint
|
|
threshold — well above this arm's own floor — and only when the session
|
|
has NOT opened that rule. The remedy is one `get_rule` call, and the act
|
|
may then be re-submitted unchanged; the hook caps stops at one per rule
|
|
and five per session so a mis-set floor degrades to noise rather than to
|
|
a session that cannot proceed.
|
|
"""
|
|
tool = (request.args.get("tool") or "tool").strip()
|
|
command = request.args.get("command") or ""
|
|
project_id, _repo, _unbound = await _project_scope()
|
|
exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
|
|
held_rule_ids = _int_list(request.args.get("held_rule_ids"))
|
|
result = await plugin_ctx_svc.build_tool_rule_hint(
|
|
g.user.id, tool, command,
|
|
project_id=project_id, exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids,
|
|
)
|
|
return jsonify(result)
|
|
|
|
|
|
@plugin_bp.get("/prior-art")
|
|
@login_required
|
|
async def write_path_prior_art():
|
|
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
|
|
|
|
Answers "what is already recorded for the file about to be written?" — by
|
|
place (a snippet at this path or in its directory) and by meaning (snippets
|
|
resembling the code about to be written). Titles only, gated, and empty most
|
|
of the time. See services.plugin_context.build_write_path_hint.
|
|
|
|
Query:
|
|
path (str) — the target file, REPO-RELATIVE, matching the
|
|
convention snippet locations are recorded in. An
|
|
absolute path simply won't match anything.
|
|
code (optional) — the code about to be written; the semantic query.
|
|
Omit it and only the location arm runs.
|
|
repo (optional) — working repo remote; resolved to the bound project
|
|
to scope the search, exactly as /retrieve does. It
|
|
is NOT used as the location `repo` filter — see the
|
|
service docstring for why those two differ.
|
|
project_id (opt) — explicit project scope override (ad-hoc/testing).
|
|
exclude_ids (opt) — comma-separated REUSE-class ids already surfaced
|
|
this session.
|
|
exclude_sync_ids (opt) — comma-separated SYNC-class ids (snippets
|
|
recorded AT the edited file, #2708) already
|
|
surfaced. A separate channel on purpose: a reuse
|
|
hint shown early must not suppress the record-sync
|
|
nudge when the recorded file is edited later.
|
|
exclude_rule_ids (opt) — comma-separated RULE ids already surfaced
|
|
this session. Its own channel like the three
|
|
above, and for the same reason: a rule named
|
|
twenty turns ago should not be re-offered on
|
|
every subsequent write.
|
|
held_rule_ids (opt) — RULE ids the session actually OPENED, as
|
|
against merely named; drives the third
|
|
reference wording (#4100).
|
|
exclude_derive (opt) — comma-separated derive keys (a derive group id
|
|
or `canon:<snippet_id>`) already named this
|
|
session by the ledger arm (#2900); its own
|
|
channel, like the two above.
|
|
(Returns a `checkpoint` block on the same contract as /tool-rules —
|
|
see that endpoint. The write-path HOOK deliberately does not act on
|
|
it: scribe_prior_art.sh carries a tested property that it never
|
|
returns a permissionDecision, on the operator's decision that a recall
|
|
aid may not stand in the way of a write. The block is computed and
|
|
returned so that decision can be revisited with evidence rather than
|
|
re-argued, and so a change of mind is a hook edit and not a feature.)
|
|
|
|
shapes (opt) — comma-separated `kind:name` definitions the hook
|
|
found in (or enclosing) the payload, kind being
|
|
css|sym. The shape ledger's write-path feed
|
|
(#2791): when the session recently PULLED a
|
|
snippet this payload references or resembles,
|
|
these carry it as a proposal — evidence for the
|
|
agent's own end-of-turn judgment, never a
|
|
verdict (milestone 439). 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,
|
|
)
|
|
return jsonify(result)
|
|
|
|
|
|
# The hook names at most a dozen definitions per write; anything past that is
|
|
# a generated file, not a shape being instantiated.
|
|
_SHAPES_CAP = 12
|
|
|
|
|
|
def _parse_shapes(raw: str) -> list[tuple[str, str]]:
|
|
"""`css:btn-primary,sym:onTrash` → [("css", "btn-primary"), ("sym", "onTrash")].
|
|
Unknown kinds and empty names are dropped, duplicates collapse, and the
|
|
list is capped — the hook's own cap, re-applied so the contract holds
|
|
for any caller."""
|
|
out: list[tuple[str, str]] = []
|
|
for part in raw.split(","):
|
|
kind, _sep, name = part.strip().partition(":")
|
|
kind, name = kind.strip(), name.strip()
|
|
if kind in ("css", "sym") and name and (kind, name) not in out:
|
|
out.append((kind, name))
|
|
if len(out) >= _SHAPES_CAP:
|
|
break
|
|
return out
|
|
|
|
|
|
@plugin_bp.get("/report-check")
|
|
@login_required
|
|
async def report_check():
|
|
"""Record what a Stop hook found in a reply that closed a task (milestone 409 step 5).
|
|
|
|
The hook decides which completion sections the reply lacks — a local check
|
|
of text it can read — and reports the outcome here. For `blocked` the
|
|
response carries the `reason` to send the agent back with: the words are
|
|
the server's, so every client's hook says the same thing (plugin/PACKAGING.md).
|
|
A hook blocks only on a `reason` it received, which means only on a block
|
|
that was recorded.
|
|
|
|
A GET for the reason every plugin endpoint is one: a read-scoped key must
|
|
be enough to run the plugin, and this records telemetry the way /retrieve
|
|
records a retrieval log.
|
|
|
|
Query:
|
|
outcome (str) — passed | blocked | passed_after_rewrite |
|
|
missing_after_rewrite. Anything else is a 400.
|
|
missing (opt) — comma-separated sections the reply lacked:
|
|
"where it sits", "needs you", "next".
|
|
task_ids (opt) — comma-separated ids of the tasks the turn closed.
|
|
repo (opt) — working repo remote, resolved like the other arms.
|
|
"""
|
|
outcome = (request.args.get("outcome") or "").strip()
|
|
if outcome not in report_check_svc.OUTCOMES:
|
|
return jsonify({"error": f"outcome must be one of {list(report_check_svc.OUTCOMES)}"}), 400
|
|
missing = [m for m in (request.args.get("missing") or "").split(",") if m.strip()]
|
|
task_ids = _int_list(request.args.get("task_ids"))[:20]
|
|
project_id, _repo, _unbound = await _project_scope()
|
|
await report_check_svc.record_report_check(
|
|
g.user.id, outcome, missing=missing, task_ids=task_ids, project_id=project_id or None,
|
|
)
|
|
body: dict = {"status": "ok"}
|
|
if outcome == "blocked":
|
|
body["reason"] = report_check_svc.block_reason(missing)
|
|
return jsonify(body)
|
|
|
|
|
|
@plugin_bp.get("/shape-check")
|
|
@login_required
|
|
async def shape_check():
|
|
"""The end-of-turn question (milestone 439): 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 /report-check does. 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 scribe.services import access
|
|
from scribe.services import coverage as coverage_svc
|
|
from scribe.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 (milestone 381).
|
|
|
|
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 (milestone 381 step 4).
|
|
|
|
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"})
|