diff --git a/plugin/hooks/scribe_autoinject.sh b/plugin/hooks/scribe_autoinject.sh index d10a51b..a182936 100755 --- a/plugin/hooks/scribe_autoinject.sh +++ b/plugin/hooks/scribe_autoinject.sh @@ -11,9 +11,12 @@ # 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: -# CLAUDE_PLUGIN_OPTION_api_endpoint base URL, no trailing slash -# CLAUDE_PLUGIN_OPTION_api_token fmcp_ API key (sensitive) +# 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 @@ -32,8 +35,8 @@ event_cwd=$(printf '%s' "$event" | jq -r '.cwd // empty' 2>/dev/null) || event_c # Nothing to retrieve against. [ -n "$prompt" ] || exit 0 -url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}} -token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}} +url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}} +token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}} # Guard against an unexpanded ${...} placeholder arriving as a literal. case "$url" in *'${'*) url="" ;; esac case "$token" in *'${'*) token="" ;; esac @@ -42,14 +45,18 @@ case "$token" in *'${'*) token="" ;; esac # Cap the query length — a giant prompt makes a giant URL for no extra signal. q=$(printf '%s' "$prompt" | cut -c1-2000) -q_enc=$(printf '%s' "$q" | jq -rR '@uri' 2>/dev/null) || exit 0 +# `-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 -rR '@uri' 2>/dev/null) || enc="" + enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc="" [ -n "$enc" ] && repo_q="&repo=${enc}" fi diff --git a/plugin/hooks/scribe_prior_art.sh b/plugin/hooks/scribe_prior_art.sh index 8ae9bd1..1b79998 100755 --- a/plugin/hooks/scribe_prior_art.sh +++ b/plugin/hooks/scribe_prior_art.sh @@ -13,9 +13,11 @@ # Any failure — unconfigured, unreachable, malformed — exits 0 in silence. A # recall aid must not be able to stop the operator's work. # -# Config (same as the other hooks), exported to the hook by Claude Code: -# CLAUDE_PLUGIN_OPTION_api_endpoint base URL, no trailing slash -# CLAUDE_PLUGIN_OPTION_api_token fmcp_ API key (sensitive) +# Config (same as the other hooks), exported to the hook by Claude Code with the +# userConfig key UPPERCASED (see #2198 — the lowercase spelling reads as empty +# and this hook then exits 0 in silence, looking exactly like "no prior art"): +# 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. set -uo pipefail @@ -46,8 +48,8 @@ case "$file_path" in exit 0 ;; esac -url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}} -token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}} +url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}} +token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}} # Guard against an unexpanded ${...} placeholder arriving as a literal. case "$url" in *'${'*) url="" ;; esac case "$token" in *'${'*) token="" ;; esac @@ -70,16 +72,24 @@ fi # token limit well before this, so a bigger slice buys no extra signal — and the # payload has to stay a GET (a read-scoped API key cannot POST, and every other # plugin hook works with a read key). -q=$(printf '%s' "$code" | cut -c1-1200) +# `head -c`, not `cut -c1-1200`: cut is line-oriented and caps each line +# separately, so a 400-line edit sailed past the "1200 char" budget entirely and +# built a URL from the whole payload. head -c caps the total, which is the point. +q=$(printf '%s' "$code" | head -c 1200) -path_enc=$(printf '%s' "$rel_path" | jq -rR '@uri' 2>/dev/null) || exit 0 -code_enc=$(printf '%s' "$q" | jq -rR '@uri' 2>/dev/null) || code_enc="" +# `-sRr`, not `-rR`: jq -R reads input LINE BY LINE, so a multi-line payload came +# back as several separately-encoded lines joined by raw newlines — an invalid +# URL that made curl fail, and this hook then exited 0 in silence. -s slurps the +# whole input into one string first. Newlines are exactly what code contains, so +# this hook could never have worked without it (issue #2198 / #2082). +path_enc=$(printf '%s' "$rel_path" | jq -sRr '@uri' 2>/dev/null) || exit 0 +code_enc=$(printf '%s' "$q" | jq -sRr '@uri' 2>/dev/null) || code_enc="" # Resolve the working repo's remote so the server can scope to the bound project. repo=$(git -C "$lookup_dir" remote get-url origin 2>/dev/null || true) repo_q="" if [ -n "$repo" ]; then - enc=$(printf '%s' "$repo" | jq -rR '@uri' 2>/dev/null) || enc="" + enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc="" [ -n "$enc" ] && repo_q="&repo=${enc}" fi diff --git a/plugin/hooks/scribe_session_context.sh b/plugin/hooks/scribe_session_context.sh index fa40586..9cfec0c 100755 --- a/plugin/hooks/scribe_session_context.sh +++ b/plugin/hooks/scribe_session_context.sh @@ -4,16 +4,18 @@ # 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 the API token never reached this hook. The latter is a known -# Claude Code gap: sensitive userConfig values aren't always exported to the -# hook subprocess, so the dynamic tier can silently get nothing. The static tier -# is the load-bearing floor that does not depend on the key or the network. +# 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) +# 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. # @@ -25,16 +27,16 @@ # 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 -# hooks.json — sensitive values are kept in the keychain and never spliced into -# a hook command line, so the placeholder arrives unexpanded. The harness env -# vars above are the supported channel; SCRIBE_URL / SCRIBE_TOKEN override for -# the settings.json dogfooding path. +# 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. A *failed* -# dynamic fetch is surfaced as a short status line (not swallowed). A fully -# unconfigured install (no url AND no token) is the intended static-only mode -# and stays quiet. +# 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 command -v jq >/dev/null 2>&1 || exit 0 # needed to emit the JSON envelope safely @@ -55,8 +57,8 @@ prepend() { if [ -n "$out" ]; then out="$1"$'\n\n---\n\n'"${out}"; else out="$1" [ -f "$here/scribe_static_context.md" ] && out=$(cat "$here/scribe_static_context.md") # --- Tier 2: dynamic rules + active-project context (best-effort) --- -url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}} -token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}} +url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}} +token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}} # Guard against an unexpanded `${...}` placeholder reaching us as a literal — it # would otherwise be sent as a garbage Bearer token and 401. Treat as unset. @@ -71,7 +73,7 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true) q="" if [ -n "$repo" ]; then - enc=$(printf '%s' "$repo" | jq -rR '@uri' 2>/dev/null) || enc="" + enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc="" [ -n "$enc" ] && q="?repo=${enc}" fi body=$(curl -fsS --max-time 8 \ @@ -80,9 +82,16 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then [ -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 - # Endpoint configured but token absent: the signature of the known Claude Code - # userConfig export gap (sensitive values not always reaching the hook). - status="> ⚠️ Scribe: live context disabled this session — the API token did not reach this hook (a known Claude Code plugin-config gap). Tools still work; pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\`." + 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" diff --git a/plugin/hooks/scribe_sync_processes.sh b/plugin/hooks/scribe_sync_processes.sh index d9bd7e3..6422e45 100755 --- a/plugin/hooks/scribe_sync_processes.sh +++ b/plugin/hooks/scribe_sync_processes.sh @@ -18,14 +18,16 @@ # FAIL-OPEN & SILENT: never blocks a session; emits NOTHING on stdout (so it's # safe as a second SessionStart hook). On any fetch failure it exits without # touching existing stubs — a transient outage must not wipe the user's skills. -# Config mirrors the context hook (CLAUDE_PLUGIN_OPTION_* / SCRIBE_* override). +# Config mirrors the context hook: CLAUDE_PLUGIN_OPTION_API_ENDPOINT / +# CLAUDE_PLUGIN_OPTION_API_TOKEN (userConfig key UPPERCASED by Claude Code — see +# #2198), with SCRIBE_URL / SCRIBE_TOKEN as the override. set -uo pipefail command -v jq >/dev/null 2>&1 || exit 0 command -v curl >/dev/null 2>&1 || exit 0 -url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}} -token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}} +url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}} +token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}} # Guard against an unexpanded `${...}` placeholder arriving as a literal. case "$url" in *'${'*) url="" ;; esac case "$token" in *'${'*) token="" ;; esac