51e5c22818
CI & Build / Plugin hooks (push) Failing after 2s
CI & Build / Python lint (push) Successful in 8s
CI & Build / integration (push) Successful in 31s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 20s
`plugin/` is not built into the image; installs fetch it from this repo via .claude-plugin/marketplace.json, so a push IS the release. It was absent from the workflow's `paths:` filter entirely, meaning plugin changes ran no CI at all. Two separate defects reached a live install through that gap: #2198 — all four hook scripts inert (lowercase userConfig env vars, line-oriented `jq -rR`, line-oriented `cut -c`) #2209 — the fix for #2198 couldn't reach an install because the manifest version wasn't bumped, so the installer never refreshed its cache Adds `plugin/**` + `.claude-plugin/**` to `paths:` and a `plugin` job running scripts/check_plugin.py: 1. `bash -n` on every hook. 2. The three known-bad patterns from #2198. Verified by replay against c569cdd^ — all three are caught. Narrow by design; see below. 3. Shipped plugin content differs from origin/main => the manifest version must differ too. Stated against the base branch, not per-commit, so a batch needs one bump rather than one per commit. Replayed againstc569cdd: correctly fails. The checker found a real outstanding bug on its first run: the `cut -c1-2000` prompt cap in scribe_autoinject.sh was still line-oriented. Only the prior-art hook's copy got fixed inc569cdd. Now `head -c`. It then failed on this very commit for a missing version bump, which is the third time that rule has mattered and the first time something other than memory enforced it. Manifest bumped to 0.1.20. WHAT THIS DOESN'T COVER, and why. shellcheck is the right tool for check 2 and is NOT in ci-python; nor is jq, which every hook requires and silently bails without — so a "runs and stays silent" smoke test would pass vacuously today and prove nothing. Both need those two packages added to the CI image in the CI-runner repo, which is a separate change to a separate repo (rule #5: the toolchain comes from the image, not from apt-get at job start). Verified against CI-runner's Dockerfile and scripts/install-common.sh rather than assumed (rule #37). `plugin` is not in the build job's `needs`: the plugin doesn't ship in the image, and blocking the build wouldn't un-publish a bad hook — the push already did. A failed job still reddens the run. Closes #2204 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
98 lines
4.4 KiB
Bash
Executable File
98 lines
4.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Scribe plugin — UserPromptSubmit push channel (knowledge auto-inject, Path A).
|
|
#
|
|
# On each user prompt, asks the operator's Scribe instance for a TITLE-FIRST
|
|
# awareness hint: the few notes that clear the per-user auto-inject gates
|
|
# (high-confidence threshold, margin gate, session dedup, top-k). Titles + ids
|
|
# only — never bodies; the agent calls get_note(id) to pull anything it judges
|
|
# relevant. Most turns inject nothing.
|
|
#
|
|
# Best-effort enrichment ONLY: unlike the SessionStart channel there is no
|
|
# static floor here. If the instance is unconfigured/unreachable, or anything
|
|
# fails, the hook stays SILENT and exits 0 — it must never block a prompt.
|
|
#
|
|
# Config (same as scribe_session_context.sh), exported to the hook by Claude Code
|
|
# with the userConfig key UPPERCASED (see #2198 — reading the lowercase spelling
|
|
# silently disables this hook, and silence is indistinguishable from "nothing
|
|
# cleared the threshold"):
|
|
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
|
|
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
|
|
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
|
|
#
|
|
# Session dedup: each surfaced note id is remembered in a per-session file so a
|
|
# note is injected at most once per session. Passed back as exclude_ids.
|
|
set -uo pipefail
|
|
|
|
command -v jq >/dev/null 2>&1 || exit 0
|
|
command -v curl >/dev/null 2>&1 || exit 0
|
|
|
|
# UserPromptSubmit delivers a JSON event on stdin: { prompt, session_id, cwd, ... }
|
|
event=$(cat 2>/dev/null || true)
|
|
prompt=$(printf '%s' "$event" | jq -r '.prompt // empty' 2>/dev/null) || prompt=""
|
|
session_id=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || session_id=""
|
|
event_cwd=$(printf '%s' "$event" | jq -r '.cwd // empty' 2>/dev/null) || event_cwd=""
|
|
|
|
# Nothing to retrieve against.
|
|
[ -n "$prompt" ] || exit 0
|
|
|
|
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 (auto-inject is pure enrichment).
|
|
[ -n "$url" ] && [ -n "$token" ] || exit 0
|
|
|
|
# Cap the query length — a giant prompt makes a giant URL for no extra signal.
|
|
# `head -c`, not `cut -c1-2000`: cut is line-oriented and caps EACH LINE, so a
|
|
# long multi-line prompt sailed past the budget entirely. Same defect as the
|
|
# prior-art hook's code cap; this copy was missed when that one was fixed, and
|
|
# scripts/check_plugin.py caught it.
|
|
q=$(printf '%s' "$prompt" | head -c 2000)
|
|
# `-sRr`, not `-rR`: jq -R reads LINE BY LINE, so a multi-line prompt encoded as
|
|
# several lines joined by raw newlines and the request died. Single-line prompts
|
|
# worked, which is why this looked healthy — the long, substantial prompts most
|
|
# worth retrieving against were exactly the ones silently dropped. -s slurps.
|
|
q_enc=$(printf '%s' "$q" | jq -sRr '@uri' 2>/dev/null) || exit 0
|
|
|
|
# Resolve the working repo's remote so the server can scope to the bound project.
|
|
repo_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
|
|
repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
|
|
repo_q=""
|
|
if [ -n "$repo" ]; then
|
|
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
|
|
[ -n "$enc" ] && repo_q="&repo=${enc}"
|
|
fi
|
|
|
|
# Per-session dedup: ids already injected this session are skipped.
|
|
state_dir="${TMPDIR:-/tmp}/scribe-autoinject"
|
|
mkdir -p "$state_dir" 2>/dev/null || true
|
|
idfile=""
|
|
exclude_q=""
|
|
if [ -n "$session_id" ]; then
|
|
# session_id is an opaque token from Claude Code; keep only filename-safe chars.
|
|
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
|
idfile="$state_dir/${safe_sid}.ids"
|
|
if [ -f "$idfile" ]; then
|
|
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
|
|
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
|
|
fi
|
|
fi
|
|
|
|
body=$(curl -fsS --max-time 5 \
|
|
-H "Authorization: Bearer ${token}" \
|
|
"${url%/}/api/plugin/retrieve?q=${q_enc}${repo_q}${exclude_q}" 2>/dev/null) || exit 0
|
|
[ -n "$body" ] || exit 0
|
|
|
|
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0
|
|
[ -n "$context" ] || exit 0
|
|
|
|
# Remember the surfaced ids so they aren't injected again this session.
|
|
if [ -n "$idfile" ]; then
|
|
printf '%s' "$body" | jq -r '.note_ids[]? // empty' 2>/dev/null >> "$idfile" || true
|
|
fi
|
|
|
|
jq -n --arg c "$context" \
|
|
'{hookSpecificOutput: {hookEventName: "UserPromptSubmit", additionalContext: $c}}'
|
|
exit 0
|