diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 4cae889..56f4e7b 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -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": { diff --git a/plugin/hooks/scribe_session_context.sh b/plugin/hooks/scribe_session_context.sh index 772c4ba..c9c90b9 100755 --- a/plugin/hooks/scribe_session_context.sh +++ b/plugin/hooks/scribe_session_context.sh @@ -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_ 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. -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}" +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) || 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 -body=$(curl -fsS --max-time 8 \ - -H "Authorization: Bearer ${token}" \ - "${url%/}/api/plugin/context${q}" 2>/dev/null) || exit 0 +# Nothing to inject (no static file AND no dynamic context) → stay silent. +[ -n "$out" ] || exit 0 -text=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0 -[ -n "$text" ] || exit 0 - -jq -n --arg c "$text" \ +jq -n --arg c "$out" \ '{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $c}}' exit 0 diff --git a/plugin/hooks/scribe_static_context.md b/plugin/hooks/scribe_static_context.md new file mode 100644 index 0000000..999fe39 --- /dev/null +++ b/plugin/hooks/scribe_static_context.md @@ -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()` 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.