rename(547): the plugin is grimoire — manifest, MCP key, hooks, env, skills
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m2s
CI & Build / Python tests (push) Failing after 1m26s
CI & Build / Build & push image (push) Skipped

Step 3 of the Scribe -> Grimoire rename.

- Plugin `grimoire` in marketplace `grimoire-plugin`, MCP server key
  `grimoire`, so tools reach a session as mcp__plugin_grimoire_grimoire__*.
- hooks/scribe_* -> hooks/grimoire_*, their shell functions and the TMPDIR
  ledgers with them; SCRIBE_URL / SCRIBE_TOKEN / SCRIBE_HOOK_DIR /
  SCRIBE_LEDGER_DIRS -> GRIMOIRE_*; the project marker is `.grimoire`.
- skills/using-scribe -> using-grimoire, /grimoire:sync, stored processes
  arrive as grimoire-proc-<slug>; moment_actions names them the same way.
- Migration 0124 rewrites stored skill names (rule_moments,
  rule_moment_judgments, moment_mappings) from scribe:/scribe-proc- to
  grimoire:/grimoire-proc-, so a rule mounted on a skill keeps firing.
- Settings' install commands and MCP-only defaults, the docs and the
  tests follow.
- scripts/check_plugin.py drops the smoke event for a report-check hook
  that has no file in plugin/hooks.

Kept on purpose: the secret-sealing label, and the names that let backups
written as Scribe restore and be counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 10:52:55 -04:00
co-authored by Claude Opus 5.5
parent 0f5ef0be63
commit c5eeec471f
87 changed files with 1027 additions and 989 deletions
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env bash
# Grimoire plugin — SessionStart push channel (two tiers + compaction re-grounding).
#
# Tier 1 (STATIC, always fires, no auth, no network): injects the Claude Code
# adapter's own guidance (grimoire_static_context.md) — where Grimoire's reflexes
# are stated (the using-grimoire skill), Claude Code's memory files, /compact,
# /grimoire:sync, and what to do when Grimoire is unavailable. It carries only what
# this client needs said; the reflexes themselves are owned by the shared
# skills and the server's index.
#
# Tier 2 (DYNAMIC, best-effort enrichment): curls the operator's Grimoire instance
# for 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)
# NOTE THE CASE: Claude Code uppercases the userConfig key when exporting it, so
# the `api_token` option arrives as CLAUDE_PLUGIN_OPTION_API_TOKEN. Reading the
# lowercase spelling silently yields nothing, which disables the dynamic tier,
# auto-inject and the write-path trigger at once.
# 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 Grimoire. This stays the durable path: record-as-you-go
# plus a post-compaction reload from the instance.
#
# The other half: a host hook cannot make the model flush before a compaction,
# but grimoire_precompact_preserve.sh steers the
# SUMMARY, because a PreCompact hook's stdout becomes the compaction's custom
# instructions. The two are complementary, and neither replaces the other: that
# hook decides what survives into the summary, this one reloads from the record
# once the summary lands.
#
# IMPORTANT: do NOT pass config via `${user_config.*}` substitution in a
# shell-form hooks.json command — Claude Code rejects that outright (splicing a
# configured value into a shell command line would let the shell run whatever it
# contains). The env vars above are the supported channel; GRIMOIRE_URL /
# GRIMOIRE_TOKEN override for the settings.json dogfooding path.
#
# FAIL-OPEN, BUT NOT SILENT: the dynamic tier never blocks a session, and every
# way it can come up empty produces a short status line — failed fetch, missing
# token, and missing-everything alike. Nothing about the credential path is
# allowed to fail quietly; see the comment at the status block below.
set -uo pipefail
# shellcheck source=plugin/hooks/grimoire_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/grimoire_defs.sh"
# `CDPATH= cd` is deliberate, not a typo'd assignment: it runs this one `cd`
# with CDPATH empty, so an operator whose CDPATH happens to contain a matching
# directory name can't send us somewhere else — and `cd` won't echo the resolved
# path into our output. shellcheck can't tell that idiom from `CDPATH=cd`.
# shellcheck disable=SC1007
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)
event_flat=$(printf '%s' "$event" | grimoire_json_flat)
source=$(grimoire_json_pick "$event_flat" '.source')
# --- The rule ledger outlives the context it describes ---
#
# grimoire_prior_art.sh and grimoire_tool_rules.sh record every rule id they have
# named in <state>/<sid>.rules.ids and hand it back as exclude_rule_ids, so a
# rule is named once per session and then goes quiet. That is right while the
# session still HOLDS what it was told, and wrong the moment it does not.
#
# A compaction summarizes the earlier injections away and does not touch the
# filesystem, so the rule ends up absent from context AND still excluded —
# unreachable for the rest of the session. The banner below tells the model to
# re-pull its ALWAYS-ON rules, but a rule an arm surfaced is conditional and is
# not in that set, so it has no other way back. The rules most likely to be in
# this state are the ones that fire most often, which is to say the ones that
# apply most.
#
# The session id survives a compaction — the ledgers are keyed by it and are
# still found under the same name afterwards, so a stale ledger is genuinely
# reached again rather than orphaned.
#
# CLEARED ON THE SOURCES THAT DESTROY CONTEXT, AND ONLY THOSE:
#
# compact CLEAR — summarized away; the file survived.
# clear CLEAR — context wiped.
# startup nothing to do: a new session id means a new, empty file.
# resume KEEP. The context was genuinely restored, so the ledger still
# describes what the session holds. Clearing here would re-surface
# every rule after a restore that lost nothing — the mirror error.
# fork KEEP, and the answer is the same whichever way forks are keyed: a
# fork carries the conversation, so if it inherits the id the ledger
# is accurate, and if it gets a new one the file is empty anyway.
#
# EVERY LEDGER, AND THE NOTE ARMS NEED IT MOST:
#
# - They are HARD exclusions. `exclude_ids` goes into `semantic_search_notes`
# itself, so a surfaced note is removed from the result set — it is not
# rendered as a reference the way a repeated rule is. There is no weaker
# form for it to fall back to.
# - They never AGE. The rules ledger has a TTL because salience decays
# without a context event; the note channels are a flat read.
#
# Hard plus permanent plus never cleared would make a note surfaced in the
# first minute of a session unreachable for the rest of it, through any number
# of compactions.
#
# `grimoire_clear_session_ledgers` matches on the naming convention — a
# per-session ledger is `<sid>[.<kind>].ids` — so it covers every ledger,
# including one added later. Best-effort, like every other filesystem touch in these hooks:
# a ledger that cannot be removed costs a repeated exclusion, never a session.
#
# NOT swept: `<sid>.unreached`, which records that the instance was unreachable
# rather than what the session holds, and survives on purpose. The directories
# swept are `grimoire_defs.sh`'s `GRIMOIRE_LEDGER_DIRS` — plural, because the
# auto-inject arm keeps its ledger somewhere else.
case "$source" in
compact|clear)
sid=$(grimoire_json_pick "$event_flat" '.session_id')
if [ -n "$sid" ]; then
safe_sid=$(printf '%s' "$sid" | tr -c 'A-Za-z0-9._-' '_')
grimoire_clear_session_ledgers "$safe_sid"
fi
;;
esac
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/grimoire_static_context.md" ] && out=$(cat "$here/grimoire_static_context.md")
# --- Which version is actually RUNNING (keyless, networkless) ---
#
# An install has two halves and only one self-updates:
#
# marketplaces/…/grimoire-plugin/ git clone — pulls on its own
# cache/…/grimoire/<version>/ what EXECUTES — refreshed only when the
# manifest version changes
#
# So inspecting the clone shows a fix present while the broken copy keeps
# running, and the obvious debugging move actively misleads.
#
# Naming the running version in every session makes that answerable from the
# transcript instead of by archaeology in the cache directory. Deliberately NOT
# a server round-trip or a stored per-user record: the state most needing
# diagnosis is the one where credentials never arrive, and this line still
# appears there.
manifest="$here/../.claude-plugin/plugin.json"
if [ -f "$manifest" ]; then
plugin_version=$(grimoire_json_pick "$(grimoire_json_flat < "$manifest")" '.version')
if [ -n "$plugin_version" ]; then
append "> Grimoire plugin **v${plugin_version}** is executing in this session. A fix merged after this version has not reached it — the marketplace clone updates on its own, but the cache that runs only refreshes when the manifest version changes."
fi
fi
# --- Tier 2: dynamic rules + active-project context (best-effort) ---
# Unconfigured is NOT a failure here: tier 1's static floor is still owed,
# so this records the answer rather than acting on it.
grimoire_config || :
dyn=""
status=""
if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
# Which project is this directory's? A `.grimoire` marker first, then the git
# remote. grimoire_scope_query answers that, and is shared with the
# other five hooks so a directory scopes the same way everywhere; the pieces
# are re-read here only to explain what happened when nothing resolved.
repo_dir=${CLAUDE_PROJECT_DIR:-$PWD}
marker=$(grimoire_marker_file "$repo_dir")
marker_read=$(grimoire_marker_read "$marker")
marker_id=${marker_read%%$'\t'*}
marker_why=${marker_read#*$'\t'}
repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
scope=$(grimoire_scope_query "$repo_dir")
# The source and the session id decide what the claim section says: a
# compaction gets back the work this session had claimed,
# with its latest logs; a new session hears about other sessions' claims.
sid_now=$(grimoire_json_pick "$event_flat" '.session_id')
q="source=$(printf '%s' "$source" | grimoire_urlenc)"
[ -n "$sid_now" ] && q="${q}&session_id=$(printf '%s' "$sid_now" | grimoire_urlenc)"
[ -n "$scope" ] && q="${q}&${scope}"
q="?${q}"
# ONE FETCH, NAMED WHEN IT FAILS. A timeout, an HTTP error and a refused key
# need different fixes, so each gets its own sentence. curl's write-out still
# prints on a failed transfer (as `000`), so the status and the elapsed time
# come back either way.
fetch_context() {
local resp meta
resp=$(curl -sS --max-time "$1" -w '\n%{http_code} %{time_total}' \
-H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/context${q}" 2>/dev/null)
ctx_rc=$?
meta=${resp##*$'\n'}
body=${resp%$'\n'*}
[ "$body" = "$resp" ] && body=""
ctx_code=${meta%% *}
ctx_took=${meta#* }
}
# Deadlines: 8s for the first try, sized for a cold instance.
# ONE retry at 6s, and only for failures a second try can change — a
# timeout, a dropped connection, a 5xx. A 4xx is the key or the scope and
# will say the same thing twice. Worst case is ~14s of startup, against a
# whole session run without its project.
fetch_context 8
case "$ctx_rc:$ctx_code" in
0:2*|0:4*) ;;
*) fetch_context 6 ;;
esac
body_flat=""
if [ "$ctx_rc" = 0 ] && [ "${ctx_code#2}" != "$ctx_code" ] && [ -n "$body" ]; then
body_flat=$(printf '%s' "$body" | grimoire_json_flat)
dyn=$(grimoire_json_pick "$body_flat" '.context')
fi
if [ -z "$dyn" ]; then
if [ "$ctx_rc" = 28 ]; then
why="the instance did not answer in time (8s, then ${ctx_took}s on a retry)"
elif [ "$ctx_rc" != 0 ]; then
why="the instance could not be reached (curl exit ${ctx_rc}, after a retry)"
elif [ "$ctx_code" = 401 ] || [ "$ctx_code" = 403 ]; then
why="the API key was refused (HTTP ${ctx_code})"
elif [ "${ctx_code#2}" = "$ctx_code" ]; then
why="the instance answered HTTP ${ctx_code}"
else
why="the instance answered but sent no context"
fi
# The first move, stated as one rather than as optional: a session that
# skips it starts with no recent milestones or open tasks, so it cannot
# know what prior work exists to look for.
if [ -n "$marker_id" ] && [ -z "$marker_why" ]; then
first="Start by calling \`enter_project(${marker_id})\`"
else
first="Start by finding this repo's project with \`list_projects()\` and calling \`enter_project(<id>)\`"
fi
status="> ⚠️ Grimoire: live project context was not loaded this session — ${why}. The tools may still answer. ${first} before any other work: it loads the recent milestones and open tasks this session would otherwise begin without, and prior work you cannot see is work you will redo. The using-grimoire skill still applies."
fi
elif [ -n "$url" ] && [ -z "$token" ]; then
status="> ⚠️ Grimoire: live context disabled this session — the API key is not configured (Grimoire base URL is). Set it with \`/plugin\` → Grimoire → configure, or export GRIMOIRE_TOKEN. Tools still work; ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\`."
elif [ -z "$url" ] && [ -z "$token" ]; then
# NEITHER value arrived. Not a benign "unconfigured install": the plugin
# prompts for both values at enable time, so if neither reached the hook,
# something is wrong (an env-var casing mismatch looks exactly like this).
# Say so.
status="> ⚠️ Grimoire: live context disabled this session — neither the Grimoire base URL nor the API key reached this hook. Configure the plugin (\`/plugin\` → Grimoire), or export GRIMOIRE_URL + GRIMOIRE_TOKEN. Note this also disables prompt auto-inject and the write-path prior-art trigger. Tools still work; ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\`."
fi
[ -n "$dyn" ] && append "$dyn"
[ -n "$status" ] && append "$status"
# --- Nothing resolved: say WHICH nothing, and what would fix it ---
#
# The server can say "no project is bound to this working directory". It is
# the same sentence for a directory
# that is not a repo, a repo whose remote nobody bound, and a marker file
# naming a project this account cannot read — three different problems with
# three different fixes, and no way to tell them apart from inside the session.
#
# The marker is the adapter's convention, so the adapter explains it: the
# server reports whether a project resolved, and this hook — which knows what
# it sent and why — turns that into the sentence the operator can act on. The
# repo case is left to the server's existing "bind this repo" hint.
if [ -n "$dyn" ] && [ -z "$(grimoire_json_pick "$body_flat" '.project.id')" ]; then
host=$(grimoire_url_host "$url")
if [ -n "$marker_why" ]; then
append "> ⚠️ Grimoire: the marker file \`${marker}\` ${marker_why}, so no project context was loaded. Fix the file, or ignore it and bind this directory another way."
elif [ -n "$marker_id" ]; then
append "> ⚠️ Grimoire: \`${marker}\` names project ${marker_id}, which this account cannot read on ${host} — it may belong to a different Grimoire instance, or the project may have been deleted. Check with \`list_projects()\` and correct the file."
elif [ -z "$repo" ]; then
append "> ℹ️ Grimoire: this directory is not a git repository and has no \`.grimoire\` marker, so no project context was loaded — every hook this session is unscoped. If this work belongs to a Grimoire project, call \`list_projects()\` and write the marker: \`{\"instance\": \"${url%/}\", \"project_id\": <id>, \"project\": \"<title>\"}\` in \`${repo_dir}/.grimoire\`. Future sessions here load that project on their own."
fi
fi
# 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. Any rules that had been retrieved went into that summary with everything else, so treat yourself as holding none: before the next consequential act, ask again with \`search(content_type=\"rule\")\` rather than trusting a half-remembered one. Re-run \`enter_project()\` for the active project, check its recent milestones and open tasks, and reconcile what you are mid-way through against what Grimoire records. Grimoire is the record."
fi
# Nothing at all to inject → stay silent.
[ -n "$out" ] || exit 0
grimoire_json_out SessionStart "$out"
exit 0