#!/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 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. # # 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) # 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 # 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. # # 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. 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 -rR '@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 # 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()\`." 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