CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / Python tests (push) Failing after 1m7s
CI & Build / Build & push image (push) Skipped
Every hook opened `command -v jq >/dev/null 2>&1 || exit 0`, so on a machine without jq the operator got no session context, no rules, no prior art and no process sync — and not one word saying why, because `exit 0` is indistinguishable from "ran fine, nothing to say". jq is absent by default on macOS, on the Debian/Ubuntu slim images, on Alpine and in most CI containers. That is not a prerequisite to document; it is the plugin handing its own packaging problem to whoever installs it. `tac` was worse: GNU-only, so the prior-art hook's enclosing-definition arm did nothing at all on every Mac, silently, from the day it shipped. It is not replaced but removed — scribe_defs judges each line independently, so extracting forward and taking `tail -1` is the same answer as reversing and taking the head, and it drops the early-exit `head` that #4042 was filed for. No server contract changed, so a lagging plugin cache keeps working. scribe_json.awk JSON -> IDX<TAB>PATH<TAB>VALUE. Two modes: `whole` for an event or a response body, `lines` for a transcript, where an unparseable record is dropped and the rest still read — the `map(try fromjson catch empty)` the jq program opened with. Arrays also report their LENGTH at `[#]`, which is what keeps "zero notes" distinct from "no answer" (#2932). scribe_turn.awk the turn-bounding program, replacing the thirty lines of jq in the Stop hook. scribe_defs.sh scribe_json_flat / _pick / _list / _len / _list_minus read, scribe_json_out writes the envelope (five copies of one shape, gone), scribe_urlenc replaces `jq -sRr '@uri'`. Percent-encoding goes through `od -tu1` rather than an awk character loop on purpose: awk's idea of a character follows the locale, so gawk reads an accented letter as one and mawk as two, and an encoder built on substr() would emit a different URL depending on which awk is installed. Encoding is defined on bytes. Verified byte-identical to `jq -sRr '@uri'`. Measured, not assumed. The per-event path costs 8ms against jq's 3ms. The transcript path was 70x slower until two fixes: the Stop hook now finds where the turn starts with a fixed-string grep before parsing (a needle carrying unescaped quotes cannot occur inside a JSON string, so it matches only at a record's top level — checked against a full JSON parse of a 27MB transcript: 152 prompt records, 152 matches, no misses, no extras), and the parser reads each token out of a 1024-byte window instead of copying the rest of the buffer per token, which was quadratic in line length on the 400KB tool results a transcript carries. Differential-tested against the jq program it replaces over 724 windows cut from three real transcripts — 724 identical, 0 mismatched, 45 of them exercising a real task close and a real reply. That sweep is what caught `scribe_turn.awk` never setting FS, which truncated every multi-word reply at its first space and was invisible to a test whose replies were all empty. check_plugin.py's `jq -R` lint becomes a guard against either binary coming back, and three smoke checks lose their `shutil.which("jq")` skip. jq is not in `ci-python` either, so those three announced a skip on every CI run and had never once run there: removing the dependency from the product also closed a permanent hole in its verification. They pass now across all ten hooks. tests/test_hook_json_reader.py is a differential against Python's `json` over nested objects, arrays, unicode, escapes, control characters, empty cases and a value longer than the token window, plus the envelope, the encoder and the turn analyzer. 139 cases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
254 lines
15 KiB
Bash
Executable File
254 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"
|
||
|
||
# `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 (#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=$(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/<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=$(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 (#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=""
|
||
body_flat=""
|
||
if [ -n "$body" ]; then
|
||
body_flat=$(printf '%s' "$body" | scribe_json_flat)
|
||
dyn=$(scribe_json_pick "$body_flat" '.context')
|
||
fi
|
||
# 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 "$(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\": <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
|
||
|
||
scribe_json_out SessionStart "$out"
|
||
exit 0
|