209 lines
12 KiB
Bash
Executable File
209 lines
12 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 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
|
|
|
|
# shellcheck source=plugin/hooks/scribe_defs.sh
|
|
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
|
|
|
|
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=""
|
|
|
|
# --- The rule ledger outlives the context it describes (#3749) ---
|
|
#
|
|
# scribe_prior_art.sh and scribe_tool_rules.sh record every rule id they have
|
|
# named in <state>/<sid>.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 etag marker further down is
|
|
# rewritten on `compact` and keyed by session_id, which is only meaningful if
|
|
# the id is stable — so the stale ledger is genuinely found again, not 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.
|
|
#
|
|
# ONLY the rules ledger. The same directory holds .ids / .sync.ids /
|
|
# .derive.ids for the note arms. Whether a surfaced NOTE should return after a
|
|
# compaction is a different question with a different answer, and leaving those
|
|
# alone is a decision rather than an oversight.
|
|
case "$source" in
|
|
compact|clear)
|
|
sid=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || sid=""
|
|
if [ -n "$sid" ]; then
|
|
safe_sid=$(printf '%s' "$sid" | tr -c 'A-Za-z0-9._-' '_')
|
|
# Best-effort, like every other filesystem touch in these hooks: a ledger
|
|
# that cannot be removed costs a repeated exclusion, never a session.
|
|
rm -f "${TMPDIR:-/tmp}/scribe-priorart/${safe_sid}.rules.ids" 2>/dev/null || true
|
|
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/<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) ---
|
|
# 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
|
|
# 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)
|
|
# Stash the rules marker for the write-path hook (milestone 323). THIS is
|
|
# where it has to be captured: the model receives one from
|
|
# an MCP tool too, but a hook cannot see a tool's result. Stored
|
|
# under the same state dir the prior-art hook already uses, keyed by session,
|
|
# so "changed since" means since THIS session loaded its rules.
|
|
#
|
|
# Written on `compact` as well as `startup`, and that is correct rather than
|
|
# convenient: a compact tells the session to re-pull its rules, so the marker
|
|
# should describe the set it is about to hold. It is also why this cannot
|
|
# cover the compaction case — see the table in services/plugin_context.py.
|
|
if [ -n "$body" ]; then
|
|
etag=$(printf '%s' "$body" | jq -r '.rules_etag // empty' 2>/dev/null) || etag=""
|
|
if [ -n "$etag" ]; then
|
|
sid=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || sid=""
|
|
safe_sid=$(printf '%s' "${sid:-nosession}" | tr -c 'A-Za-z0-9._-' '_')
|
|
etag_dir="${TMPDIR:-/tmp}/scribe-priorart"
|
|
# Best-effort throughout: a marker that cannot be stored costs a hint,
|
|
# never the session.
|
|
mkdir -p "$etag_dir" 2>/dev/null \
|
|
&& printf '%s' "$etag" > "$etag_dir/${safe_sid}.rules_etag" 2>/dev/null || true
|
|
fi
|
|
fi
|
|
[ -z "$dyn" ] && status="> ⚠️ Scribe: live project context could not be loaded this session (instance unreachable or request failed). The standing guidance above still applies — ask for rules with \`search(content_type=\"rule\")\` 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; ask for rules with \`search(content_type=\"rule\")\` 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; ask for rules with \`search(content_type=\"rule\")\` 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. 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 open tasks and recent notes, 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
|
|
|
|
jq -n --arg c "$out" \
|
|
'{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $c}}'
|
|
exit 0
|