CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Failing after 8s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 36s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 21s
The prior-art and tool-rule hooks record every rule id they have named in <state>/<sid>.rules.ids and hand it back as exclude_rule_ids, so a rule is surfaced once per session and then goes quiet. That is correct while the session still HOLDS what it was told. A compaction breaks it in the worst available way: it summarizes the earlier injections out of context and does not touch the filesystem. The rule ends up absent from context AND still excluded — unreachable for the rest of the session. The compaction banner this hook already prints 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 that state are the ones that fire most often. The stale ledger is genuinely found again rather than orphaned: the etag marker further down this same hook is rewritten on `compact` and keyed by session_id, which is only meaningful if the id survives a compaction. CLEARED ON THE SOURCES THAT DESTROY CONTEXT, AND ONLY THOSE. `compact` and `clear` destroy it while the file survives. `resume` does not — the context came back intact, so clearing there would re-surface every rule after a restore that lost nothing, which is the same defect from the other side. `startup` is a no-op against a new session id. `fork` keeps it, and the answer holds whichever way forks are keyed: a fork carries the conversation, so an inherited id means an accurate ledger and a new id means an empty file. Only the RULE 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 a `rm` glob would have decided it silently. The guard pins the DISCRIMINATION, not the deletion: the whole source table is asserted in one statement, so a blanket delete (all False) and a no-op (all True) both fail, and neither can be made to pass by editing one case. A second test pins the scope against that glob, and a third proves an event with no session id clears nothing rather than falling back to a wildcard. Runs with no SCRIBE_URL/SCRIBE_TOKEN on purpose — the clear is local, keyless and networkless, and must still happen against an unreachable instance. That is also why it sits above the config read rather than inside the dynamic tier. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
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 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
|
|
|
|
# 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
|
|
# list_always_on_rules too, but a hook cannot see an MCP 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 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
|