Files
FabledScribe/plugin/hooks/scribe_session_context.sh
T
bvandeusenandClaude Opus 5 841506b10c
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 19s
feat(plugin): every session says which plugin version is executing
Twice a shipped fix failed to reach a live install, and both times the only
detector was the operator saying "I don't think it updated" (#2198, #2209).

The reason it is hard to see: an install has two halves and only one
self-updates. The marketplace clone pulls on its own; the CACHE is what
executes and refreshes only when the manifest version changes. So inspecting
the clone shows the fix present while the broken copy keeps running — the
obvious debugging move actively misleads.

The SessionStart context now names the version it is running. That makes "what
is actually executing?" answerable from the transcript rather than by
archaeology in the cache directory.

DELIBERATELY SMALLER THAN THE ISSUE PROPOSED. #2220 recommended reporting the
version to the server, storing last-seen per user, and surfacing it in
Settings. That is three surfaces and a migration to answer a question the
session can answer about itself. Per the operator's framing on #2338 — "I'm
afraid of building another integration between two more surfaces, you being
able to notice is enough" — the visibility is the deliverable, not the
plumbing.

It also lands the issue's own caveat, which the server-side design could not:
the state most needing diagnosis is the one where credentials never arrive,
and there the dynamic tier does not run at all. This marker is keyless and
networkless, so it still appears — verified against both the unconfigured and
unreachable-instance paths.

CI pins it, asserting WITHOUT credentials for the same reason. Manifest 0.1.22
-> 0.1.23, because a shipped hook changed and an unbumped manifest is precisely
the failure this commit is about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-03 11:57:33 -04:00

141 lines
8.5 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
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) ---
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