CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / integration (push) Successful in 1m34s
CI & Build / Python tests (push) Successful in 2m21s
CI & Build / Build & push image (push) Successful in 25s
DRY pass 3's remainder. Both halves start from enumeration, because the task's
candidate list was hypotheses and the process requires counting before
proposing — and counting changed the answer twice.
## The list_* family: a limit with no offset
Enumerated all 19 `list_*` MCP tools first. They are genuinely heterogeneous —
8 take `project_id`, 6 take `limit`, six take no arguments at all — so a
common-parameter guard would invent a convention the API does not have, which
is the over-DRY trap (§5). One contract IS real: a `limit` without an `offset`
is a truncation with no continuation. The caller is told there are 250 results,
handed 50, and given no way to ask for the rest.
Two tools had it, and both were capped over a service that already accepted an
offset: `snippets_svc.list_snippets(offset=0)` was simply not exposed, and
`list_processes` passed a hardcoded `offset=0` into `query_knowledge`. The
capability existed one layer down in both; only the door was missing — the
missing-sibling shape exactly. Both now expose it.
`tests/test_mcp_list_family.py` guards it, with `list_tags` exempted for a
stated reason (a ranked top-N over a bounded vocabulary has no "rest" to page
into). Candidates derived, decision explicit, same design as test_mcp_auth —
plus the reverse checks: a stale exemption, and an offset with no limit, which
would page through an unbounded result set. Verified non-vacuous by running the
sweep against the pre-fix tree, where it fails naming both tools.
## The verb pairs: no finding, which is the finding
`preview`/`apply` and `dry_run`/`commit` do not exist anywhere in the 102
tools — those were guesses about a shape Scribe never adopted. `count_*` does
not exist either. Of the create/delete stems only `project_rule` lacks a
`delete_X`, and deliberately: a project rule IS a rule, `delete_rule` removes
it, and the docstring says so. `force` sits on 6 of 7 duplicate-gated creates;
the exception is `create_system`, whose gate is an exact normalized-NAME match
rather than a semantic near-match — forcing it would split one area's records
across two piles, which its own message explains. No guard added: it would
need a seven-entry exemption list to defend against a hypothetical. Recorded
on the leave-alone list instead, which the process asks for by name.
## The hook config preamble
Not 3 of 6 hooks as recorded — all FIVE carried their own copy, and of four
lines rather than two. The extra two are a guard treating an unexpanded
`${...}` placeholder as unset, so it is never sent as a garbage Bearer token:
precisely the correctness detail a sixth hook would omit with nothing failing
loudly. Now `scribe_config` in scribe_defs.sh, which also declares the two
names it owns. It sets globals rather than echoing, so a token never passes
through a subshell's output where xtrace or a log could catch it, and returns
a status so a caller can bail (`|| exit 0`) or continue degraded — the
session-context hook still owes its static floor when Scribe is unconfigured.
`check_plugin.py` now runs shellcheck with `-x`. Without it the shared helpers
were invisible: every variable they set read as unassigned and every bug inside
them went unlinted at the call site, which is the opposite of what sharing them
was for. All twelve fail-open scenarios still pass, and all five hooks were
probed live against the instance — prior_art and after_write both still name
canon, autoinject returns context, session_context serves 11k chars of rules,
sync_processes stays silent. Plugin 0.1.46 (#2209).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
96 lines
4.2 KiB
Bash
Executable File
96 lines
4.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Scribe plugin — UserPromptSubmit push channel (knowledge auto-inject, Path A).
|
|
#
|
|
# On each user prompt, asks the operator's Scribe instance for a TITLE-FIRST
|
|
# awareness hint: the few notes that clear the per-user auto-inject gates
|
|
# (high-confidence threshold, margin gate, session dedup, top-k). Titles + ids
|
|
# only — never bodies; the agent calls get_note(id) to pull anything it judges
|
|
# relevant. Most turns inject nothing.
|
|
#
|
|
# Best-effort enrichment ONLY: unlike the SessionStart channel there is no
|
|
# static floor here. If the instance is unconfigured/unreachable, or anything
|
|
# fails, the hook stays SILENT and exits 0 — it must never block a prompt.
|
|
#
|
|
# Config (same as scribe_session_context.sh), exported to the hook by Claude Code
|
|
# with the userConfig key UPPERCASED (see #2198 — reading the lowercase spelling
|
|
# silently disables this hook, and silence is indistinguishable from "nothing
|
|
# cleared the threshold"):
|
|
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
|
|
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
|
|
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
|
|
#
|
|
# Session dedup: each surfaced note id is remembered in a per-session file so a
|
|
# note is injected at most once per session. Passed back as exclude_ids.
|
|
set -uo pipefail
|
|
|
|
# shellcheck source=plugin/hooks/scribe_defs.sh
|
|
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
|
|
|
|
command -v jq >/dev/null 2>&1 || exit 0
|
|
command -v curl >/dev/null 2>&1 || exit 0
|
|
|
|
# UserPromptSubmit delivers a JSON event on stdin: { prompt, session_id, cwd, ... }
|
|
event=$(cat 2>/dev/null || true)
|
|
prompt=$(printf '%s' "$event" | jq -r '.prompt // empty' 2>/dev/null) || prompt=""
|
|
session_id=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || session_id=""
|
|
event_cwd=$(printf '%s' "$event" | jq -r '.cwd // empty' 2>/dev/null) || event_cwd=""
|
|
|
|
# Nothing to retrieve against.
|
|
[ -n "$prompt" ] || exit 0
|
|
|
|
# Unconfigured install → silent (auto-inject is pure enrichment).
|
|
scribe_config || exit 0
|
|
|
|
# Cap the query length — a giant prompt makes a giant URL for no extra signal.
|
|
# `head -c`, not `cut -c1-2000`: cut is line-oriented and caps EACH LINE, so a
|
|
# long multi-line prompt sailed past the budget entirely. Same defect as the
|
|
# prior-art hook's code cap; this copy was missed when that one was fixed, and
|
|
# scripts/check_plugin.py caught it.
|
|
q=$(printf '%s' "$prompt" | head -c 2000)
|
|
# `-sRr`, not `-rR`: jq -R reads LINE BY LINE, so a multi-line prompt encoded as
|
|
# several lines joined by raw newlines and the request died. Single-line prompts
|
|
# worked, which is why this looked healthy — the long, substantial prompts most
|
|
# worth retrieving against were exactly the ones silently dropped. -s slurps.
|
|
q_enc=$(printf '%s' "$q" | jq -sRr '@uri' 2>/dev/null) || exit 0
|
|
|
|
# Resolve the working repo's remote so the server can scope to the bound project.
|
|
repo_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
|
|
repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
|
|
repo_q=""
|
|
if [ -n "$repo" ]; then
|
|
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
|
|
[ -n "$enc" ] && repo_q="&repo=${enc}"
|
|
fi
|
|
|
|
# Per-session dedup: ids already injected this session are skipped.
|
|
state_dir="${TMPDIR:-/tmp}/scribe-autoinject"
|
|
mkdir -p "$state_dir" 2>/dev/null || true
|
|
idfile=""
|
|
exclude_q=""
|
|
if [ -n "$session_id" ]; then
|
|
# session_id is an opaque token from Claude Code; keep only filename-safe chars.
|
|
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
|
idfile="$state_dir/${safe_sid}.ids"
|
|
if [ -f "$idfile" ]; then
|
|
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
|
|
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
|
|
fi
|
|
fi
|
|
|
|
body=$(curl -fsS --max-time 5 \
|
|
-H "Authorization: Bearer ${token}" \
|
|
"${url%/}/api/plugin/retrieve?q=${q_enc}${repo_q}${exclude_q}" 2>/dev/null) || exit 0
|
|
[ -n "$body" ] || exit 0
|
|
|
|
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0
|
|
[ -n "$context" ] || exit 0
|
|
|
|
# Remember the surfaced ids so they aren't injected again this session.
|
|
if [ -n "$idfile" ]; then
|
|
printf '%s' "$body" | jq -r '.note_ids[]? // empty' 2>/dev/null >> "$idfile" || true
|
|
fi
|
|
|
|
jq -n --arg c "$context" \
|
|
'{hookSpecificOutput: {hookEventName: "UserPromptSubmit", additionalContext: $c}}'
|
|
exit 0
|