#!/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 command -v jq >/dev/null 2>&1 || exit 0 # needed to emit the JSON envelope safely 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") # --- 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:-}} # 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. case "$url" in *'${'*) url="" ;; esac case "$token" in *'${'*) token="" ;; esac 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