Files
FabledScribe/plugin/hooks/scribe_session_context.sh
T
bvandeusenandClaude Opus 5 0e10f6bb8a
CI & Build / Python lint (push) Failing after 3s
CI & Build / Plugin hooks (push) Failing after 12s
CI & Build / integration (push) Failing after 27s
CI & Build / TypeScript typecheck (push) Failing after 35s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
feat(rules)!: retire the always-on tier — every rule arrives by retrieval (#394)
Milestone 394, steps 5-8. Operator: "remove the always on rule functionality
as the goal was to not have it at all since it didn't seem to work as
expected."

Unconditional preload had three failures the retrieval arms do not. It could
not be MEASURED — a resident rule is in the context whether or not it
mattered, so nothing distinguished "this governed the act" from "this was
scenery", and it was the one surface structurally exempt from the scoreboard
judging every other. It was SUMMARISED AWAY by compaction while the session
went on believing it held the rules. And it CROWDED OUT the few rules that
applied with the thirty that did not.

WHAT GOES

Schema (0100): rules.tier + ck_rules_tier, rule_versions.tier,
rulebooks.always_on, and project_rulebook_exclusions — a table recording a
project's opt-out of something that no longer binds it unasked.

Tools: list_always_on_rules, exclude_always_on_rulebook,
include_always_on_rulebook. Service: the same three plus rules_etag_for,
_valid_tier and the whole etag family. The SessionStart preload and the
write-path staleness arm go with them: nothing is resident, so nothing can
have drifted since a session loaded it.

THREE CALLS WORTH REVIEWING

enter_project got NARROWER, not wider. Its filter was `always_on OR
area-tagged`; dropping the tier arm leaves the deterministic half, so a
project with no canonical-tagged Systems gets no bulk rules and reaches them
by retrieval instead. Dropping the whole clause would have made that payload
bigger than the preload this milestone deletes.

Backups import tolerantly. A pre-394 archive carries tier, always_on and the
retired inception choice; none is read, and the exclusion key is DROPPED
rather than remapped, because restoring it would write data that
validate_inception now rejects as unknown.

The migration is irreversible in the way that matters and says so: downgrade
recreates the columns at their defaults and cannot restore which rules were
always-on. A value invented to fill a hole is not a measurement.

THE INSTRUCTION SURFACES SAY THE HARDER THING

Deleting "call list_always_on_rules()" is easy; replacing it is not, because
the new model asks a session to trust something it cannot see. All three
surfaces now say a session holds nothing, that rules arrive when work matches
them, and — the half that got dangerous — that "no rule arrived" means
"nothing matched", never "there is no rule". Under residency an empty session
was rare and suspicious; it is now the ordinary state of most turns, so
reading it as permission is wrong on nearly every turn rather than
occasionally. That is #3720's defect at session scale.

test_instruction_surfaces_agree is repointed rather than retired: its two
halves collapsed into one instruction, and it gains a guard that every
surface states what absence means. _INSTRUCTIONS is back at 1999/2000 —
the inception clause paid for the longer HOW line.

UI (rule 27, and the opportunity step 8 named)

The tier selector is gone, and what replaces it is the point: `when_to_apply`
is now the field that decides whether a rule is ever seen, so the editor
marks it required, warns while it is empty, and both rule lists badge a
trigger-less rule "never surfaces". A rule without one is not quiet, it is
unreachable.

TESTS

Two files deleted outright — test_rules_etag.py and test_inception_rules.py
tested subsystems that no longer exist. Elsewhere obsolete cases were removed
and the rest repointed. One deserves naming: the wiring test asserted the act
arms pass no `tier`, which had become an assertion that could not fail. It is
repointed onto `kind`, which does still exist and where the same claim is
live — a preference must reach a write exactly as a rule does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
2026-09-11 16:22:17 -04:00

191 lines
11 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)
# 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 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