CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 42s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Failing after 1m46s
CI & Build / Build & push image (push) Skipped
CI caught two things in c61f730, and the second is the one that mattered.
1. `scribe_autoinject.sh` keeps its note ledger in `${TMPDIR}/scribe-autoinject`,
not `scribe-priorart`. Every ledger NAMED in the hooks was in the one
directory the sweep visited, so it read as complete — and the arm that fires
most (598 calls in five days) was the only one still carrying the bug. The
clear ran, found nothing to remove, and exited 0. `SCRIBE_LEDGER_DIRS` in
`scribe_defs.sh` is now the roster, and the guard reads that string rather
than a copy of it, so a test can no longer agree with itself forever.
2. The convention guard over-reached: it flagged `<sid>.snap` and
`<sid>.blocked`, which live in `scribe-afterwrite` and `scribe-reportcheck`
and can never be touched by the sweep. It is now two tests keyed on the
directory, because the two assumptions fail differently — one lets a ledger
sit where nothing sweeps, the other lets it sit in the right place under a
name the sweep does not match.
`test_only_the_rule_ledger_is_cleared_and_the_note_ledgers_are_left` also went
red, correctly: the note arms' exemption was a real decision, recorded in a
test, and my commit message said nothing about it had been decided. That was
wrong and the test was right to stop me. The decision is reversed rather than
ignored, and the reasoning is that #4101 removed its premise: it rested on a
note repeat being WITHHELD, so clearing the ledger meant re-injecting whole
menu lines the session already had. Now a repeat is rendered with a `seen`
marker, so the ledger only decides whether that marker is true — and across a
compaction it is false, telling a freshly-summarised session it has already
seen a record that is nowhere in its context. The test keeps its name and
records the reversal with the reason, rather than being deleted.
Verified by running the hook directly: all six ledgers across both directories
gone on `compact`, `<sid>.unreached` left standing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
252 lines
15 KiB
Bash
Executable File
252 lines
15 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 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. Since milestone 410
|
||
# (decision #4027) 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 — 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. This stays the durable path: record-as-you-go
|
||
# plus a post-compaction reload from the instance.
|
||
#
|
||
# The other half arrived with #3680. A host hook still 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 #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 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. (This used to point at an etag marker as
|
||
# the evidence for that; milestone 394 removed the preload the etag described,
|
||
# and `plugin_context.py` records its retirement.)
|
||
#
|
||
# 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 NEEDED IT MOST (#4101).
|
||
#
|
||
# This used to clear the two rule ledgers by name and say, in a comment, that
|
||
# leaving `.ids` / `.sync.ids` / `.derive.ids` alone was "a decision rather than
|
||
# an oversight". Reading the note arms says otherwise, on two counts:
|
||
#
|
||
# - 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 #3750 made a repeated rule. There is no
|
||
# weaker form for it to fall back to.
|
||
# - They never AGE. #3751 gave the rules ledger a TTL precisely because
|
||
# salience decays without a context event; the note channels were left on a
|
||
# flat read.
|
||
#
|
||
# Hard plus permanent plus never cleared means a note surfaced in the first
|
||
# minute of a session is unreachable for the rest of it, through any number of
|
||
# compactions. That is milestone 386's original defect, alive on the arms that
|
||
# fire most often, and nothing about it was decided.
|
||
#
|
||
# THE LIST WAS THE BUG, so the fix is not a longer list. `scribe_clear_session_
|
||
# ledgers` matches on the naming convention — a per-session ledger is
|
||
# `<sid>[.<kind>].ids` — which covers all five and covers the sixth on the day
|
||
# it is written. 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: `<sid>.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 and a single-directory sweep
|
||
# missed exactly the arm that fires most.
|
||
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._-' '_')
|
||
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/<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
|
||
# Which project is this directory's? A `.scribe` marker first, then the git
|
||
# remote (#4085). 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")
|
||
q=""
|
||
[ -n "$scope" ] && q="?${scope}"
|
||
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)
|
||
# The rules marker is gone with the resident set it described
|
||
# (milestone 394). Nothing is preloaded, so there is no set whose
|
||
# drift a later write could be told about — a rule is retrieved at
|
||
# the moment it applies, which cannot be stale.
|
||
[ -z "$dyn" ] && status="> ⚠️ Scribe: live project context could not be loaded this session (instance unreachable or request failed). The using-scribe skill 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"
|
||
|
||
# --- Nothing resolved: say WHICH nothing, and what would fix it (#4085) ---
|
||
#
|
||
# The server can say "no project is bound to this working directory", and
|
||
# until now that was the whole answer. 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 "$(printf '%s' "$body" | jq -r '.project.id // empty' 2>/dev/null)" ]; 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\": <id>, \"project\": \"<title>\"}\` 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
|
||
|
||
jq -n --arg c "$out" \
|
||
'{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $c}}'
|
||
exit 0
|