diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index a486db8..b0fd4a3 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "scribe", "description": "Scribe system-of-record 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, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.", - "version": "0.1.21", + "version": "0.1.22", "author": { "name": "Bryan Van Deusen" }, "mcpServers": { "scribe": { diff --git a/plugin/hooks/scribe_prior_art.sh b/plugin/hooks/scribe_prior_art.sh index 1b79998..027315b 100755 --- a/plugin/hooks/scribe_prior_art.sh +++ b/plugin/hooks/scribe_prior_art.sh @@ -48,16 +48,9 @@ case "$file_path" in exit 0 ;; esac -url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}} -token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}} -# Guard against an unexpanded ${...} placeholder arriving as a literal. -case "$url" in *'${'*) url="" ;; esac -case "$token" in *'${'*) token="" ;; esac -# Unconfigured install → silent. Prior-art recall is pure enrichment. -[ -n "$url" ] && [ -n "$token" ] || exit 0 - # Snippet locations are recorded repo-relative, so send a repo-relative path — -# an absolute one would simply match nothing. +# an absolute one would simply match nothing. Resolved BEFORE the config gate +# because the local arm below needs the repo root and needs no server at all. lookup_dir=$(dirname -- "$file_path" 2>/dev/null || true) [ -d "$lookup_dir" ] || lookup_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}} repo_root=$(git -C "$lookup_dir" rev-parse --show-toplevel 2>/dev/null || true) @@ -68,6 +61,84 @@ if [ -n "$repo_root" ]; then esac fi +# --------------------------------------------------------------------------- +# ARM 1 — BY NAME, LOCALLY (#2280). Does a definition of this already exist? +# +# The other two arms ask Scribe what was RECORDED. Scribe has never read a line +# of the codebase, so a helper nobody thought to record is invisible to them — +# which is how `.btn-primary` came to be defined four times, in four scoped +# stylesheets, already diverged. It was never a snippet, so no threshold and no +# query rewrite could ever have surfaced it. +# +# This arm closes that by asking the only question the record cannot answer, +# in the only place that can: the hook already runs on the developer's machine, +# inside the repo, holding the code about to be written. No index, no storage, +# no staleness, and no server — it deliberately runs even on an install that +# has never configured Scribe. +# +# Definition-shaped patterns only. Grepping for bare occurrences would match +# every CALL site and drown the real finding — and a hint that is mostly noise +# is one people learn to skip, which is worse than none. +# --------------------------------------------------------------------------- +local_lines="" +if [ -n "$repo_root" ] && [ -n "$code" ]; then + # kindname for each thing this payload DEFINES. + names=$(printf '%s' "$code" | awk ' + match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/) { + t = $0; sub(/^[[:space:]]*\./, "", t); sub(/[[:space:]]*[,{].*$/, "", t); + if (t != "") print "css\t" t; next } + match($0, /^[[:space:]]*(export[[:space:]]+)?(default[[:space:]]+)?(async[[:space:]]+)?function[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/) { + t = $0; sub(/^.*function[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_$].*$/, "", t); + if (t != "") print "sym\t" t; next } + match($0, /^[[:space:]]*(export[[:space:]]+)?class[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/) { + t = $0; sub(/^.*class[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_$].*$/, "", t); + if (t != "") print "sym\t" t; next } + match($0, /^[[:space:]]*(async[[:space:]]+)?def[[:space:]]+[A-Za-z_][A-Za-z0-9_]*/) { + t = $0; sub(/^.*def[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_].*$/, "", t); + if (t != "") print "sym\t" t; next } + match($0, /^[[:space:]]*(export[[:space:]]+)?(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/) { + t = $0; sub(/^[[:space:]]*(export[[:space:]]+)?(const|let)[[:space:]]+/, "", t); + sub(/[^A-Za-z0-9_$].*$/, "", t); + if (t != "") print "sym\t" t; next } + ' 2>/dev/null | sort -u | head -12) || names="" + + while IFS=$'\t' read -r kind name; do + [ -n "${name:-}" ] || continue + case "$kind" in + css) pat="^[[:space:]]*\.${name}[[:space:]]*[,{]" ;; + *) pat="(function|class|def)[[:space:]]+${name}[^A-Za-z0-9_]|(const|let)[[:space:]]+${name}[[:space:]]*=" ;; + esac + # -I skips binaries; :(exclude) drops the file being written, which would + # otherwise always match itself on an Edit. + hits=$(git -C "$repo_root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel_path}" 2>/dev/null | head -4) || hits="" + [ -n "$hits" ] || continue + count=$(printf '%s\n' "$hits" | grep -c . 2>/dev/null || echo 0) + label=$([ "$kind" = css ] && printf '.%s' "$name" || printf '%s' "$name") + files=$(printf '%s' "$hits" | tr '\n' ' ' | sed 's/ $//') + local_lines="${local_lines}> - \`${label}\` is already defined in ${count} other file(s): ${files}"$'\n' + done <<< "$names" +fi + +local_context="" +if [ -n "$local_lines" ]; then + local_context="> Already defined elsewhere in this repo — check before adding another copy (\`git grep\` shown; this is a nudge, not a gate):"$'\n'"${local_lines}" +fi + +url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}} +token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}} +# Guard against an unexpanded ${...} placeholder arriving as a literal. +case "$url" in *'${'*) url="" ;; esac +case "$token" in *'${'*) token="" ;; esac +# Unconfigured install → the recorded-prior-art arms are skipped, but the local +# arm above already ran and may have something to say. +if [ -z "$url" ] || [ -z "$token" ]; then + if [ -n "$local_context" ]; then + jq -n --arg c "$local_context" \ + '{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $c}}' + fi + exit 0 +fi + # Cap the code sent as the semantic query. The embedder truncates at its own # token limit well before this, so a bigger slice buys no extra signal — and the # payload has to stay a GET (a read-scoped API key cannot POST, and every other @@ -110,20 +181,32 @@ if [ -n "$session_id" ]; then fi fi +# `|| true`, not `|| exit 0`: an unreachable instance must not discard a local +# finding that needed no instance to produce. body=$(curl -fsS --max-time 5 \ -H "Authorization: Bearer ${token}" \ - "${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}" 2>/dev/null) || exit 0 -[ -n "$body" ] || exit 0 + "${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}" 2>/dev/null) || body="" -context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0 -[ -n "$context" ] || exit 0 - -# Remember what was surfaced so it isn't shown again this session. -if [ -n "$idfile" ]; then - printf '%s' "$body" | jq -r '.note_ids[]? // empty' 2>/dev/null >> "$idfile" || true +context="" +if [ -n "$body" ]; then + context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || context="" + # Remember what was surfaced so it isn't shown again this session. + if [ -n "$idfile" ] && [ -n "$context" ]; then + printf '%s' "$body" | jq -r '.note_ids[]? // empty' 2>/dev/null >> "$idfile" || true + fi fi +# Local first. It answers "this already EXISTS", which is a stronger claim than +# "this resembles something recorded" — and it is the one the recorded arms are +# structurally unable to make. +combined="$local_context" +if [ -n "$context" ]; then + [ -n "$combined" ] && combined="${combined}"$'\n' + combined="${combined}${context}" +fi +[ -n "$combined" ] || exit 0 + # No permissionDecision: this is a nudge, not a gate. The write goes ahead. -jq -n --arg c "$context" \ +jq -n --arg c "$combined" \ '{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $c}}' exit 0