#!/usr/bin/env bash # Scribe plugin — SessionStart push channel (two tiers + compaction re-grounding). # # Tier 1 (STATIC, always fires, no auth, no network): injects the Claude Code # adapter's own guidance (scribe_static_context.md) — where Scribe's reflexes # are stated (the using-scribe skill), Claude Code's memory files, /compact, # /scribe:sync, and what to do when Scribe is unavailable. It carries only what # this client needs said; the reflexes themselves are owned by the shared # skills and the server's index. # # Tier 2 (DYNAMIC, best-effort enrichment): curls the operator's Scribe instance # for 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, which disables 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. This stays the durable path: record-as-you-go # plus a post-compaction reload from the instance. # # The other half: a host hook cannot make the model flush before a compaction, # but scribe_precompact_preserve.sh steers the # SUMMARY, because a PreCompact hook's stdout becomes the compaction's custom # instructions. The two are complementary, and neither replaces the other: that # hook decides what survives into the summary, this one reloads from the record # once the summary lands. # # 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 comment at the status block below. set -uo pipefail # shellcheck source=plugin/hooks/scribe_defs.sh . "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh" # `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) event_flat=$(printf '%s' "$event" | scribe_json_flat) source=$(scribe_json_pick "$event_flat" '.source') # --- The rule ledger outlives the context it describes --- # # scribe_prior_art.sh and scribe_tool_rules.sh record every rule id they have # named in /.rules.ids and hand it back as exclude_rule_ids, so a # rule is named once per session and then goes quiet. That is right while the # session still HOLDS what it was told, and wrong the moment it does not. # # A compaction summarizes the earlier injections away and does not touch the # filesystem, so the rule ends up absent from context AND still excluded — # unreachable for the rest of the session. The banner below tells the model to # re-pull its ALWAYS-ON rules, but a rule an arm surfaced is conditional and is # not in that set, so it has no other way back. The rules most likely to be in # this state are the ones that fire most often, which is to say the ones that # apply most. # # The session id survives a compaction — the ledgers are keyed by it and are # still found under the same name afterwards, so a stale ledger is genuinely # reached again rather than orphaned. # # CLEARED ON THE SOURCES THAT DESTROY CONTEXT, AND ONLY THOSE: # # compact CLEAR — summarized away; the file survived. # clear CLEAR — context wiped. # startup nothing to do: a new session id means a new, empty file. # resume KEEP. The context was genuinely restored, so the ledger still # describes what the session holds. Clearing here would re-surface # every rule after a restore that lost nothing — the mirror error. # fork KEEP, and the answer is the same whichever way forks are keyed: a # fork carries the conversation, so if it inherits the id the ledger # is accurate, and if it gets a new one the file is empty anyway. # # EVERY LEDGER, AND THE NOTE ARMS NEED IT MOST: # # - They are HARD exclusions. `exclude_ids` goes into `semantic_search_notes` # itself, so a surfaced note is removed from the result set — it is not # rendered as a reference the way a repeated rule is. There is no weaker # form for it to fall back to. # - They never AGE. The rules ledger has a TTL because salience decays # without a context event; the note channels are a flat read. # # Hard plus permanent plus never cleared would make a note surfaced in the # first minute of a session unreachable for the rest of it, through any number # of compactions. # # `scribe_clear_session_ledgers` matches on the naming convention — a # per-session ledger is `[.].ids` — so it covers every ledger, # including one added later. Best-effort, like every other filesystem touch in these hooks: # a ledger that cannot be removed costs a repeated exclusion, never a session. # # NOT swept: `.unreached`, which records that the instance was unreachable # rather than what the session holds, and survives on purpose. The directories # swept are `scribe_defs.sh`'s `SCRIBE_LEDGER_DIRS` — plural, because the # auto-inject arm keeps its ledger somewhere else. case "$source" in compact|clear) sid=$(scribe_json_pick "$event_flat" '.session_id') if [ -n "$sid" ]; then safe_sid=$(printf '%s' "$sid" | tr -c 'A-Za-z0-9._-' '_') scribe_clear_session_ledgers "$safe_sid" fi ;; esac 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// 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. # # 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=$(scribe_json_pick "$(scribe_json_flat < "$manifest")" '.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 # Which project is this directory's? A `.scribe` marker first, then the git # remote. scribe_scope_query answers that, and is shared with the # other five hooks so a directory scopes the same way everywhere; the pieces # are re-read here only to explain what happened when nothing resolved. repo_dir=${CLAUDE_PROJECT_DIR:-$PWD} marker=$(scribe_marker_file "$repo_dir") marker_read=$(scribe_marker_read "$marker") marker_id=${marker_read%%$'\t'*} marker_why=${marker_read#*$'\t'} repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true) scope=$(scribe_scope_query "$repo_dir") # The source and the session id decide what the claim section says: a # compaction gets back the work this session had claimed, # with its latest logs; a new session hears about other sessions' claims. sid_now=$(scribe_json_pick "$event_flat" '.session_id') q="source=$(printf '%s' "$source" | scribe_urlenc)" [ -n "$sid_now" ] && q="${q}&session_id=$(printf '%s' "$sid_now" | scribe_urlenc)" [ -n "$scope" ] && q="${q}&${scope}" q="?${q}" # ONE FETCH, NAMED WHEN IT FAILS. A timeout, an HTTP error and a refused key # need different fixes, so each gets its own sentence. curl's write-out still # prints on a failed transfer (as `000`), so the status and the elapsed time # come back either way. fetch_context() { local resp meta resp=$(curl -sS --max-time "$1" -w '\n%{http_code} %{time_total}' \ -H "Authorization: Bearer ${token}" \ "${url%/}/api/plugin/context${q}" 2>/dev/null) ctx_rc=$? meta=${resp##*$'\n'} body=${resp%$'\n'*} [ "$body" = "$resp" ] && body="" ctx_code=${meta%% *} ctx_took=${meta#* } } # Deadlines: 8s for the first try, sized for a cold instance. # ONE retry at 6s, and only for failures a second try can change — a # timeout, a dropped connection, a 5xx. A 4xx is the key or the scope and # will say the same thing twice. Worst case is ~14s of startup, against a # whole session run without its project. fetch_context 8 case "$ctx_rc:$ctx_code" in 0:2*|0:4*) ;; *) fetch_context 6 ;; esac body_flat="" if [ "$ctx_rc" = 0 ] && [ "${ctx_code#2}" != "$ctx_code" ] && [ -n "$body" ]; then body_flat=$(printf '%s' "$body" | scribe_json_flat) dyn=$(scribe_json_pick "$body_flat" '.context') fi if [ -z "$dyn" ]; then if [ "$ctx_rc" = 28 ]; then why="the instance did not answer in time (8s, then ${ctx_took}s on a retry)" elif [ "$ctx_rc" != 0 ]; then why="the instance could not be reached (curl exit ${ctx_rc}, after a retry)" elif [ "$ctx_code" = 401 ] || [ "$ctx_code" = 403 ]; then why="the API key was refused (HTTP ${ctx_code})" elif [ "${ctx_code#2}" = "$ctx_code" ]; then why="the instance answered HTTP ${ctx_code}" else why="the instance answered but sent no context" fi # The first move, stated as one rather than as optional: a session that # skips it starts with no recent milestones or open tasks, so it cannot # know what prior work exists to look for. if [ -n "$marker_id" ] && [ -z "$marker_why" ]; then first="Start by calling \`enter_project(${marker_id})\`" else first="Start by finding this repo's project with \`list_projects()\` and calling \`enter_project()\`" fi status="> ⚠️ Scribe: live project context was not loaded this session — ${why}. The tools may still answer. ${first} before any other work: it loads the recent milestones and open tasks this session would otherwise begin without, and prior work you cannot see is work you will redo. The using-scribe skill still applies." fi 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; ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\`." elif [ -z "$url" ] && [ -z "$token" ]; then # NEITHER value arrived. Not a benign "unconfigured install": the plugin # prompts for both values at enable time, so if neither reached the hook, # something is wrong (an env-var casing mismatch looks exactly like this). # 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; ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\`." fi [ -n "$dyn" ] && append "$dyn" [ -n "$status" ] && append "$status" # --- Nothing resolved: say WHICH nothing, and what would fix it --- # # The server can say "no project is bound to this working directory". It is # the same sentence for a directory # that is not a repo, a repo whose remote nobody bound, and a marker file # naming a project this account cannot read — three different problems with # three different fixes, and no way to tell them apart from inside the session. # # The marker is the adapter's convention, so the adapter explains it: the # server reports whether a project resolved, and this hook — which knows what # it sent and why — turns that into the sentence the operator can act on. The # repo case is left to the server's existing "bind this repo" hint. if [ -n "$dyn" ] && [ -z "$(scribe_json_pick "$body_flat" '.project.id')" ]; then host=$(scribe_url_host "$url") if [ -n "$marker_why" ]; then append "> ⚠️ Scribe: the marker file \`${marker}\` ${marker_why}, so no project context was loaded. Fix the file, or ignore it and bind this directory another way." elif [ -n "$marker_id" ]; then append "> ⚠️ Scribe: \`${marker}\` names project ${marker_id}, which this account cannot read on ${host} — it may belong to a different Scribe instance, or the project may have been deleted. Check with \`list_projects()\` and correct the file." elif [ -z "$repo" ]; then append "> ℹ️ Scribe: this directory is not a git repository and has no \`.scribe\` marker, so no project context was loaded — every hook this session is unscoped. If this work belongs to a Scribe project, call \`list_projects()\` and write the marker: \`{\"instance\": \"${url%/}\", \"project_id\": , \"project\": \"\"}\` in \`${repo_dir}/.scribe\`. Future sessions here load that project on their own." fi fi # 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. Any rules that had been retrieved went into that summary with everything else, so treat yourself as holding none: before the next consequential act, ask again with \`search(content_type=\"rule\")\` rather than trusting a half-remembered one. Re-run \`enter_project()\` for the active project, check its recent milestones and open tasks, and reconcile what you are mid-way through against what Scribe records. Scribe is the record." fi # Nothing at all to inject → stay silent. [ -n "$out" ] || exit 0 scribe_json_out SessionStart "$out" exit 0