f6629d4bcf
Always-on rules were on-demand, not always-present: Tier-1 static context only
tells the agent to call list_always_on_rules(), and Tier-2 dynamic fetch is dark
(token doesn't reach the hook subprocess). On compaction the fetched rules get
summarized away while the harness's own built-in git instruction ("branch first")
survives in the base prompt — so post-compact the generic git instinct wins and
rule #1 ("dev is home") is missed.
- scribe_static_context.md: new "Operator rules govern consequential actions"
bullet — before any git branch/commit/push or hard-to-reverse action, loaded
rules beat generic harness/default habits; re-pull rules if not loaded or
summarized by a compaction. Tier 1 = always fires, keyless, re-fires on compact.
- scribe_session_context.sh: compaction banner now re-pulls list_always_on_rules(),
not just enter_project().
- plugin.json: 0.1.10 → 0.1.11 so autoUpdate ships the plugin/ change (#1040).
Generic and instance-agnostic per rules #115/#119 — no operator-specific rule
text hardcoded. Refs issue #1197.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4bNefPFAz7esmMZMZmkzL
102 lines
5.8 KiB
Bash
Executable File
102 lines
5.8 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 the API token never reached this hook. The latter is a known
|
|
# Claude Code gap: sensitive userConfig values aren't always exported to the
|
|
# hook subprocess, so the dynamic tier can silently get nothing. 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)
|
|
# 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
|
|
# hooks.json — sensitive values are kept in the keychain and never spliced into
|
|
# a hook command line, so the placeholder arrives unexpanded. The harness 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. A *failed*
|
|
# dynamic fetch is surfaced as a short status line (not swallowed). A fully
|
|
# unconfigured install (no url AND no token) is the intended static-only mode
|
|
# and stays quiet.
|
|
set -uo pipefail
|
|
|
|
command -v jq >/dev/null 2>&1 || exit 0 # needed to emit the JSON envelope safely
|
|
|
|
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=""
|
|
|
|
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")
|
|
|
|
# --- Tier 2: dynamic rules + active-project context (best-effort) ---
|
|
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}}
|
|
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}}
|
|
|
|
# Guard against an unexpanded `${...}` placeholder reaching us as a literal — it
|
|
# would otherwise be sent as a garbage Bearer token and 401. Treat as unset.
|
|
case "$url" in *'${'*) url="" ;; esac
|
|
case "$token" in *'${'*) token="" ;; esac
|
|
|
|
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 -rR '@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)
|
|
[ -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
|
|
# Endpoint configured but token absent: the signature of the known Claude Code
|
|
# userConfig export gap (sensitive values not always reaching the hook).
|
|
status="> ⚠️ Scribe: live context disabled this session — the API token did not reach this hook (a known Claude Code plugin-config gap). 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
|