feat(plugin): two-tier SessionStart hook — static keyless floor + dynamic enrichment

The SessionStart push channel was single-tier: it curled /api/plugin/context
with a Bearer token and, on any failure (missing/unexported token, network
error, missing curl), injected nothing and exited 0 — silently. A known
upstream Claude Code gap (sensitive userConfig not reliably exported to hook
subprocesses) trips this routinely, so a fresh session gets no signal to reach
for Scribe and falls back to local file-memory (root cause of unlogged work on
remote/rc sessions).

Split into two tiers:
- Tier 1 (static, keyless, networkless, always fires): inject bundled
  scribe_static_context.md — the load-bearing behavioral mandate. Cannot be
  suppressed by the upstream key bug.
- Tier 2 (dynamic, best-effort, fails open): existing curl for live rules +
  active-project context, appended below the static block. Lights up as
  enrichment once the key reaches the hook.

Only jq is now required (JSON envelope); curl/token gate the dynamic tier only.
Bump plugin 0.1.5 -> 0.1.6 so clients pick up the change.

Refs milestone 55; task 809; decision note 810.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 15:01:35 -04:00
parent e631a4e615
commit d11eb9145b
3 changed files with 80 additions and 39 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe second brain for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, and a set of universal process-skills (brainstorm, debug, TDD, plan, verify). Replaces superpowers + file-memory with one app-backed plugin.",
"version": "0.1.5",
"version": "0.1.6",
"author": { "name": "Bryan Van Deusen" },
"mcpServers": {
"scribe": {
+45 -30
View File
@@ -1,32 +1,41 @@
#!/usr/bin/env bash
# Scribe plugin — SessionStart push channel.
# Scribe plugin — SessionStart push channel (two tiers).
#
# Curls the operator's Scribe instance for always-on rules + active-project
# context and emits it as SessionStart `additionalContext`. Config comes from
# the plugin's userConfig, which Claude Code exports to hook subprocesses as
# CLAUDE_PLUGIN_OPTION_<key> env vars:
# 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 NOT configured here — it's resolved server-side from
# the working repo's git remote (see services/repo_bindings). This keeps a
# single install working across many repos/projects: bind each repo once with
# the `bind_repo` MCP tool. An unbound repo just yields standing rules + a hint.
# 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.
#
# IMPORTANT: do NOT pass config via `${user_config.*}` substitution in
# hooks.json — sensitive userConfig values (api_token) are kept in the keychain
# and are 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 still override, for the settings.json dogfooding path where the
# hook is wired up by hand.
# 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: any missing tool/config, network error, or unreachable instance
# injects nothing and exits 0. A session must never be blocked by Scribe.
# FAIL-OPEN: the dynamic tier injects nothing on any error; the static tier
# still fires. A session is never blocked by Scribe.
set -uo pipefail
command -v jq >/dev/null 2>&1 || exit 0
command -v curl >/dev/null 2>&1 || exit 0
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
# --- Tier 1: static behavioral mandate (always, keyless, networkless) ---
out=""
[ -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:-}}
@@ -35,27 +44,33 @@ token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}}
case "$url" in *'${'*) url="" ;; esac
case "$token" in *'${'*) token="" ;; esac
[ -n "$url" ] && [ -n "$token" ] || exit 0
# Resolve the working repo's remote so the server can map it to a project. Use
# the session's project dir when provided, else the current dir. No remote (or
# not a git repo) → omit; the server returns standing rules only.
if command -v curl >/dev/null 2>&1 && [ -n "$url" ] && [ -n "$token" ]; 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) || exit 0
"${url%/}/api/plugin/context${q}" 2>/dev/null) || body=""
if [ -n "$body" ]; then
dyn=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || dyn=""
if [ -n "$dyn" ]; then
if [ -n "$out" ]; then
out="${out}"$'\n\n---\n\n'"${dyn}"
else
out="$dyn"
fi
fi
fi
fi
text=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0
[ -n "$text" ] || exit 0
# Nothing to inject (no static file AND no dynamic context) → stay silent.
[ -n "$out" ] || exit 0
jq -n --arg c "$text" \
jq -n --arg c "$out" \
'{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $c}}'
exit 0
+26
View File
@@ -0,0 +1,26 @@
# Scribe — your second brain and system of record
This environment has the **Scribe** plugin: the operator's self-hosted second
brain (notes, tasks, projects, milestones, rules) reachable through the
`scribe` MCP tools. Treat Scribe — **not local files** — as the source of truth
for the operator's work, and as your own working memory across sessions.
**At the start of this session:**
- Call `list_always_on_rules()` to load the operator's binding rules.
- If the working repo maps to a Scribe project (check `list_repo_bindings`),
call `enter_project(<id>)` to load that project's rules, open tasks, and
recent notes in one shot.
**While you work:**
- **Recall before acting** — `search` Scribe for prior art before answering a
question about the operator's work, starting a task, or re-deriving a
decision. Assume a related note, task, or decision already exists.
- **Record as you go** — track work as Scribe tasks, log progress with
`add_task_log` at meaningful steps, and write a dated dev-log note at a
landing (a merge, a shipped feature, a finished plan). Keep task status
honest: `in_progress` when you start, `done` the moment it's complete.
- Do **not** keep the operator's rules, plans, or project notes in local
memory / CLAUDE.md in parallel with Scribe — Scribe holds the single copy.
If the Scribe tools are unavailable, say so rather than silently falling back
to local notes.