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>
140 lines
8.4 KiB
Bash
Executable File
140 lines
8.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Scribe plugin — SessionStart push channel (two tiers + compaction re-grounding).
|
|
#
|
|
# Tier 1 (STATIC, always fires, no auth, no network): injects a bundled
|
|
# behavioral mandate (scribe_static_context.md) so a fresh session knows to
|
|
# reach for Scribe — record work, recall before acting — even when the instance
|
|
# is unreachable or unconfigured. The static tier is the load-bearing floor that
|
|
# does not depend on the key or the network.
|
|
#
|
|
# Tier 2 (DYNAMIC, best-effort enrichment): curls the operator's Scribe instance
|
|
# for always-on rules + active-project context and appends it. Config comes from
|
|
# the plugin's userConfig, exported to hooks as:
|
|
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
|
|
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
|
|
# NOTE THE CASE: Claude Code uppercases the userConfig key when exporting it, so
|
|
# the `api_token` option arrives as CLAUDE_PLUGIN_OPTION_API_TOKEN. Reading the
|
|
# lowercase spelling silently yields nothing — that was issue #2198, and it
|
|
# disabled the dynamic tier, auto-inject, and the write-path trigger at once.
|
|
# The active project is resolved server-side from the working repo's git remote
|
|
# (see services/repo_bindings); bind each repo once with the bind_repo MCP tool.
|
|
#
|
|
# COMPACTION RE-GROUNDING: this hook is registered matcher-less, so it ALSO
|
|
# fires after a compaction (SessionStart input `source` == "compact"), when
|
|
# earlier turns have just been summarized and in-flight state is most at risk.
|
|
# On that source we lead with a banner telling the model to reload project +
|
|
# in-flight tasks from Scribe. (PreCompact is the wrong tool here — a host hook
|
|
# can't make the model flush, and can't know the in-flight task ids; the durable
|
|
# path is record-as-you-go + this post-compaction reload.)
|
|
#
|
|
# IMPORTANT: do NOT pass config via `${user_config.*}` substitution in a
|
|
# shell-form hooks.json command — Claude Code rejects that outright (splicing a
|
|
# configured value into a shell command line would let the shell run whatever it
|
|
# contains). The env vars above are the supported channel; SCRIBE_URL /
|
|
# SCRIBE_TOKEN override for the settings.json dogfooding path.
|
|
#
|
|
# FAIL-OPEN, BUT NOT SILENT: the dynamic tier never blocks a session, and every
|
|
# way it can come up empty produces a short status line — failed fetch, missing
|
|
# token, and missing-everything alike. Nothing about the credential path is
|
|
# allowed to fail quietly; see the #2198 comment at the status block below.
|
|
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 # needed to emit the JSON envelope safely
|
|
|
|
# `CDPATH= cd` is deliberate, not a typo'd assignment: it runs this one `cd`
|
|
# with CDPATH empty, so an operator whose CDPATH happens to contain a matching
|
|
# directory name can't send us somewhere else — and `cd` won't echo the resolved
|
|
# path into our output. shellcheck can't tell that idiom from `CDPATH=cd`.
|
|
# shellcheck disable=SC1007
|
|
here=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) || exit 0
|
|
|
|
# SessionStart delivers a JSON event on stdin; `source` is startup|resume|compact|clear.
|
|
event=$(cat 2>/dev/null || true)
|
|
source=$(printf '%s' "$event" | jq -r '.source // empty' 2>/dev/null) || source=""
|
|
|
|
out=""
|
|
# Append $1 to $out, separated by a horizontal rule when $out already has content.
|
|
append() { if [ -n "$out" ]; then out="${out}"$'\n\n---\n\n'"$1"; else out="$1"; fi; }
|
|
# Prepend $1 above $out (used for the compaction banner so it's seen first).
|
|
prepend() { if [ -n "$out" ]; then out="$1"$'\n\n---\n\n'"${out}"; else out="$1"; fi; }
|
|
|
|
# --- Tier 1: static behavioral mandate (always, keyless, networkless) ---
|
|
[ -f "$here/scribe_static_context.md" ] && out=$(cat "$here/scribe_static_context.md")
|
|
|
|
# --- Which version is actually RUNNING (keyless, networkless) ---
|
|
#
|
|
# An install has two halves and only one self-updates:
|
|
#
|
|
# marketplaces/…/scribe-plugin/ git clone — pulls on its own
|
|
# cache/…/scribe/<version>/ what EXECUTES — refreshed only when the
|
|
# manifest version changes
|
|
#
|
|
# So inspecting the clone shows a fix present while the broken copy keeps
|
|
# running, and the obvious debugging move actively misleads (#2209). Twice, the
|
|
# only detector was the operator saying "I don't think it updated".
|
|
#
|
|
# Naming the running version in every session makes that answerable from the
|
|
# transcript instead of by archaeology in the cache directory. Deliberately NOT
|
|
# a server round-trip or a stored per-user record: the state most needing
|
|
# diagnosis is the one where credentials never arrive, and this line still
|
|
# appears there.
|
|
manifest="$here/../.claude-plugin/plugin.json"
|
|
if [ -f "$manifest" ]; then
|
|
plugin_version=$(jq -r '.version // empty' "$manifest" 2>/dev/null) || plugin_version=""
|
|
if [ -n "$plugin_version" ]; then
|
|
append "> Scribe plugin **v${plugin_version}** is executing in this session. A fix merged after this version has not reached it — the marketplace clone updates on its own, but the cache that runs only refreshes when the manifest version changes."
|
|
fi
|
|
fi
|
|
|
|
# --- Tier 2: dynamic rules + active-project context (best-effort) ---
|
|
# Unconfigured is NOT a failure here: tier 1's static floor is still owed,
|
|
# so this records the answer rather than acting on it.
|
|
scribe_config || :
|
|
|
|
dyn=""
|
|
status=""
|
|
if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
|
|
# Resolve the working repo's remote so the server can map it to a project.
|
|
repo_dir=${CLAUDE_PROJECT_DIR:-$PWD}
|
|
repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
|
|
q=""
|
|
if [ -n "$repo" ]; then
|
|
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
|
|
[ -n "$enc" ] && q="?repo=${enc}"
|
|
fi
|
|
body=$(curl -fsS --max-time 8 \
|
|
-H "Authorization: Bearer ${token}" \
|
|
"${url%/}/api/plugin/context${q}" 2>/dev/null) || body=""
|
|
[ -n "$body" ] && dyn=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null)
|
|
[ -z "$dyn" ] && status="> ⚠️ Scribe: live rules/project context could not be loaded this session (instance unreachable or request failed). The standing guidance above still applies — pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\` as needed."
|
|
elif [ -n "$url" ] && [ -z "$token" ]; then
|
|
status="> ⚠️ Scribe: live context disabled this session — the API key is not configured (Scribe base URL is). Set it with \`/plugin\` → Scribe → configure, or export SCRIBE_TOKEN. Tools still work; pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\`."
|
|
elif [ -z "$url" ] && [ -z "$token" ]; then
|
|
# NEITHER value arrived. Previously this case stayed silent as "an unconfigured
|
|
# install", which made issue #2198 invisible for weeks: a *casing* bug here
|
|
# (reading CLAUDE_PLUGIN_OPTION_api_token when Claude Code exports the key
|
|
# UPPERCASED) looks identical to never having configured the plugin, and
|
|
# silently disabled auto-inject and the write-path trigger too. It is not a
|
|
# benign state — the plugin prompts for both values at enable time, so if
|
|
# neither reached the hook, something is wrong. Say so.
|
|
status="> ⚠️ Scribe: live context disabled this session — neither the Scribe base URL nor the API key reached this hook. Configure the plugin (\`/plugin\` → Scribe), or export SCRIBE_URL + SCRIBE_TOKEN. Note this also disables prompt auto-inject and the write-path prior-art trigger. Tools still work; pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\`."
|
|
fi
|
|
|
|
[ -n "$dyn" ] && append "$dyn"
|
|
[ -n "$status" ] && append "$status"
|
|
|
|
# Compaction re-grounding: lead with a reload banner when this fire is a compact.
|
|
if [ "$source" = "compact" ]; then
|
|
prepend "> ⟳ This session was just COMPACTED — earlier turns are now a summary, so in-flight detail may be lost. Before continuing, reload your bearings from Scribe: re-pull the operator's binding rules with \`list_always_on_rules()\` (a compaction can summarize them out of context, leaving only generic harness defaults in their place), re-run \`enter_project()\` for the active project, check its open tasks and recent notes, and reconcile what you're mid-way through against what Scribe records. Don't trust half-remembered state — Scribe is the record."
|
|
fi
|
|
|
|
# Nothing at all to inject → stay silent.
|
|
[ -n "$out" ] || exit 0
|
|
|
|
jq -n --arg c "$out" \
|
|
'{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $c}}'
|
|
exit 0
|