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
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "scribe",
"description": "Grimoire for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Grimoire skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting, family-canon), and syncs your saved Grimoire Processes as skills (/scribe:sync).",
"name": "grimoire",
"description": "Grimoire for Claude Code: connects the grimoire MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Grimoire skills (using-grimoire, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting, family-canon), and syncs your saved Grimoire Processes as skills (/grimoire:sync).",
"version": "2026.10.21.2344",
"author": {
"name": "Bryan Van Deusen"
},
"mcpServers": {
"scribe": {
"grimoire": {
"type": "http",
"url": "${user_config.api_endpoint}/mcp",
"headers": {
@@ -18,7 +18,7 @@
"api_endpoint": {
"type": "string",
"title": "Grimoire base URL",
"description": "Base URL of your Grimoire instance, no trailing slash (e.g. https://scribe.example.com)"
"description": "Base URL of your Grimoire instance, no trailing slash (e.g. https://grimoire.example.com)"
},
"api_token": {
"type": "string",
+18 -18
View File
@@ -31,27 +31,27 @@ another one means adding files, not moving or rewriting any.
| File | Role |
|---|---|
| `.claude-plugin/plugin.json` | Manifest: `mcpServers.scribe` (HTTP, `Authorization: Bearer ${user_config.api_token}`), `userConfig` for the base URL and key, version (minted, never hand-edited; see README). |
| `.claude-plugin/plugin.json` | Manifest: `mcpServers.grimoire` (HTTP, `Authorization: Bearer ${user_config.api_token}`), `userConfig` for the base URL and key, version (minted, never hand-edited; see README). |
| `../.claude-plugin/marketplace.json` (repo root) | The marketplace entry pointing at `./plugin`. |
| `hooks/hooks.json` | Wires the scripts to Claude Code events. |
| `hooks/scribe_session_context.sh` | SessionStart: adapter static text + running version + `GET /api/plugin/context`; a reload banner after compaction. |
| `hooks/scribe_autoinject.sh` | UserPromptSubmit: `GET /api/plugin/retrieve` for the message. |
| `hooks/scribe_prior_art.sh` | PreToolUse on editor writes: `GET /api/plugin/prior-art`. |
| `hooks/scribe_after_write.sh` | PostToolUse on shell commands: the same check for code written through the shell. |
| `hooks/scribe_tool_rules.sh` | PreToolUse on shell commands: `GET /api/plugin/tool-rules`. |
| `hooks/scribe_moment.sh` | PreToolUse on every tool: the rules mounted on the moments the call reaches, `POST /api/plugin/moment`; skips tools `GET /api/plugin/moment-tools` says reach nothing mounted, and Grimoire's own (their responses carry `moment_rules`). |
| `hooks/scribe_reply_check.sh` | Stop: sends the finished reply to `POST /api/plugin/reply-rules` — the rules mounted on the reply moments, and the reply against every rule's trigger, and — when the turn closed a task — the completion-section check; holds once per rule, with the reason the server returns, and never holds the rewrite. |
| `hooks/scribe_shape_check.sh` | Stop: sends the definitions the turn wrote (the write hooks' `<sid>.written.ids` ledger) to `GET /api/plugin/shape-check`; blocks once, with the reason the server returns, so the agent judges what it built. |
| `hooks/scribe_record_opened.sh` | PostToolUse on `get_rule`: notes the rule as read in the session ledger and `POST /api/plugin/rule-opened`. |
| `hooks/scribe_record_outcome.sh` | PostToolUse on `rule_outcome`: notes the rule as acted on, locally, for the session-scoped readout. |
| `hooks/scribe_claim_session.sh` | PostToolUse on `update_task` / `add_task_log`: `GET /api/plugin/claim-session`, so the task comes back after a compaction. |
| `hooks/scribe_precompact_preserve.sh` | PreCompact: tells the summarizer what to keep literally. Local, and never blocks. |
| `hooks/scribe_session_end.sh` | SessionEnd: `GET /api/plugin/release-session` releases the session's claims. |
| `hooks/scribe_sync_processes.sh` + `commands/sync.md` | `GET /api/plugin/processes` → `~/.claude/skills/scribe-proc-*` stubs; `/scribe:sync` on demand. |
| `hooks/scribe_defs.sh` | Shared shell helpers: config, dedup ledgers, outage line. |
| `hooks/scribe_static_context.md` | The adapter static text. |
| `hooks/grimoire_session_context.sh` | SessionStart: adapter static text + running version + `GET /api/plugin/context`; a reload banner after compaction. |
| `hooks/grimoire_autoinject.sh` | UserPromptSubmit: `GET /api/plugin/retrieve` for the message. |
| `hooks/grimoire_prior_art.sh` | PreToolUse on editor writes: `GET /api/plugin/prior-art`. |
| `hooks/grimoire_after_write.sh` | PostToolUse on shell commands: the same check for code written through the shell. |
| `hooks/grimoire_tool_rules.sh` | PreToolUse on shell commands: `GET /api/plugin/tool-rules`. |
| `hooks/grimoire_moment.sh` | PreToolUse on every tool: the rules mounted on the moments the call reaches, `POST /api/plugin/moment`; skips tools `GET /api/plugin/moment-tools` says reach nothing mounted, and Grimoire's own (their responses carry `moment_rules`). |
| `hooks/grimoire_reply_check.sh` | Stop: sends the finished reply to `POST /api/plugin/reply-rules` — the rules mounted on the reply moments, and the reply against every rule's trigger, and — when the turn closed a task — the completion-section check; holds once per rule, with the reason the server returns, and never holds the rewrite. |
| `hooks/grimoire_shape_check.sh` | Stop: sends the definitions the turn wrote (the write hooks' `<sid>.written.ids` ledger) to `GET /api/plugin/shape-check`; blocks once, with the reason the server returns, so the agent judges what it built. |
| `hooks/grimoire_record_opened.sh` | PostToolUse on `get_rule`: notes the rule as read in the session ledger and `POST /api/plugin/rule-opened`. |
| `hooks/grimoire_record_outcome.sh` | PostToolUse on `rule_outcome`: notes the rule as acted on, locally, for the session-scoped readout. |
| `hooks/grimoire_claim_session.sh` | PostToolUse on `update_task` / `add_task_log`: `GET /api/plugin/claim-session`, so the task comes back after a compaction. |
| `hooks/grimoire_precompact_preserve.sh` | PreCompact: tells the summarizer what to keep literally. Local, and never blocks. |
| `hooks/grimoire_session_end.sh` | SessionEnd: `GET /api/plugin/release-session` releases the session's claims. |
| `hooks/grimoire_sync_processes.sh` + `commands/sync.md` | `GET /api/plugin/processes` → `~/.claude/skills/grimoire-proc-*` stubs; `/grimoire:sync` on demand. |
| `hooks/grimoire_defs.sh` | Shared shell helpers: config, dedup ledgers, outage line. |
| `hooks/grimoire_static_context.md` | The adapter static text. |
Hook config arrives as `CLAUDE_PLUGIN_OPTION_API_ENDPOINT` / `CLAUDE_PLUGIN_OPTION_API_TOKEN` (uppercased by Claude Code), with `SCRIBE_URL` / `SCRIBE_TOKEN` as an override.
Hook config arrives as `CLAUDE_PLUGIN_OPTION_API_ENDPOINT` / `CLAUDE_PLUGIN_OPTION_API_TOKEN` (uppercased by Claude Code), with `GRIMOIRE_URL` / `GRIMOIRE_TOKEN` as an override.
## How other clients would map
+22 -22
View File
@@ -4,7 +4,7 @@ Turns a self-hosted [Grimoire](https://git.fabledsword.com/bvandeusen/FabledScri
instance into a first-class Claude Code extension:
- **MCP tools** over your notes, tasks, projects, milestones, systems, and
rulebook (the `scribe` server).
rulebook (the `grimoire` server).
- **Session-start push channel** — a `SessionStart` hook injects the
active project's live state (from the server) and this adapter's short
Claude Code guidance, so Grimoire surfaces *without being asked*. Rules are
@@ -14,15 +14,15 @@ instance into a first-class Claude Code extension:
path, and what resembles the code) and offers them before the helper is
rewritten. Titles only, never blocks the edit.
- **The shared Grimoire skills** — client-neutral Agent Skills, the same files
any client's package would ship: using-scribe, writing-plans, reporting-back
any client's package would ship: using-grimoire, writing-plans, reporting-back
(reply to the operator in a shape that says where the work stands),
systematic-debugging, verification, brainstorming, reusing-code (record and
recall reusable code as snippets), shape-accounting and family-canon.
Replaces superpowers.
- **Your Grimoire Processes as skills** — saved Processes are synced into local
`~/.claude/skills/scribe-proc-*` stubs that auto-surface by relevance; the
`~/.claude/skills/grimoire-proc-*` stubs that auto-surface by relevance; the
stub fetches the live procedure via `get_process`. Refreshed each session and
on demand with `/scribe:sync`.
on demand with `/grimoire:sync`.
It is designed so you can uninstall `superpowers` and depend on Grimoire instead
of auto-memory — leave auto-memory at its default; Grimoire replaces its job by
@@ -31,8 +31,8 @@ holding the one copy, not by switching it off.
**How the pieces divide the work**: the Grimoire server orients
every MCP client and serves live state; the skills in `skills/` state every
reflex in full and name no client; this plugin is the Claude Code adapter —
hooks that deliver at the right moment, `/scribe:sync`, and the few things only
Claude Code needs said (`hooks/scribe_static_context.md`). Packaging Grimoire for
hooks that deliver at the right moment, `/grimoire:sync`, and the few things only
Claude Code needs said (`hooks/grimoire_static_context.md`). Packaging Grimoire for
another client: see [PACKAGING.md](PACKAGING.md).
## Install
@@ -42,25 +42,25 @@ you always get the plugin version that matches your Grimoire instance.
```
/plugin marketplace add https://git.fabledsword.com/bvandeusen/FabledScribe.git
/plugin install scribe@scribe-plugin
/plugin install grimoire@grimoire-plugin
```
On install you'll be asked for:
| Setting | What |
|---|---|
| **Grimoire base URL** | e.g. `https://scribe.example.com` (no trailing slash) |
| **Grimoire base URL** | e.g. `https://grimoire.example.com` (no trailing slash) |
| **Grimoire API key** | a write-scoped `fmcp_` key from **Settings → MCP Access** (stored in your OS keychain) |
| **Active project id** | optional — numeric project id to scope the session-start context |
## What gets wired
- `plugin.json` `mcpServers` → the `scribe` MCP server at `<base URL>/mcp` (Bearer auth).
- `hooks/hooks.json` → SessionStart hook (`hooks/scribe_session_context.sh`),
- `plugin.json` `mcpServers` → the `grimoire` MCP server at `<base URL>/mcp` (Bearer auth).
- `hooks/hooks.json` → SessionStart hook (`hooks/grimoire_session_context.sh`),
**fail-open**: if Grimoire is unreachable it injects nothing and never blocks
the session.
- `hooks/hooks.json` → PreToolUse hook on `Write|Edit`
(`hooks/scribe_prior_art.sh`) → `GET /api/plugin/prior-art`. Returns
(`hooks/grimoire_prior_art.sh`) → `GET /api/plugin/prior-art`. Returns
`additionalContext` with **no** permission decision, so it can inform the write
but never stop it; silent when nothing is recorded, which is most of the time.
Two framings: a REUSE menu (similar/nearby records), and a SYNC nudge when a
@@ -74,12 +74,12 @@ On install you'll be asked for:
checked"; an answer clears the marker. The local by-name arm needs no
server and always runs. Toggle in **Settings → General → Knowledge
auto-inject**.
- `hooks/hooks.json` → UserPromptSubmit hook (`hooks/scribe_autoinject.sh`) →
- `hooks/hooks.json` → UserPromptSubmit hook (`hooks/grimoire_autoinject.sh`) →
`GET /api/plugin/retrieve`: the rules, preferences and records that match the
message, as a short menu of titles and matched passages.
- `hooks/hooks.json` → PreToolUse hook on `Bash` (`hooks/scribe_tool_rules.sh`)
- `hooks/hooks.json` → PreToolUse hook on `Bash` (`hooks/grimoire_tool_rules.sh`)
→ `GET /api/plugin/tool-rules`: the rules that match the command about to run.
- `hooks/hooks.json` → PreToolUse hook on every tool (`hooks/scribe_moment.sh`)
- `hooks/hooks.json` → PreToolUse hook on every tool (`hooks/grimoire_moment.sh`)
→ `POST /api/plugin/moment`: the rules mounted on the moments the call
reaches. It skips tools that `GET /api/plugin/moment-tools` says reach
nothing mounted, and Grimoire's own tools, whose responses carry
@@ -90,7 +90,7 @@ On install you'll be asked for:
`POST /api/plugin/rule-opened`), and claim the task being worked
(`GET /api/plugin/claim-session`) so it comes back after a compaction.
- `hooks/hooks.json` → PostToolUse hook on `Bash`
(`hooks/scribe_after_write.sh`): code written through sed/heredocs/scripts
(`hooks/grimoire_after_write.sh`): code written through sed/heredocs/scripts
never reaches the PreToolUse hook, so this one diffs the working tree after
every Bash call (per-session path+blob snapshot; one `git status` when
nothing changed) and runs the same arms on the definitions just written,
@@ -98,8 +98,8 @@ On install you'll be asked for:
only; never blocks, and shares the pre-write hook's once-per-outage "did not
answer" line (8 s budget here — it runs after the tool, so it gates
nothing). The extractor, the prose/data skip list, the local by-name
duplicate arm and the outage line are shared in `hooks/scribe_defs.sh`.
- `hooks/hooks.json` → Stop hook (`hooks/scribe_reply_check.sh`): sends the
duplicate arm and the outage line are shared in `hooks/grimoire_defs.sh`.
- `hooks/hooks.json` → Stop hook (`hooks/grimoire_reply_check.sh`): sends the
reply that ends the turn to `POST /api/plugin/reply-rules`, which holds it
once for an unopened rule mounted on the reply moments or matching the reply.
When the turn closed a Grimoire task (`update_task`/`create_task` with status
@@ -109,7 +109,7 @@ On install you'll be asked for:
the rewrite came out; it never blocks twice, and never blocks when the
instance did not record the check (unconfigured or unreachable). Outcomes
land in the admin logs under category `plugin`, action `report_check`.
- `hooks/hooks.json` → a second Stop hook (`hooks/scribe_shape_check.sh`): the
- `hooks/hooks.json` → a second Stop hook (`hooks/grimoire_shape_check.sh`): the
write hooks note every definition a write names (and every new file) in a
session ledger; at the end of the turn this sends them to
`GET /api/plugin/shape-check`, which answers with the ones nobody has judged.
@@ -119,14 +119,14 @@ On install you'll be asked for:
as the report check: never twice, never without a recorded check. Outcomes
land under category `plugin`, action `shape_check`.
- `skills/` → the universal process-skills, surfaced by description match.
- `hooks/scribe_sync_processes.sh` (a 2nd SessionStart hook) + the `/scribe:sync`
command → generate `~/.claude/skills/scribe-proc-*` stubs from your Grimoire
- `hooks/grimoire_sync_processes.sh` (a 2nd SessionStart hook) + the `/grimoire:sync`
command → generate `~/.claude/skills/grimoire-proc-*` stubs from your Grimoire
Processes (via `GET /api/plugin/processes`); also **fail-open**, and pruned to
match what exists in Grimoire.
- `hooks/hooks.json` → PreCompact hook (`hooks/scribe_precompact_preserve.sh`):
- `hooks/hooks.json` → PreCompact hook (`hooks/grimoire_precompact_preserve.sh`):
tells the summarizer which parts of the conversation to keep literally. Local
only, and it never blocks, because a blocked compaction is skipped outright.
- `hooks/hooks.json` → SessionEnd hook (`hooks/scribe_session_end.sh`) →
- `hooks/hooks.json` → SessionEnd hook (`hooks/grimoire_session_end.sh`) →
`GET /api/plugin/release-session`: releases the session's task claims.
## Notes
+3 -3
View File
@@ -4,7 +4,7 @@ allowed-tools: Bash(bash:*), Bash(ls:*)
---
Regenerate the local skill stubs for your Grimoire **Processes** so they auto-surface
this session. Each Process becomes a `~/.claude/skills/scribe-proc-<slug>/SKILL.md`
this session. Each Process becomes a `~/.claude/skills/grimoire-proc-<slug>/SKILL.md`
whose body calls `get_process(<name>)` for the live procedure.
This normally runs automatically at session start; use it after adding or editing
@@ -14,7 +14,7 @@ skill files within this session — no restart needed).
Run the bundled sync script, then list what was synced:
```
bash "${CLAUDE_PLUGIN_ROOT}/hooks/scribe_sync_processes.sh" && ls ~/.claude/skills 2>/dev/null | grep '^scribe-proc-' || echo "no Grimoire process skills (no Processes, or Grimoire unreachable)"
bash "${CLAUDE_PLUGIN_ROOT}/hooks/grimoire_sync_processes.sh" && ls ~/.claude/skills 2>/dev/null | grep '^grimoire-proc-' || echo "no Grimoire process skills (no Processes, or Grimoire unreachable)"
```
Report which `scribe-proc-*` skills are now present.
Report which `grimoire-proc-*` skills are now present.
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Grimoire plugin — PostToolUse write-path trigger on Bash.
#
# scribe_prior_art.sh fires before a Write/Edit TOOL CALL. Code written any
# grimoire_prior_art.sh fires before a Write/Edit TOOL CALL. Code written any
# other way — sed, heredocs, python edit scripts, `cat > file` — never reaches
# it, and those are the edits a long session makes most. So after EVERY Bash
# call it asks git what changed in the working
@@ -16,9 +16,9 @@
# Cheap when nothing changed: one `git status`. State per session, beside the
# pre-write hook's (its three dedup channels are SHARED, so a family named by
# one hook is not named again by the other):
# ${TMPDIR:-/tmp}/scribe-afterwrite/<sid>.snap path<TAB>blob-hash of every
# ${TMPDIR:-/tmp}/grimoire-afterwrite/<sid>.snap path<TAB>blob-hash of every
# dirty/untracked file last seen
# ${TMPDIR:-/tmp}/scribe-priorart/<sid>.* the dedup channels
# ${TMPDIR:-/tmp}/grimoire-priorart/<sid>.* the dedup channels
#
# NEVER BLOCKS. It returns `additionalContext` only (no decision — there is
# nothing left to decide, the write already happened). Any failure —
@@ -27,27 +27,27 @@
# Config (same as the other hooks):
# 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.
# GRIMOIRE_URL / GRIMOIRE_TOKEN override for the settings.json dogfooding path.
set -uo pipefail
command -v git >/dev/null 2>&1 || exit 0
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# shellcheck source=plugin/hooks/grimoire_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/grimoire_defs.sh"
# PostToolUse delivers { session_id, cwd, tool_name, tool_input, tool_response }.
event=$(cat 2>/dev/null || true)
event_flat=$(printf '%s' "$event" | scribe_json_flat)
tool_name=$(scribe_json_pick "$event_flat" '.tool_name')
event_flat=$(printf '%s' "$event" | grimoire_json_flat)
tool_name=$(grimoire_json_pick "$event_flat" '.tool_name')
[ "$tool_name" = "Bash" ] || exit 0
session_id=$(scribe_json_pick "$event_flat" '.session_id')
event_cwd=$(scribe_json_pick "$event_flat" '.cwd')
session_id=$(grimoire_json_pick "$event_flat" '.session_id')
event_cwd=$(grimoire_json_pick "$event_flat" '.cwd')
work_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
repo_root=$(git -C "$work_dir" rev-parse --show-toplevel 2>/dev/null) || exit 0
[ -n "$repo_root" ] || exit 0
safe_sid=$(printf '%s' "${session_id:-nosession}" | tr -c 'A-Za-z0-9._-' '_')
snap_dir="${TMPDIR:-/tmp}/scribe-afterwrite"
snap_dir="${TMPDIR:-/tmp}/grimoire-afterwrite"
mkdir -p "$snap_dir" 2>/dev/null || true
snap="$snap_dir/${safe_sid}.snap"
@@ -98,18 +98,18 @@ while IFS=$'\t' read -r path sha; do
*"${path}"$'\t'"${sha}"*) continue ;;
esac
fi
scribe_skip_path "$path" && continue
grimoire_skip_path "$path" && continue
changed="${changed}${path}"$'\n'
done <<< "$current"
[ -n "$changed" ] || exit 0
scribe_config || : # sets url/token; the call below is guarded on them
scope=$(scribe_scope_query "$repo_root")
grimoire_config || : # sets url/token; the call below is guarded on them
scope=$(grimoire_scope_query "$repo_root")
repo_q=""
[ -n "$scope" ] && repo_q="&${scope}"
# The dedup channels are the PRE-write hook's files, on purpose (see header).
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
state_dir="${TMPDIR:-/tmp}/grimoire-priorart"
mkdir -p "$state_dir" 2>/dev/null || true
idfile="$state_dir/${safe_sid}.ids"
syncfile="$state_dir/${safe_sid}.sync.ids"
@@ -137,15 +137,15 @@ while IFS= read -r rel_path; do
fi
[ -n "$code" ] || continue
# `|| true` inside: an early-exiting `head` must not void its own output.
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12 || true)
names=$(printf '%s' "$code" | grimoire_defs | sort -u | head -12 || true)
# What this write defined, for the end-of-turn question —
# recorded before the arms below, which skip a write that defines nothing.
printf '%s\n' "$names" | scribe_written_append "$safe_sid" "$rel_path" "$fresh" "$code"
printf '%s\n' "$names" | grimoire_written_append "$safe_sid" "$rel_path" "$fresh" "$code"
# Nothing DEFINED in what was written (prose, data, a call-site edit) →
# nothing to say; the arms are about shapes.
[ -n "$names" ] || continue
local_lines=$(scribe_local_dups "$repo_root" "$rel_path" <<< "$names") || local_lines=""
local_lines=$(grimoire_local_dups "$repo_root" "$rel_path" <<< "$names") || local_lines=""
local_context=""
if [ -n "$local_lines" ]; then
local_context="> Already defined elsewhere in this repo — \`${rel_path}\` (just written) adds another copy; check before keeping it (\`git grep\` shown; a nudge, not a gate):"$'\n'"${local_lines}"
@@ -157,12 +157,12 @@ while IFS= read -r rel_path; do
unreached_context=""
if [ -n "$url" ] && [ -n "$token" ]; then
q=$(printf '%s' "$code" | head -c 1200)
path_enc=$(printf '%s' "$rel_path" | scribe_urlenc) || path_enc=""
code_enc=$(printf '%s' "$q" | scribe_urlenc) || code_enc=""
path_enc=$(printf '%s' "$rel_path" | grimoire_urlenc) || path_enc=""
code_enc=$(printf '%s' "$q" | grimoire_urlenc) || code_enc=""
shapes_q=""
enc=$(printf '%s\n' "$names" \
| awk -F'\t' 'NF>=2 {printf "%s%s:%s", (n++?",":""), $1, $2}' \
| scribe_urlenc) || enc=""
| grimoire_urlenc) || enc=""
[ -n "$enc" ] && shapes_q="&shapes=${enc}"
exclude_q=""; sync_exclude_q=""; derive_exclude_q=""
if [ -f "$idfile" ]; then
@@ -174,7 +174,7 @@ while IFS= read -r rel_path; do
[ -n "$sync_seen" ] && sync_exclude_q="&exclude_sync_ids=${sync_seen}"
fi
if [ -f "$derivefile" ]; then
derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | scribe_urlenc) || derive_seen=""
derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | grimoire_urlenc) || derive_seen=""
[ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}"
fi
if [ -n "$path_enc" ]; then
@@ -190,19 +190,19 @@ while IFS= read -r rel_path; do
# shared marker with the pre-write hook, so one outage is one
# line however the code was written.
if [ "$reached" = 1 ]; then
scribe_reached "$state_dir" "$safe_sid"
grimoire_reached "$state_dir" "$safe_sid"
else
unreached_context=$(scribe_unreached "$state_dir" "$safe_sid" 8 "$rel_path")
unreached_context=$(grimoire_unreached "$state_dir" "$safe_sid" 8 "$rel_path")
fi
fi
body_flat=""
if [ -n "$body" ]; then
body_flat=$(printf '%s' "$body" | scribe_json_flat)
context=$(scribe_json_pick "$body_flat" '.context')
body_flat=$(printf '%s' "$body" | grimoire_json_flat)
context=$(grimoire_json_pick "$body_flat" '.context')
if [ -n "$context" ]; then
scribe_json_list_minus "$body_flat" '.note_ids' '.sync_note_ids' >> "$idfile" || true
scribe_json_list "$body_flat" '.sync_note_ids' >> "$syncfile" || true
scribe_json_list "$body_flat" '.derive_keys' >> "$derivefile" || true
grimoire_json_list_minus "$body_flat" '.note_ids' '.sync_note_ids' >> "$idfile" || true
grimoire_json_list "$body_flat" '.sync_note_ids' >> "$syncfile" || true
grimoire_json_list "$body_flat" '.derive_keys' >> "$derivefile" || true
# Several files in one call may name the same family: keep each
# token once, so the next request's exclude list stays exact.
for f in "$idfile" "$syncfile" "$derivefile"; do
@@ -217,7 +217,7 @@ while IFS= read -r rel_path; do
# that did not answer; "nothing recorded" is a claim only an answer
# can back.
if [ -n "$local_lines" ] && [ "$reached" != 0 ]; then
n_recorded=$(scribe_json_len "$body_flat" '.note_ids')
n_recorded=$(grimoire_json_len "$body_flat" '.note_ids')
if [ "${n_recorded:-0}" = "0" ] || [ "$n_recorded" = "" ]; then
local_context="${local_context}"$'\n'"> None of those existing copies is recorded in Grimoire. If the version just written is the canonical one — or this edit is consolidating the copies — record it now with create_snippet so the next session is offered it instead of writing another copy."
fi
@@ -238,5 +238,5 @@ while IFS= read -r rel_path; do
done <<< "$changed"
[ -n "$combined" ] || exit 0
scribe_json_out PostToolUse "$combined"
grimoire_json_out PostToolUse "$combined"
exit 0
@@ -23,20 +23,20 @@
# 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
# Config (same as grimoire_session_context.sh), exported to the hook by Claude Code
# with the userConfig key UPPERCASED (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.
# GRIMOIRE_URL / GRIMOIRE_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
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# shellcheck source=plugin/hooks/grimoire_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/grimoire_defs.sh"
command -v curl >/dev/null 2>&1 || exit 0
@@ -45,11 +45,11 @@ command -v curl >/dev/null 2>&1 || exit 0
# the largest payload any hook reads, and re-parsing it per field is three
# passes over the same text.
event=$(cat 2>/dev/null || true)
event_flat=$(printf '%s' "$event" | scribe_json_flat)
prompt=$(scribe_json_pick "$event_flat" '.prompt')
session_id=$(scribe_json_pick "$event_flat" '.session_id')
event_cwd=$(scribe_json_pick "$event_flat" '.cwd')
transcript=$(scribe_json_pick "$event_flat" '.transcript_path')
event_flat=$(printf '%s' "$event" | grimoire_json_flat)
prompt=$(grimoire_json_pick "$event_flat" '.prompt')
session_id=$(grimoire_json_pick "$event_flat" '.session_id')
event_cwd=$(grimoire_json_pick "$event_flat" '.cwd')
transcript=$(grimoire_json_pick "$event_flat" '.transcript_path')
# Nothing to retrieve against.
[ -n "$prompt" ] || exit 0
@@ -58,12 +58,12 @@ transcript=$(scribe_json_pick "$event_flat" '.transcript_path')
# slash-command echo, a local command's caveat banner. Leaving before the
# request matters more for the LOG than for the noise: a retrieval call
# recorded against a notification inflates this surface's denominator and
# seeds `near_misses` with demand nobody expressed. scribe_skip_prompt carries
# seeds `near_misses` with demand nobody expressed. grimoire_skip_prompt carries
# the reasoning.
scribe_skip_prompt "$prompt" && exit 0
grimoire_skip_prompt "$prompt" && exit 0
# Unconfigured install → silent (auto-inject is pure enrichment).
scribe_config || exit 0
grimoire_config || 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
@@ -72,9 +72,9 @@ scribe_config || exit 0
q=$(printf '%s' "$prompt" | head -c 2000)
# Encoded whole, never line by line: per-line encoding joins the pieces with
# raw newlines, an invalid URL, and the long multi-line prompts most worth
# retrieving against would be the ones dropped. scribe_urlenc reads bytes and
# retrieving against would be the ones dropped. grimoire_urlenc reads bytes and
# has no notion of a line.
q_enc=$(printf '%s' "$q" | scribe_urlenc) || exit 0
q_enc=$(printf '%s' "$q" | grimoire_urlenc) || exit 0
[ -n "$q_enc" ] || exit 0
# What the conversation is about, for a prompt too short to say. Sent
@@ -82,28 +82,28 @@ q_enc=$(printf '%s' "$q" | scribe_urlenc) || exit 0
# thin enough to need it, for the notes and rule arms alike. Capped at
# 600 chars here as well as there, so the URL stays small whatever the reply.
ctx_q=""
ctx=$(scribe_recent_context "$transcript")
ctx=$(grimoire_recent_context "$transcript")
if [ -n "$ctx" ]; then
ctx_enc=$(printf '%s' "$ctx" | scribe_urlenc) && [ -n "$ctx_enc" ] && ctx_q="&ctx=${ctx_enc}"
ctx_enc=$(printf '%s' "$ctx" | grimoire_urlenc) && [ -n "$ctx_enc" ] && ctx_q="&ctx=${ctx_enc}"
fi
# Scope to this directory's project — a `.scribe` marker, else the git remote.
# Scope to this directory's project — a `.grimoire` marker, else the git remote.
repo_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
scope=$(scribe_scope_query "$repo_dir")
scope=$(grimoire_scope_query "$repo_dir")
repo_q=""
[ -n "$scope" ] && repo_q="&${scope}"
# Per-session dedup: ids already injected this session are skipped.
state_dir="${TMPDIR:-/tmp}/scribe-autoinject"
state_dir="${TMPDIR:-/tmp}/grimoire-autoinject"
mkdir -p "$state_dir" 2>/dev/null || true
# RULES DEDUP IN A DIFFERENT DIRECTORY, and it has to be this one. The rule
# ledger is SHARED by every arm that can name a rule — the two PreToolUse
# hooks already keep it under scribe-priorart — so that one session keeps ONE
# hooks already keep it under grimoire-priorart — so that one session keeps ONE
# list and a rule named here is not re-announced before the next Bash call.
# A private copy here would make each arm's "already seen" mean something
# different. The directory is named for the prior-art hook, which is not a
# scope claim.
rule_state_dir="${TMPDIR:-/tmp}/scribe-priorart"
rule_state_dir="${TMPDIR:-/tmp}/grimoire-priorart"
mkdir -p "$rule_state_dir" 2>/dev/null || true
idfile=""
rulefile=""
@@ -120,15 +120,15 @@ if [ -n "$session_id" ]; then
fi
# AGED, not read flat: an exclusion that never expires means a rule surfaced
# once in a long session is silenced for the rest of it, even as the session
# stops holding what it was told. scribe_rules_live carries the reasoning.
rule_seen=$(scribe_rules_live "$rulefile")
# stops holding what it was told. grimoire_rules_live carries the reasoning.
rule_seen=$(grimoire_rules_live "$rulefile")
[ -n "$rule_seen" ] && exclude_q="${exclude_q}&exclude_rule_ids=${rule_seen}"
# What the session actually OPENED, as against what it was shown.
exclude_q="${exclude_q}$(scribe_held_query "$rule_state_dir/${safe_sid}.opened.ids")"
exclude_q="${exclude_q}$(grimoire_held_query "$rule_state_dir/${safe_sid}.opened.ids")"
# Which reply shapes it already holds in full: the core is
# sent whole once, and as its one-line reminder on every later turn.
shapefile=$(scribe_shapes_file "$safe_sid")
shapes_seen=$(scribe_shapes_seen "$shapefile")
shapefile=$(grimoire_shapes_file "$safe_sid")
shapes_seen=$(grimoire_shapes_seen "$shapefile")
[ -n "$shapes_seen" ] && exclude_q="${exclude_q}&shapes_seen=${shapes_seen}"
fi
@@ -137,25 +137,25 @@ body=$(curl -fsS --max-time 5 \
"${url%/}/api/plugin/retrieve?q=${q_enc}${ctx_q}${repo_q}${exclude_q}" 2>/dev/null) || exit 0
[ -n "$body" ] || exit 0
body_flat=$(printf '%s' "$body" | scribe_json_flat)
context=$(scribe_json_pick "$body_flat" '.context')
body_flat=$(printf '%s' "$body" | grimoire_json_flat)
context=$(grimoire_json_pick "$body_flat" '.context')
[ -n "$context" ] || exit 0
# Remember the surfaced ids so they aren't injected again this session.
if [ -n "$idfile" ]; then
scribe_json_list "$body_flat" '.note_ids' >> "$idfile" || true
grimoire_json_list "$body_flat" '.note_ids' >> "$idfile" || true
fi
# Rules onto the SHARED ledger, stamped so they can age out. Only FRESH ids
# come back in rule_ids — a rule rendered as a repeat is already on
# the ledger, and re-appending it would keep pushing its stamp forward so it
# never aged at all.
if [ -n "$rulefile" ]; then
scribe_json_list "$body_flat" '.rule_ids' \
| scribe_rules_append "$rulefile"
grimoire_json_list "$body_flat" '.rule_ids' \
| grimoire_rules_append "$rulefile"
fi
if [ -n "$shapefile" ]; then
scribe_json_list "$body_flat" '.shape_keys' | scribe_shapes_append "$shapefile"
grimoire_json_list "$body_flat" '.shape_keys' | grimoire_shapes_append "$shapefile"
fi
scribe_json_out UserPromptSubmit "$context"
grimoire_json_out UserPromptSubmit "$context"
exit 0
@@ -9,7 +9,7 @@
# The harness knows. This PostToolUse hook watches `update_task` and
# `add_task_log` and reports the event's `session_id` against the task the
# call named, so the claim can say "this session" rather than "someone,
# recently". Same evidence class as scribe_record_opened.sh: a tool call
# recently". Same evidence class as grimoire_record_opened.sh: a tool call
# happened and the harness reported it; nothing here asks the model anything.
#
# It cannot CREATE a claim. The server binds the session only to a live claim
@@ -21,24 +21,24 @@
# turn a successful tool call into a hook error.
set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# shellcheck source=plugin/hooks/grimoire_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/grimoire_defs.sh"
command -v curl >/dev/null 2>&1 || exit 0
scribe_config || exit 0
grimoire_config || exit 0
event=$(cat 2>/dev/null || true)
[ -n "$event" ] || exit 0
event_flat=$(printf '%s' "$event" | scribe_json_flat)
session_id=$(scribe_json_pick "$event_flat" '.session_id')
event_flat=$(printf '%s' "$event" | grimoire_json_flat)
session_id=$(grimoire_json_pick "$event_flat" '.session_id')
[ -n "$session_id" ] || exit 0
task_id=$(scribe_json_pick "$event_flat" '.tool_input.task_id')
task_id=$(grimoire_json_pick "$event_flat" '.tool_input.task_id')
task_id=$(printf '%s' "$task_id" | tr -cd '0-9')
[ -n "$task_id" ] || exit 0
sid_enc=$(printf '%s' "$session_id" | scribe_urlenc) || exit 0
sid_enc=$(printf '%s' "$session_id" | grimoire_urlenc) || exit 0
curl -fsS --max-time 4 \
-H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/claim-session?task_id=${task_id}&session_id=${sid_enc}" \
@@ -2,42 +2,42 @@
# shellcheck shell=bash
# Grimoire plugin — the pieces the hooks share.
#
# scribe_prior_art.sh fires BEFORE a Write/Edit tool call; scribe_after_write.sh
# grimoire_prior_art.sh fires BEFORE a Write/Edit tool call; grimoire_after_write.sh
# fires AFTER a Bash tool call and diffs the working tree, so code written by
# sed/heredocs/scripts gets the same prior-art and ledger checks. Both need the
# same three things, kept here so they cannot drift apart:
#
# scribe_skip_path PATH formats that hold prose or data, not shapes
# scribe_skip_prompt TEXT the turn was written by the CLIENT, not the
# grimoire_skip_path PATH formats that hold prose or data, not shapes
# grimoire_skip_prompt TEXT the turn was written by the CLIENT, not the
# operator — skip it entirely
# scribe_defs stdin code → "kind<TAB>name" per definition
# scribe_local_dups ROOT REL "kind<TAB>name" lines on stdin → the by-name
# grimoire_defs stdin code → "kind<TAB>name" per definition
# grimoire_local_dups ROOT REL "kind<TAB>name" lines on stdin → the by-name
# local-duplicate lines (ARM 1)
# scribe_unreached STATE SID SECS REL the "Grimoire didn't answer" line, once
# grimoire_unreached STATE SID SECS REL the "Grimoire didn't answer" line, once
# per outage — or nothing, if said lately
# scribe_reached STATE SID the server answered: the next outage speaks again
# scribe_config sets `url` + `token` from the env, returns 0
# grimoire_reached STATE SID the server answered: the next outage speaks again
# grimoire_config sets `url` + `token` from the env, returns 0
# only if BOTH are usable
# scribe_rules_live FILE live rule ids from the exclusion ledger,
# grimoire_rules_live FILE live rule ids from the exclusion ledger,
# comma-joined; entries age out
# scribe_rules_append FILE stdin ids -> the ledger, timestamped
# scribe_json_flat stdin JSON -> IDX<TAB>PATH<TAB>VALUE lines,
# queried with scribe_json_pick / _list / _len.
# scribe_json_out writes the hook envelope,
# scribe_urlenc percent-encodes. No jq.
# scribe_scope_query DIR `project_id=N` or `repo=<enc>` for DIR — the
# grimoire_rules_append FILE stdin ids -> the ledger, timestamped
# grimoire_json_flat stdin JSON -> IDX<TAB>PATH<TAB>VALUE lines,
# queried with grimoire_json_pick / _list / _len.
# grimoire_json_out writes the hook envelope,
# grimoire_urlenc percent-encodes. No jq.
# grimoire_scope_query DIR `project_id=N` or `repo=<enc>` for DIR — the
# project-scope key EVERY hook sends.
# Helpers: scribe_marker_file, scribe_url_host,
# scribe_marker_read (id<TAB>why-not),
# scribe_marker_project (the id alone)
# Helpers: grimoire_marker_file, grimoire_url_host,
# grimoire_marker_read (id<TAB>why-not),
# grimoire_marker_project (the id alone)
#
# Sourced, not executed: `. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"`.
# Sourced, not executed: `. "$(dirname "${BASH_SOURCE[0]}")/grimoire_defs.sh"`.
# Skip formats that hold prose or data rather than reusable code. Purely to
# avoid a pointless round-trip — the server would return nothing for these
# anyway. Config formats are NOT skipped: a CI workflow or a compose file is
# often exactly the thing worth reusing.
scribe_skip_path() {
grimoire_skip_path() {
case "$1" in
*.md|*.mdx|*.txt|*.rst|*.json|*.lock|*.log|*.csv|*.tsv|*.svg|*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf)
return 0 ;;
@@ -73,9 +73,9 @@ scribe_skip_path() {
# NOT LISTED, ON PURPOSE: the compaction-resume injection. It is machine
# written too, but it is a summary of real work rather than plumbing, and a
# resumed session is exactly where recalling a rule earns its keep.
scribe_skip_prompt() {
_scribe_prompt=${1#"${1%%[![:space:]]*}"}
case "$_scribe_prompt" in
grimoire_skip_prompt() {
_grimoire_prompt=${1#"${1%%[![:space:]]*}"}
case "$_grimoire_prompt" in
"<task-notification>"*|\
"<agent-message>"*|"<agent-message "*|\
"<command-name>"*|"<command-message>"*|"<command-args>"*|\
@@ -93,33 +93,33 @@ scribe_skip_prompt() {
# exactly like one that ran and had nothing to say. awk, sed, tr and od are
# POSIX; every one of them is already required by code above this line.
#
# scribe_json.awk does the parsing and carries the format. These are the four
# grimoire_json.awk does the parsing and carries the format. These are the four
# jobs the hooks actually had jq for:
#
# READ scribe_json_flat / _flat_lines turn stdin into `IDX<TAB>PATH<TAB>
# VALUE` lines ONCE, and scribe_json_pick / _list / _len query that
# READ grimoire_json_flat / _flat_lines turn stdin into `IDX<TAB>PATH<TAB>
# VALUE` lines ONCE, and grimoire_json_pick / _list / _len query that
# text. Parse once, query many: a hook reads four fields off one
# event, and re-parsing per field is four passes over a payload that
# may be an entire source file.
# WRITE scribe_json_out emits the hookSpecificOutput envelope, which is the
# WRITE grimoire_json_out emits the hookSpecificOutput envelope, which is the
# one piece of JSON these hooks produce.
# ENCODE scribe_urlenc replaces `jq -sRr '@uri'`, byte-exact for UTF-8.
# DECODE scribe_json_unescape turns a raw JSON string body into text.
# ENCODE grimoire_urlenc replaces `jq -sRr '@uri'`, byte-exact for UTF-8.
# DECODE grimoire_json_unescape turns a raw JSON string body into text.
_scribe_self=${BASH_SOURCE[0]:-$0}
_grimoire_self=${BASH_SOURCE[0]:-$0}
# shellcheck disable=SC1007 # `CDPATH= cd` scopes one variable to one command
SCRIBE_HOOK_DIR=$(CDPATH= cd -- "$(dirname -- "$_scribe_self")" 2>/dev/null && pwd) \
|| SCRIBE_HOOK_DIR=$(dirname -- "$_scribe_self")
GRIMOIRE_HOOK_DIR=$(CDPATH= cd -- "$(dirname -- "$_grimoire_self")" 2>/dev/null && pwd) \
|| GRIMOIRE_HOOK_DIR=$(dirname -- "$_grimoire_self")
# One JSON value on stdin → flat lines. Empty output means it did not parse.
scribe_json_flat() {
awk -f "$SCRIBE_HOOK_DIR/scribe_json.awk" 2>/dev/null || true
grimoire_json_flat() {
awk -f "$GRIMOIRE_HOOK_DIR/grimoire_json.awk" 2>/dev/null || true
}
# JSONL on stdin → flat lines, IDX counting the records that PARSED. A line
# that does not parse is dropped and the rest are still read.
scribe_json_flat_lines() {
awk -v mode=lines -f "$SCRIBE_HOOK_DIR/scribe_json.awk" 2>/dev/null || true
grimoire_json_flat_lines() {
awk -v mode=lines -f "$GRIMOIRE_HOOK_DIR/grimoire_json.awk" 2>/dev/null || true
}
# $1 transcript path → the tail of the last assistant REPLY text, decoded, on
@@ -132,19 +132,19 @@ scribe_json_flat_lines() {
# seconds. Fixed strings
# with UNESCAPED quotes can only match at a record's own top level.
# A sidechain is a subagent talking, not this session, so it is skipped.
scribe_recent_context() {
grimoire_recent_context() {
[ -n "${1:-}" ] && [ -f "$1" ] || return 0
tail -c 524288 "$1" 2>/dev/null \
| grep -F '"role":"assistant"' 2>/dev/null \
| grep -F '"type":"text"' 2>/dev/null \
| tail -n 4 \
| scribe_json_flat_lines \
| grimoire_json_flat_lines \
| awk -F'\t' '
$2 == ".isSidechain" { side[$1] = $3; next }
$2 ~ /^\.message\.content\[[0-9]+\]\.text$/ { txt[$1] = txt[$1] " " $3; if ($1 > last) last = $1 }
END { for (i = last; i >= 0; i--) if ((i in txt) && side[i] != "true") { print txt[i]; exit } }
' 2>/dev/null \
| scribe_json_unescape \
| grimoire_json_unescape \
| tr '\n\t' ' ' \
| awk '{ sub(/[ ]+$/, ""); n = length($0); if (n) print (n > 600 ? substr($0, n - 599) : $0) }'
# A transcript with no reply yet is an answer ("nothing"), not a failure —
@@ -156,16 +156,16 @@ scribe_recent_context() {
#
# `null` READS AS ABSENT. A field the server left null and one it never sent
# mean the same thing to every caller here, and the shells downstream test `[ -n "$x" ]`.
scribe_json_pick() {
grimoire_json_pick() {
printf '%s\n' "${1:-}" \
| awk -F'\t' -v p="$2" '$2 == p { if ($3 != "null") print $3; exit }' 2>/dev/null \
| scribe_json_unescape
| grimoire_json_unescape
}
# $1 flat text, $2 array path → each element decoded, one per line. Elements
# are ids and keys here, so a value containing a newline is not a case that
# arises; one would split into two lines.
scribe_json_list() {
grimoire_json_list() {
printf '%s\n' "${1:-}" \
| awk -F'\t' -v p="$2" '
BEGIN { plen = length(p) }
@@ -173,25 +173,25 @@ scribe_json_list() {
rest = substr($2, plen + 1)
if (rest ~ /^\[[0-9]+\]$/ && $3 != "null") print $3
}' 2>/dev/null \
| scribe_json_unescape
| grimoire_json_unescape
}
# $1 flat text, $2 array path, $3 array path to SUBTRACT → the elements of $2
# that are not in $3, in order. The reuse and sync channels are two classes of
# one answer and each has its own session ledger, so the ids that go in the
# reuse file are precisely `note_ids` minus `sync_note_ids`.
scribe_json_list_minus() {
grimoire_json_list_minus() {
local keep
keep=$(scribe_json_list "$1" "$3")
scribe_json_list "$1" "$2" \
keep=$(grimoire_json_list "$1" "$3")
grimoire_json_list "$1" "$2" \
| awk -v drop="$keep" '
BEGIN { n = split(drop, a, "\n"); for (i = 1; i <= n; i++) if (a[i] != "") s[a[i]] = 1 }
NF && !($0 in s)' 2>/dev/null || true
}
# $1 flat text, $2 array path → its length. "" when the path is not an array,
# which is NOT the same as 0 — see the `[#]` note in scribe_json.awk.
scribe_json_len() {
# which is NOT the same as 0 — see the `[#]` note in grimoire_json.awk.
grimoire_json_len() {
printf '%s\n' "${1:-}" \
| awk -F'\t' -v p="$2" '$2 == p "[#]" { print $3; exit }' 2>/dev/null
}
@@ -205,7 +205,7 @@ scribe_json_len() {
# and mawk write the single byte n % 256, so "·" (U+00B7) would arrive as a
# lone 0xB7 wherever the hook runs without a UTF-8 locale. Under LC_ALL=C every awk writes exactly the byte asked for, so the encoding
# below is the only one that happens.
scribe_json_unescape() {
grimoire_json_unescape() {
LC_ALL=C awk '
function utf8(n) {
if (n < 128) return sprintf("%c", n)
@@ -269,7 +269,7 @@ scribe_json_unescape() {
# quadruple-escape whose meaning varies between awks. Plain concatenation has
# no such reading, and getting this wrong corrupts every injected context that
# happens to contain a Windows path or a regex.
scribe_json_escape() {
grimoire_json_escape() {
awk '
function rep(str, sep, with, parts, cnt, i, o) {
cnt = split(str, parts, sep)
@@ -302,9 +302,9 @@ scribe_json_escape() {
# The one JSON document these hooks WRITE: $1 hook event name, $2 the context.
# Every hook emitted this through `jq -n --arg c`, five copies of one shape.
scribe_json_out() {
grimoire_json_out() {
local esc
esc=$(printf '%s' "$2" | scribe_json_escape) || return 0
esc=$(printf '%s' "$2" | grimoire_json_escape) || return 0
printf '{"hookSpecificOutput":{"hookEventName":"%s","additionalContext":"%s"}}\n' \
"$1" "$esc"
}
@@ -321,7 +321,7 @@ scribe_json_out() {
#
# Slightly more aggressive than jq's `@uri`, which leaves `!~*'()` alone. Those
# are legal either way and the server decodes both identically.
scribe_urlenc() {
grimoire_urlenc() {
od -An -v -tu1 2>/dev/null \
| awk '{
for (i = 1; i <= NF; i++) {
@@ -350,7 +350,7 @@ scribe_urlenc() {
# symbol that does not exist. blank_spans() replaces every comment and string
# span with its own newlines before a matcher sees a line — the same scan, in
# the same order, as coverage.py::_blank_spans. Change one, change both.
scribe_defs() {
grimoire_defs() {
awk '
BEGIN {
SQ = sprintf("%c", 39)
@@ -510,7 +510,7 @@ scribe_defs() {
# 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. ALL code, not a
# language shortlist: the same keyword family scribe_defs announces.
# language shortlist: the same keyword family grimoire_defs announces.
#
# $1 repo root, $2 repo-relative path of the file being written (excluded from
# the grep — it would always match itself on an Edit). Definitions on stdin.
@@ -527,7 +527,7 @@ scribe_defs() {
# carrier for a reflex that has to change what the act IS, so this looks the
# callers up instead.
#
# `scribe_exposed` — the names a CALLER can depend on, from a blob of code.
# `grimoire_exposed` — the names a CALLER can depend on, from a blob of code.
# Three kinds, because a contract breaks three ways and they look nothing
# alike in the source:
#
@@ -538,15 +538,15 @@ scribe_defs() {
# The third matters because a returned dict can change shape with no
# signature changing: callers that read it inside a fail-open `except` go
# silently inert, with nothing pointing at the cause.
scribe_exposed() {
# The `sym` half DELEGATES to scribe_defs rather than repeating its patterns.
grimoire_exposed() {
# The `sym` half DELEGATES to grimoire_defs rather than repeating its patterns.
# Those patterns cover nine languages and carry their own edge cases (the Go
# receiver form, the `type` import specifier, the dunder skip); a second copy
# here would drift from them. One reader, called twice.
local blob
blob=$(cat)
{
printf '%s' "$blob" | scribe_defs
printf '%s' "$blob" | grimoire_defs
printf '%s' "$blob" | awk '
function emit(kind, name) {
if (name != "" && name !~ /^__.*__$/) print kind "\t" name
@@ -597,10 +597,10 @@ scribe_exposed() {
# always its own first mention and listing it says nothing.
#
# `|| true` INSIDE the substitution, for the reason given at
# scribe_local_dups: under `pipefail` a `head` that exits early kills the
# grimoire_local_dups: under `pipefail` a `head` that exits early kills the
# still-writing git grep, and an outer fallback then wipes the hits head had
# already printed.
scribe_contract_readers() {
grimoire_contract_readers() {
local root="$1" rel="$2" name="$3"
[ -n "$root" ] && [ -n "$name" ] || return 0
git -C "$root" grep -I -l -w -e "$name" -- . ":(exclude)${rel}" 2>/dev/null \
@@ -621,7 +621,7 @@ scribe_contract_readers() {
# result ends it silently. On a repo of any size most edits touch something
# local, so most edits say nothing here — and a hint that fires on everything
# is one that gets skipped.
scribe_contract_block() {
grimoire_contract_block() {
local root="$1" rel="$2" subject="$3" old_text="$4" new_text="$5" ledger="$6"
local changed gained lost readers count
[ -n "$root" ] && [ -n "$subject" ] || return 0
@@ -638,8 +638,8 @@ scribe_contract_block() {
# also keeps the two sides' extraction visibly identical.
changed=$(
{
printf '%s' "$old_text" | scribe_exposed | sed 's/^/O\t/'
printf '%s' "$new_text" | scribe_exposed | sed 's/^/N\t/'
printf '%s' "$old_text" | grimoire_exposed | sed 's/^/O\t/'
printf '%s' "$new_text" | grimoire_exposed | sed 's/^/N\t/'
} | awk -F'\t' '
NF >= 3 { k = $2 "\t" $3; side[k] = side[k] $1 }
END {
@@ -651,7 +651,7 @@ scribe_contract_block() {
)
[ -n "$changed" ] || return 0
readers=$(scribe_contract_readers "$root" "$rel" "$subject")
readers=$(grimoire_contract_readers "$root" "$rel" "$subject")
[ -n "$readers" ] || return 0
count=$(printf '%s\n' "$readers" | grep -c . 2>/dev/null || printf '0')
@@ -669,7 +669,7 @@ scribe_contract_block() {
}
scribe_slippage_lines() {
grimoire_slippage_lines() {
# $1 state dir, $2 sanitised session id.
#
# SILENT ONLY WHEN NO RULE TOUCHED THE SESSION AT ALL. Traffic that did
@@ -690,15 +690,15 @@ scribe_slippage_lines() {
# their TTL has usually already eaten the early part of a long session. A
# readout built on them would report only the last stretch of the session
# and read as though it had reported all of it.
named=$(scribe_ledger_kept "$dir/${sid}.rules.keep.ids")
opened=$(scribe_ledger_kept "$dir/${sid}.opened.keep.ids")
acted=$(scribe_ledger_kept "$dir/${sid}.acted.keep.ids")
held=$(scribe_ledger_kept "$dir/${sid}.checkpoint.keep.ids")
named=$(grimoire_ledger_kept "$dir/${sid}.rules.keep.ids")
opened=$(grimoire_ledger_kept "$dir/${sid}.opened.keep.ids")
acted=$(grimoire_ledger_kept "$dir/${sid}.acted.keep.ids")
held=$(grimoire_ledger_kept "$dir/${sid}.checkpoint.keep.ids")
[ -n "$named$opened" ] || return 0
local unread unresolved
unread=$(scribe_ids_minus "$named" "$opened")
unresolved=$(scribe_ids_minus "$opened" "$acted")
unread=$(grimoire_ids_minus "$named" "$opened")
unresolved=$(grimoire_ids_minus "$opened" "$acted")
printf -- '- This session'"'"'s rule traffic, from what actually happened rather than from recollection:\n'
[ -n "$opened" ] && printf -- ' read: %s\n' "$opened"
@@ -710,7 +710,7 @@ scribe_slippage_lines() {
return 0
}
scribe_ids_minus() {
grimoire_ids_minus() {
# Set difference over two space-separated id lists, order preserved. Written
# as one awk pass rather than a nested shell loop because the ledgers can
# hold a few dozen ids by the end of a long session and this runs inside the
@@ -757,15 +757,15 @@ scribe_ids_minus() {
#
# ONCE PER DISTINCT FILE, not once per (name, file) pair: the same file is
# usually a candidate for several names at once, and the extractor reads the
# whole file either way. `scribe_defs` runs at roughly 33ms per 250KB, so
# whole file either way. `grimoire_defs` runs at roughly 33ms per 250KB, so
# even a pathological payload of ordinary-word names costs a few hundred ms to
# confirm; the per-(name, file) shape would pay that several times over for
# the same answer.
_SCRIBE_DUP_CANDIDATES=12
_SCRIBE_DUP_SHOWN=4
_SCRIBE_DUP_BUDGET=48
_GRIMOIRE_DUP_CANDIDATES=12
_GRIMOIRE_DUP_SHOWN=4
_GRIMOIRE_DUP_BUDGET=48
scribe_local_dups() {
grimoire_local_dups() {
local root="$1" rel="$2" kind name pat hits
local records="" cands="" idx=$'\n' seen=0
local f defs line record rest files shown
@@ -774,7 +774,7 @@ scribe_local_dups() {
#
# THE CANDIDATE CAP IS WIDER THAN THE DISPLAY CAP. Confirmation REMOVES
# hits, so capping before it runs would let phantom matches crowd out a real
# definition. The display cap (_SCRIBE_DUP_SHOWN) applies to CONFIRMED hits,
# definition. The display cap (_GRIMOIRE_DUP_SHOWN) applies to CONFIRMED hits,
# which is where a cap belongs.
while IFS=$'\t' read -r kind name; do
[ -n "${name:-}" ] || continue
@@ -789,7 +789,7 @@ scribe_local_dups() {
# wipe the hits head had already printed — dropping exactly the most
# duplicated name.
hits=$(git -C "$root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel}" 2>/dev/null \
| head -"$_SCRIBE_DUP_CANDIDATES" || true)
| head -"$_GRIMOIRE_DUP_CANDIDATES" || true)
[ -n "$hits" ] || continue
records+="${kind}"$'\t'"${name}"$'\t'"${hits//$'\n'/$'\t'}"$'\n'
cands+="${hits}"$'\n'
@@ -806,9 +806,9 @@ scribe_local_dups() {
# worth less here than no claim.
while IFS= read -r f; do
[ -n "$f" ] || continue
[ "$seen" -ge "$_SCRIBE_DUP_BUDGET" ] && break
[ "$seen" -ge "$_GRIMOIRE_DUP_BUDGET" ] && break
seen=$((seen + 1))
defs=$(scribe_defs < "$root/$f" 2>/dev/null | sort -u) || defs=""
defs=$(grimoire_defs < "$root/$f" 2>/dev/null | sort -u) || defs=""
[ -n "$defs" ] || continue
while IFS= read -r line; do
[ -n "$line" ] || continue
@@ -834,7 +834,7 @@ scribe_local_dups() {
esac
files+="${f} "
shown=$((shown + 1))
[ "$shown" -ge "$_SCRIBE_DUP_SHOWN" ] && break
[ "$shown" -ge "$_GRIMOIRE_DUP_SHOWN" ] && break
done <<< "$(printf '%s' "${rest#*$'\t'}" | tr '\t' '\n')"
[ -n "$files" ] || continue
if [ "$kind" = css ]; then label=".$name"; else label="$name"; fi
@@ -851,9 +851,9 @@ scribe_local_dups() {
# Sets `url` and `token` as globals rather than echoing them: a token must not
# pass through a subshell's output, where it could land in a log or an `xtrace`
# line. Returns 0 only when both are usable, so a caller can either bail
# (`scribe_config || exit 0`) or carry on degraded — the session-context hook
# (`grimoire_config || exit 0`) or carry on degraded — the session-context hook
# still owes its static floor when Grimoire is unconfigured.
# Declared here, not just assigned inside the function: `scribe_defs.sh` owns
# Declared here, not just assigned inside the function: `grimoire_defs.sh` owns
# these two names, and a sourcing hook should have them defined the moment it
# sources — before any code path that might reference them. It also lets
# the linter see the assignment, which it cannot follow into a function in
@@ -861,9 +861,9 @@ scribe_local_dups() {
url=""
token=""
scribe_config() {
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
grimoire_config() {
url=${GRIMOIRE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
token=${GRIMOIRE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
# An unexpanded `${...}` placeholder arriving as a literal would be sent as a
# garbage Bearer token and 401. Treat it as unset.
case "$url" in *'${'*) url="" ;; esac
@@ -879,21 +879,21 @@ scribe_config() {
# spoke; within ten minutes of that it stays quiet, and a successful call
# clears it so the next outage announces itself afresh. Unconfigured installs
# never reach this: no URL/token means no call was owed.
_SCRIBE_UNREACHED_QUIET=600
_GRIMOIRE_UNREACHED_QUIET=600
scribe_unreached() {
grimoire_unreached() {
local marker="$1/$2.unreached" now last
now=$(date +%s 2>/dev/null) || now=0
if [ -f "$marker" ]; then
last=$(cat "$marker" 2>/dev/null) || last=0
case "$last" in ''|*[!0-9]*) last=0 ;; esac
[ $((now - last)) -lt "$_SCRIBE_UNREACHED_QUIET" ] && return 0
[ $((now - last)) -lt "$_GRIMOIRE_UNREACHED_QUIET" ] && return 0
fi
printf '%s' "$now" > "$marker" 2>/dev/null || true
printf '> Grimoire did not answer the prior-art check for `%s` within %ss — this write went UNCHECKED against the record and the shape ledger (the local by-name arm, if it spoke above, needed no server). If the name matters, check it yourself: `search` for the concept, `list_shapes(project_id, path=…)` for the ledger. Said once per outage; if it keeps happening the instance is slow or down.' "$4" "$3"
}
scribe_reached() {
grimoire_reached() {
rm -f "$1/$2.unreached" 2>/dev/null || true
}
@@ -901,7 +901,7 @@ scribe_reached() {
# THE RULE EXCLUSION LEDGER, and how entries in it AGE.
#
# Two hooks write this file and two read it, which is the whole reason the
# parsing lives here: `scribe_prior_art.sh` and `scribe_tool_rules.sh` share
# parsing lives here: `grimoire_prior_art.sh` and `grimoire_tool_rules.sh` share
# one ledger so a rule named by one arm is not re-offered by the other, and a
# format only one of them understood would break that on the first read.
#
@@ -923,7 +923,7 @@ scribe_reached() {
# wrong. Wall time is available from `date` and costs nothing. The failure mode
# it accepts is a session left idle over lunch treating its rules as forgotten,
# which produces one extra full line per rule and no other harm.
_SCRIBE_RULE_TTL=2700
_GRIMOIRE_RULE_TTL=2700
# 45 MINUTES, and the reasoning rather than the number.
#
@@ -957,11 +957,11 @@ _SCRIBE_RULE_TTL=2700
# never "old": treating it as expired would re-announce every such rule at
# once, which is precisely the noise this exists to prevent. Those entries
# simply never age, which is bounded: the session ends.
scribe_rules_live() {
grimoire_rules_live() {
local f="$1" now
[ -n "$f" ] && [ -f "$f" ] || return 0
now=$(date +%s 2>/dev/null) || now=0
awk -F'\t' -v now="$now" -v ttl="$_SCRIBE_RULE_TTL" '
awk -F'\t' -v now="$now" -v ttl="$_GRIMOIRE_RULE_TTL" '
{
id = $1
gsub(/[^0-9]/, "", id)
@@ -982,8 +982,8 @@ scribe_rules_live() {
}
# Append surfaced ids, stamped. Reads ids on stdin, one per line — the shape
# `scribe_json_list "$flat" '.rule_ids'` already produces at both call sites.
scribe_rules_append() {
# `grimoire_json_list "$flat" '.rule_ids'` already produces at both call sites.
grimoire_rules_append() {
# Writes TWO files, and the second one is the point.
#
# `$f` is an EXCLUSION ledger: it answers "does this context already hold
@@ -998,7 +998,7 @@ scribe_rules_append() {
# would clear the ledger that is also the record.
#
# DERIVED HERE rather than listed at the call sites, because a list goes
# stale (see `scribe_clear_session_ledgers`). Every ledger
# stale (see `grimoire_clear_session_ledgers`). Every ledger
# written through this function gets its twin, including the next one
# somebody adds. The cost is a second small file per ledger per session.
local f="$1" now
@@ -1009,7 +1009,7 @@ scribe_rules_append() {
' >> "$f" 2>/dev/null || true
}
scribe_ledger_kept() {
grimoire_ledger_kept() {
# Ids from an EVIDENCE twin: deduped, order preserved, NOT aged. A rule
# opened two hours ago was still opened, so the TTL that keeps an exclusion
# ledger honest would here delete the finding.
@@ -1030,7 +1030,7 @@ scribe_ledger_kept() {
# remedy has been offered; repeating it on the next call would
# turn a reader who decided the rule does not apply into a
# reader who cannot proceed.
# per session — at most `_SCRIBE_CHECKPOINT_CAP` stops in total, whatever the
# per session — at most `_GRIMOIRE_CHECKPOINT_CAP` stops in total, whatever the
# corpus scores. A mis-set floor or a corpus that suddenly
# resembles everything must degrade to a noisy session, never
# to one that cannot make progress. This is the guard on the
@@ -1042,9 +1042,9 @@ scribe_ledger_kept() {
# and that does not become untrue an hour later. Ageing it would let a long
# session be stopped by the same rule repeatedly, which is the one outcome
# both guards exist to prevent.
_SCRIBE_CHECKPOINT_CAP=5
_GRIMOIRE_CHECKPOINT_CAP=5
scribe_checkpoint_allowed() {
grimoire_checkpoint_allowed() {
# $1 ledger file, $2 rule id. Returns 0 (and RECORDS the stop) when this act
# may be held; non-zero otherwise. Records on the way out rather than asking
# the caller to, because a caller that forgets is a session that can be
@@ -1062,7 +1062,7 @@ scribe_checkpoint_allowed() {
# others, which is how a cap comes to cap nothing.
n=$(printf '%s' "$n" | tr -cd '0-9')
[ -n "$n" ] || n=0
[ "$n" -ge "$_SCRIBE_CHECKPOINT_CAP" ] && return 1
[ "$n" -ge "$_GRIMOIRE_CHECKPOINT_CAP" ] && return 1
fi
printf '%s\n' "$id" >> "$f" 2>/dev/null || return 1
# The evidence twin, beside the cap rather than instead of it. `$f` is swept
@@ -1073,7 +1073,7 @@ scribe_checkpoint_allowed() {
return 0
}
scribe_json_deny() {
grimoire_json_deny() {
# $1 hook event name, $2 the reason the agent reads INSTEAD of the result.
#
# The one place this plugin emits a decision on a tool call. `deny` returns
@@ -1083,7 +1083,7 @@ scribe_json_deny() {
# would hand a judgement that is the agent's to make to the person who asked
# for the work.
local esc
esc=$(printf '%s' "$2" | scribe_json_escape) || return 0
esc=$(printf '%s' "$2" | grimoire_json_escape) || return 0
printf '{"hookSpecificOutput":{"hookEventName":"%s","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' \
"$1" "$esc"
}
@@ -1092,7 +1092,7 @@ scribe_json_deny() {
#
# TWO LEDGERS, BECAUSE THEY RECORD TWO DIFFERENT FACTS. `.rules.ids` holds
# every id an arm has NAMED; `.opened.ids` holds the ids the session actually
# read, written by scribe_record_opened.sh from the `get_rule` call itself.
# read, written by grimoire_record_opened.sh from the `get_rule` call itself.
# Named is not read: the injected line is a teaser, and one skimmed past
# leaves nothing behind — least of all across a compaction. Sending both lets
# the server tell a reader who opened a rule from one who was only shown it,
@@ -1100,9 +1100,9 @@ scribe_json_deny() {
#
# Same reader as the naming ledger on purpose, so ageing, the last-entry-wins
# rule and the bare-id format are defined once and cannot drift apart.
scribe_held_query() {
grimoire_held_query() {
local ids
ids=$(scribe_rules_live "$1")
ids=$(grimoire_rules_live "$1")
[ -n "$ids" ] && printf '&held_rule_ids=%s' "$ids"
return 0
}
@@ -1113,7 +1113,7 @@ scribe_held_query() {
# the server already answers a repeat with one short reference line. The file
# follows the ledger name convention, so a compaction clears it and the
# rulings are shown in full again to the context that lost them.
scribe_rulings_query() {
grimoire_rulings_query() {
local ids
[ -f "$1" ] || return 0
ids=$(grep -E '^[0-9]+$' "$1" 2>/dev/null | sort -un | tr '\n' ',' | sed 's/,$//')
@@ -1139,18 +1139,18 @@ scribe_rulings_query() {
# what the session holds, and the once-per-outage line needs it to outlive a
# compaction.
#
# TWO DIRECTORIES: `scribe_autoinject.sh` keeps its note ledger in
# `scribe-autoinject`, apart from the prior-art hooks'. The roster lives here
# TWO DIRECTORIES: `grimoire_autoinject.sh` keeps its note ledger in
# `grimoire-autoinject`, apart from the prior-art hooks'. The roster lives here
# rather than as a path at each call site: one place to add to, and the tests
# read THIS string rather than a copy of it.
SCRIBE_LEDGER_DIRS="scribe-priorart scribe-autoinject"
GRIMOIRE_LEDGER_DIRS="grimoire-priorart grimoire-autoinject"
# THE WRITTEN-SHAPES LEDGER. Every definition a write names,
# appended as `path<TAB>kind<TAB>name`, for the Stop hook to ask about at the
# end of the turn (scribe_shape_check.sh). Its own file — one file, one
# end of the turn (grimoire_shape_check.sh). Its own file — one file, one
# question — under the prior-art directory, so the session
# clear above already sweeps it. Read `kind<TAB>name` lines on stdin (the
# scribe_defs format).
# grimoire_defs format).
#
# A NEW file that is a unit in its own right adds one `file` line named by its
# stem — the same test the server's sync applies (coverage.is_file_unit): it
@@ -1160,10 +1160,10 @@ SCRIBE_LEDGER_DIRS="scribe-priorart scribe-autoinject"
# $1 session id (already made filename-safe) $2 repo-relative path
# $3 "new" when the file did not exist before this write
# $4 the code written (to test whether the file renders)
scribe_written_append() {
grimoire_written_append() {
local sid="$1" rel="$2" fresh="${3:-}" code="${4:-}" dir stem names
if [ -z "$sid" ] || [ -z "$rel" ]; then cat >/dev/null; return 0; fi
dir="${TMPDIR:-/tmp}/scribe-priorart"
dir="${TMPDIR:-/tmp}/grimoire-priorart"
mkdir -p "$dir" 2>/dev/null || true
names=$(cat)
{
@@ -1190,34 +1190,34 @@ scribe_written_append() {
# point rather than a side effect: after a compaction the session no longer
# holds the shape, so the next turn gets it in full again. Not aged — the
# per-turn reminder is what keeps it in view between compactions.
# scribe_shapes_file <sid> the ledger's path
# scribe_shapes_seen <file> its keys, comma-joined, for `shapes_seen=`
# scribe_shapes_append <file> keys on stdin, appended
scribe_shapes_file() {
printf '%s/scribe-priorart/%s.shapes.ids' "${TMPDIR:-/tmp}" "$1"
# grimoire_shapes_file <sid> the ledger's path
# grimoire_shapes_seen <file> its keys, comma-joined, for `shapes_seen=`
# grimoire_shapes_append <file> keys on stdin, appended
grimoire_shapes_file() {
printf '%s/grimoire-priorart/%s.shapes.ids' "${TMPDIR:-/tmp}" "$1"
}
scribe_shapes_seen() {
grimoire_shapes_seen() {
[ -n "$1" ] && [ -f "$1" ] || return 0
awk '/^[a-z]+$/ && !seen[$0]++ { out = out (out == "" ? "" : ",") $0 } END { print out }' \
"$1" 2>/dev/null || true
}
scribe_shapes_append() {
grimoire_shapes_append() {
[ -n "$1" ] || { cat >/dev/null; return 0; }
mkdir -p "$(dirname "$1")" 2>/dev/null || true
awk '/^[a-z]+$/' >> "$1" 2>/dev/null || true
}
scribe_clear_session_ledgers() {
grimoire_clear_session_ledgers() {
# SPARES `*.keep.ids`, which are evidence rather than exclusions — see
# `scribe_rules_append`. Everything else still goes: the convention is
# `grimoire_rules_append`. Everything else still goes: the convention is
# unchanged and still covers a ledger added tomorrow, which is what the
# block above insists on. The twin opts OUT by its name, so a new ledger is
# born on the swept side unless somebody says otherwise.
local sid="$1" dir f
[ -n "$sid" ] || return 0
for dir in $SCRIBE_LEDGER_DIRS; do
for dir in $GRIMOIRE_LEDGER_DIRS; do
for f in "${TMPDIR:-/tmp}/$dir/$sid"*.ids; do
[ -f "$f" ] || continue
case "$f" in *.keep.ids) continue ;; esac
@@ -1228,13 +1228,13 @@ scribe_clear_session_ledgers() {
}
# ---------------------------------------------------------------------------
# WHAT HAPPENED IN THE LAST TURN of a transcript: scribe_turn.awk's
# WHAT HAPPENED IN THE LAST TURN of a transcript: grimoire_turn.awk's
# facts — bounded, closed, task_ids, reply — one `KEY<TAB>VALUE` per line.
# Shared by the two Stop hooks that read a turn: the report check and the
# reply check. Empty output when the turn cannot be bounded.
#
# The turn is parsed once. A window of recent lines, flattened record by
# record and then read by scribe_turn.awk, which carries the turn-bounding
# record and then read by grimoire_turn.awk, which carries the turn-bounding
# rules. A line that does not parse is dropped and the rest are still read —
# the first line of a `tail -n 3000` window is routinely half a record. If the
# window holds no prompt, the turn cannot be bounded, and the caller says
@@ -1269,28 +1269,28 @@ scribe_clear_session_ledgers() {
# does, grep matches nothing, `start` stays 1, and the whole window is read the
# slow way — correct, and slow, which is the right way round for a check that
# can block a stop.
_scribe_turn_window() { tail -n 3000 "$1" 2>/dev/null; }
_scribe_turn_parse() {
_scribe_turn_window "$1" | tail -n +"${2:-1}" | scribe_json_flat_lines \
| awk -f "$SCRIBE_HOOK_DIR/scribe_turn.awk" 2>/dev/null
_grimoire_turn_window() { tail -n 3000 "$1" 2>/dev/null; }
_grimoire_turn_parse() {
_grimoire_turn_window "$1" | tail -n +"${2:-1}" | grimoire_json_flat_lines \
| awk -f "$GRIMOIRE_HOOK_DIR/grimoire_turn.awk" 2>/dev/null
}
scribe_turn_facts() {
grimoire_turn_facts() {
local transcript="$1" start facts
[ -n "$transcript" ] && [ -f "$transcript" ] || return 0
start=$(_scribe_turn_window "$transcript" \
start=$(_grimoire_turn_window "$transcript" \
| grep -n -F '"message":{"role":"user","content":"' 2>/dev/null \
| cut -d: -f1 | awk '{ last = $0 } END { if (NR) print last }')
case "$start" in ''|*[!0-9]*) start=1 ;; esac
facts=$(_scribe_turn_parse "$transcript" "$start")
if [ "$(scribe_turn_fact "$facts" bounded)" != "1" ] && [ "$start" != "1" ]; then
facts=$(_scribe_turn_parse "$transcript" 1)
facts=$(_grimoire_turn_parse "$transcript" "$start")
if [ "$(grimoire_turn_fact "$facts" bounded)" != "1" ] && [ "$start" != "1" ]; then
facts=$(_grimoire_turn_parse "$transcript" 1)
fi
printf '%s\n' "$facts"
}
# $1 facts from scribe_turn_facts, $2 key → that fact's value, or "".
scribe_turn_fact() {
# $1 facts from grimoire_turn_facts, $2 key → that fact's value, or "".
grimoire_turn_fact() {
printf '%s\n' "$1" | awk -F'\t' -v k="$2" '$1 == k { print substr($0, index($0, "\t") + 1); exit }'
}
@@ -1302,12 +1302,12 @@ scribe_turn_fact() {
# works inside a bound repo and not at all outside one, where a session would
# get no project scope from any hook.
#
# The second key is a `.scribe` file in the directory (or above it, the way
# The second key is a `.grimoire` file in the directory (or above it, the way
# git finds its root), naming the project the work belongs to. It is a
# POINTER, not a copy: an id and enough to check the id means what it says.
# Nothing Grimoire should be holding goes in it.
#
# {"instance": "https://scribe.example.com", "project_id": 2,
# {"instance": "https://grimoire.example.com", "project_id": 2,
# "project": "FabledScribe"}
#
# instance WHICH Grimoire the id belongs to, and the reason this file is
@@ -1323,7 +1323,7 @@ scribe_turn_fact() {
# answers "what is this?" when opened, and so a stale one is
# visible rather than inert.
#
# A bare integer is also accepted (`echo 2 > .scribe`), because it is what a
# A bare integer is also accepted (`echo 2 > .grimoire`), because it is what a
# person writes by hand and it parses as JSON already. It skips the instance
# check by having nothing to check — deliberate, and the reason the written
# form carries `instance`.
@@ -1332,13 +1332,13 @@ scribe_turn_fact() {
# a remote is just where the code happens to be pushed. That also makes the
# marker the way to override a binding for one directory.
# Nearest `.scribe` at or above DIR. Walks up to the filesystem root, capped so
# Nearest `.grimoire` at or above DIR. Walks up to the filesystem root, capped so
# a pathological path cannot spin.
scribe_marker_file() {
grimoire_marker_file() {
local dir="$1" depth=0
[ -n "$dir" ] || return 0
while [ "$depth" -lt 40 ]; do
[ -f "$dir/.scribe" ] && { printf '%s' "$dir/.scribe"; return 0; }
[ -f "$dir/.grimoire" ] && { printf '%s' "$dir/.grimoire"; return 0; }
case "$dir" in ""|"/") return 0 ;; esac
dir=$(dirname -- "$dir" 2>/dev/null) || return 0
depth=$((depth + 1))
@@ -1347,7 +1347,7 @@ scribe_marker_file() {
}
# Host of a URL, lowercased, port kept. "" for empty input.
scribe_url_host() {
grimoire_url_host() {
printf '%s' "${1:-}" \
| sed -e 's#^[A-Za-z][A-Za-z0-9+.-]*://##' -e 's#^[^/@]*@##' -e 's#[/?].*$##' \
| tr 'A-Z' 'a-z'
@@ -1364,24 +1364,24 @@ scribe_url_host() {
# subshell. The caller would then read an unset variable, which under the
# `set -u` these hooks all run with aborts the hook and costs the whole
# session's context — a failure far larger than the message it was fetching.
scribe_marker_read() {
grimoire_marker_read() {
local f="$1" flat id inst want
[ -n "$f" ] && [ -f "$f" ] || { printf '\t'; return 0; }
flat=$(scribe_json_flat < "$f") || flat=""
flat=$(grimoire_json_flat < "$f") || flat=""
# Both accepted forms, read off one parse: the object's `project_id`, or —
# for the bare integer someone writes by hand — the root scalar, whose path
# is the empty string because it sits under no key.
id=$(scribe_json_pick "$flat" '.project_id')
[ -n "$id" ] || id=$(scribe_json_pick "$flat" '')
id=$(grimoire_json_pick "$flat" '.project_id')
[ -n "$id" ] || id=$(grimoire_json_pick "$flat" '')
case "$id" in ''|*[!0-9]*) id="" ;; esac
if [ -z "$id" ] || [ "$id" = "0" ]; then
printf '\tnames no project_id'
return 0
fi
inst=$(scribe_json_pick "$flat" '.instance')
inst=$(grimoire_json_pick "$flat" '.instance')
if [ -n "$inst" ]; then
want=$(scribe_url_host "${url:-}")
inst=$(scribe_url_host "$inst")
want=$(grimoire_url_host "${url:-}")
inst=$(grimoire_url_host "$inst")
if [ -n "$want" ] && [ "$inst" != "$want" ]; then
printf '\tpoints at %s, but this session is configured for %s' "$inst" "$want"
return 0
@@ -1390,25 +1390,25 @@ scribe_marker_read() {
printf '%s\t' "$id"
}
# Just the id a marker names, or "" — the half scribe_scope_query needs.
scribe_marker_project() {
scribe_marker_read "$1" | cut -f1
# Just the id a marker names, or "" — the half grimoire_scope_query needs.
grimoire_marker_project() {
grimoire_marker_read "$1" | cut -f1
}
# The query args identifying DIR's project — `project_id=N` or `repo=<enc>` —
# with NO leading `?` or `&`, so each caller keeps its own separator. Empty
# when neither key is available. Requires `url` to be set (scribe_config) for
# when neither key is available. Requires `url` to be set (grimoire_config) for
# the marker's instance check; without it a marker is still honoured, since an
# unconfigured hook is not going to send the request anyway.
scribe_scope_query() {
grimoire_scope_query() {
local dir="$1" id repo enc
id=$(scribe_marker_project "$(scribe_marker_file "$dir")")
id=$(grimoire_marker_project "$(grimoire_marker_file "$dir")")
if [ -n "$id" ]; then
printf 'project_id=%s' "$id"
return 0
fi
repo=$(git -C "$dir" remote get-url origin 2>/dev/null || true)
[ -n "$repo" ] || return 0
enc=$(printf '%s' "$repo" | scribe_urlenc) || enc=""
enc=$(printf '%s' "$repo" | grimoire_urlenc) || enc=""
[ -n "$enc" ] && printf 'repo=%s' "$enc"
}
@@ -23,7 +23,7 @@
# VALUE for a string, the RAW JSON-escaped body with the quotes stripped — not
# the decoded text. Decoding here would let a newline or a tab inside a
# value break the line format that makes this readable from shell. The
# caller decodes what it actually uses, with `scribe_json_unescape`.
# caller decodes what it actually uses, with `grimoire_json_unescape`.
# Numbers, `true`, `false` and `null` are emitted literally.
#
# EVERY ARRAY ALSO EMITS `PATH[#]` with its LENGTH, including an empty one —
@@ -16,53 +16,53 @@
# other tool costs one file read. An install that has mounted nothing gets an
# empty list and never sends a moment request at all.
#
# SCRIBE'S OWN TOOLS ARE SKIPPED for delivery. Closing a task or recording a
# GRIMOIRE'S OWN TOOLS ARE SKIPPED for delivery. Closing a task or recording a
# lesson reaches its moment through the tool's own response (`moment_rules`),
# which works for a client without this plugin too; delivering it here as well
# would say it twice. They are still written to the acts ledger below.
#
# SILENT ON OUTAGE, like scribe_tool_rules.sh and for its reason: this fires
# SILENT ON OUTAGE, like grimoire_tool_rules.sh and for its reason: this fires
# before many calls, and an outage line before each is noise. A failed list
# fetch is cached as an empty list, so a down instance costs one timeout per
# window rather than one per call.
#
# Env:
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
# GRIMOIRE_URL / GRIMOIRE_TOKEN override for the settings.json dogfooding path.
command -v curl >/dev/null 2>&1 || exit 0
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# shellcheck source=plugin/hooks/grimoire_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/grimoire_defs.sh"
event=$(cat 2>/dev/null || true)
event_flat=$(printf '%s' "$event" | scribe_json_flat)
tool_name=$(scribe_json_pick "$event_flat" '.tool_name')
session_id=$(scribe_json_pick "$event_flat" '.session_id')
event_cwd=$(scribe_json_pick "$event_flat" '.cwd')
event_flat=$(printf '%s' "$event" | grimoire_json_flat)
tool_name=$(grimoire_json_pick "$event_flat" '.tool_name')
session_id=$(grimoire_json_pick "$event_flat" '.session_id')
event_cwd=$(grimoire_json_pick "$event_flat" '.cwd')
[ -n "$tool_name" ] && [ -n "$session_id" ] || exit 0
scribe_config || exit 0
grimoire_config || exit 0
# The tool's KEY, as the server writes mappings: no MCP server prefix, lowercase.
tool_key=${tool_name##*__}
tool_key=$(printf '%s' "$tool_key" | tr '[:upper:]' '[:lower:]')
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
state_dir="${TMPDIR:-/tmp}/grimoire-priorart"
mkdir -p "$state_dir" 2>/dev/null || true
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
# Its own directory, outside SCRIBE_LEDGER_DIRS, so a compaction does not
# Its own directory, outside GRIMOIRE_LEDGER_DIRS, so a compaction does not
# sweep it: what is kept here describes the install and what the session
# DID, not what its context holds. scribe_session_end.sh removes it.
cache_dir="${TMPDIR:-/tmp}/scribe-moment"
# DID, not what its context holds. grimoire_session_end.sh removes it.
cache_dir="${TMPDIR:-/tmp}/grimoire-moment"
mkdir -p "$cache_dir" 2>/dev/null || true
now=$(date +%s 2>/dev/null) || now=0
# ── The acts ledger ──────────────────────────────────────────────────────
#
# Every call, before any filter: `epoch<TAB>event`, one line each. When the
# session then opens a rule, scribe_record_opened.sh sends the last few
# session then opens a rule, grimoire_record_opened.sh sends the last few
# minutes of these, and the server counts the open as evidence the rule
# belongs on the moments they reached — which is how a rule nobody mounted
# gets proposed for one. Recorded whether or not anything is mounted, since
@@ -73,7 +73,7 @@ if [ "$now" -gt 0 ] && [ "$tool_key" != "get_rule" ]; then
acts_file="$cache_dir/${safe_sid}.acts"
act=$(printf '%s' "$event" | tr -d '\n\r')
if [ "${#act}" -gt 16384 ]; then
esc=$(printf '%s' "$tool_name" | scribe_json_escape) || esc=""
esc=$(printf '%s' "$tool_name" | grimoire_json_escape) || esc=""
act="{\"tool_name\":\"${esc}\",\"tool_input\":{}}"
fi
printf '%s\t%s\n' "$now" "$act" >> "$acts_file" 2>/dev/null || true
@@ -86,7 +86,7 @@ if [ "$now" -gt 0 ] && [ "$tool_key" != "get_rule" ]; then
fi
case "$tool_name" in
mcp__*scribe*__*) exit 0 ;;
mcp__*grimoire*__*) exit 0 ;;
esac
# ── Which tools can reach a mounted rule: cached, first line a timestamp ──
@@ -103,7 +103,7 @@ if [ "$now" -eq 0 ] || [ $((now - stamp)) -gt 300 ]; then
"${url%/}/api/plugin/moment-tools" 2>/dev/null) || listed=""
{
printf '%s\n' "$now"
scribe_json_list "$(printf '%s' "$listed" | scribe_json_flat)" '.tools'
grimoire_json_list "$(printf '%s' "$listed" | grimoire_json_flat)" '.tools'
} > "$tools_file" 2>/dev/null || true
fi
tail -n +2 "$tools_file" 2>/dev/null | grep -Fqx -- "$tool_key" || exit 0
@@ -112,27 +112,27 @@ tail -n +2 "$tools_file" 2>/dev/null | grep -Fqx -- "$tool_key" || exit 0
repo_q=""
lookup_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
scope=$(scribe_scope_query "$lookup_dir")
scope=$(grimoire_scope_query "$lookup_dir")
[ -n "$scope" ] && repo_q="&${scope}"
# The shared session ledger, as scribe_tool_rules.sh reads and writes it: a
# The shared session ledger, as grimoire_tool_rules.sh reads and writes it: a
# rule this session was already shown is cited, not quoted again.
rulefile="$state_dir/${safe_sid}.rules.ids"
ledger_q=""
rule_seen=$(scribe_rules_live "$rulefile")
rule_seen=$(grimoire_rules_live "$rulefile")
[ -n "$rule_seen" ] && ledger_q="&exclude_rule_ids=${rule_seen}"
ledger_q="${ledger_q}$(scribe_held_query "$state_dir/${safe_sid}.opened.ids")"
ledger_q="${ledger_q}$(grimoire_held_query "$state_dir/${safe_sid}.opened.ids")"
# The reply-shape ledger: a shape this session already holds in
# full comes back as its reminder.
shapefile=$(scribe_shapes_file "$safe_sid")
shapes_seen=$(scribe_shapes_seen "$shapefile")
shapefile=$(grimoire_shapes_file "$safe_sid")
shapes_seen=$(grimoire_shapes_seen "$shapefile")
[ -n "$shapes_seen" ] && ledger_q="${ledger_q}&shapes_seen=${shapes_seen}"
# The event whole, since which field a mapping matches is the mapping's
# business. A write of a very large file is reduced to its name: the moments a
# write reaches never depend on its content.
if [ "${#event}" -gt 65536 ]; then
esc=$(printf '%s' "$tool_name" | scribe_json_escape) || exit 0
esc=$(printf '%s' "$tool_name" | grimoire_json_escape) || exit 0
event="{\"tool_name\":\"${esc}\",\"tool_input\":{}}"
fi
@@ -144,11 +144,11 @@ body=$(printf '%s' "$event" | curl -fsS --max-time 3 \
--data-binary @- \
"${url%/}/api/plugin/moment${query:+?$query}" 2>/dev/null) || exit 0
body_flat=$(printf '%s' "$body" | scribe_json_flat)
scribe_json_list "$body_flat" '.rule_ids' | scribe_rules_append "$rulefile"
scribe_json_list "$body_flat" '.shape_keys' | scribe_shapes_append "$shapefile"
body_flat=$(printf '%s' "$body" | grimoire_json_flat)
grimoire_json_list "$body_flat" '.rule_ids' | grimoire_rules_append "$rulefile"
grimoire_json_list "$body_flat" '.shape_keys' | grimoire_shapes_append "$shapefile"
context=$(scribe_json_pick "$body_flat" '.context')
context=$(grimoire_json_pick "$body_flat" '.context')
[ -n "$context" ] || exit 0
scribe_json_out PreToolUse "$context"
grimoire_json_out PreToolUse "$context"
exit 0
@@ -40,8 +40,8 @@
# text is the receipt. (Auto-compaction suppresses that notification.)
set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# shellcheck source=plugin/hooks/grimoire_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/grimoire_defs.sh"
# THE EVENT IS READ for `session_id`: the slippage readout below is specific
# to THIS session, and the id is the key to the ledgers that hold it. The
@@ -51,8 +51,8 @@ set -uo pipefail
event=$(cat 2>/dev/null || true)
session_id=""
if [ -n "$event" ]; then
event_flat=$(printf '%s' "$event" | scribe_json_flat 2>/dev/null || true)
session_id=$(scribe_json_pick "$event_flat" '.session_id' 2>/dev/null || true)
event_flat=$(printf '%s' "$event" | grimoire_json_flat 2>/dev/null || true)
session_id=$(grimoire_json_pick "$event_flat" '.session_id' 2>/dev/null || true)
fi
cat <<'EOF'
@@ -94,7 +94,7 @@ EOF
# readout teaches its reader to skip it.
if [ -n "$session_id" ]; then
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
slippage=$(scribe_slippage_lines "${TMPDIR:-/tmp}/scribe-priorart" "$safe_sid" 2>/dev/null || true)
slippage=$(grimoire_slippage_lines "${TMPDIR:-/tmp}/grimoire-priorart" "$safe_sid" 2>/dev/null || true)
if [ -n "$slippage" ]; then
printf '\n%s\n' "$slippage"
fi
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Grimoire plugin — PreToolUse write-path trigger (prior-art recall).
#
# Auto-inject (scribe_autoinject.sh) fires on the operator's prompt. The moment
# Auto-inject (grimoire_autoinject.sh) fires on the operator's prompt. The moment
# reuse is actually lost is later: when the AGENT decides mid-task to write a
# helper. This hook fires there — on Write/Edit — and asks the operator's Grimoire
# instance what prior art is already recorded for the target file: a snippet at
@@ -31,38 +31,38 @@
# hook then exits 0 in silence, looking exactly like "no prior art"):
# 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.
# GRIMOIRE_URL / GRIMOIRE_TOKEN override for the settings.json dogfooding path.
set -uo pipefail
command -v curl >/dev/null 2>&1 || exit 0
# Shared with the after-write hook: the prose/data skip list, the definition
# extractor and the local by-name duplicate arm live in scribe_defs.sh so the
# extractor and the local by-name duplicate arm live in grimoire_defs.sh so the
# two hooks cannot drift apart. Sourced FIRST because the JSON reader is there
# too.
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# shellcheck source=plugin/hooks/grimoire_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/grimoire_defs.sh"
# PreToolUse delivers { session_id, cwd, tool_name, tool_input: {...}, ... }
# One parse, five fields. `.tool_input.content` on a Write is the entire file
# being written, so parsing per field would mean reading it five times.
event=$(cat 2>/dev/null || true)
event_flat=$(printf '%s' "$event" | scribe_json_flat)
file_path=$(scribe_json_pick "$event_flat" '.tool_input.file_path')
session_id=$(scribe_json_pick "$event_flat" '.session_id')
event_cwd=$(scribe_json_pick "$event_flat" '.cwd')
event_flat=$(printf '%s' "$event" | grimoire_json_flat)
file_path=$(grimoire_json_pick "$event_flat" '.tool_input.file_path')
session_id=$(grimoire_json_pick "$event_flat" '.session_id')
event_cwd=$(grimoire_json_pick "$event_flat" '.cwd')
[ -n "$file_path" ] || exit 0
# The code about to be written. Write and Edit name this field differently, and
# the names have changed across Claude Code versions — take whichever is present
# rather than betting on one shape.
code=$(scribe_json_pick "$event_flat" '.tool_input.content')
[ -n "$code" ] || code=$(scribe_json_pick "$event_flat" '.tool_input.file_content')
[ -n "$code" ] || code=$(scribe_json_pick "$event_flat" '.tool_input.new_string')
[ -n "$code" ] || code=$(scribe_json_pick "$event_flat" '.tool_input.new_str')
code=$(grimoire_json_pick "$event_flat" '.tool_input.content')
[ -n "$code" ] || code=$(grimoire_json_pick "$event_flat" '.tool_input.file_content')
[ -n "$code" ] || code=$(grimoire_json_pick "$event_flat" '.tool_input.new_string')
[ -n "$code" ] || code=$(grimoire_json_pick "$event_flat" '.tool_input.new_str')
scribe_skip_path "$file_path" && exit 0
grimoire_skip_path "$file_path" && exit 0
# Snippet locations are recorded repo-relative, so send a repo-relative path —
# an absolute one would simply match nothing. Resolved BEFORE the config gate
@@ -78,16 +78,16 @@ if [ -n "$repo_root" ]; then
fi
# ARM 1 — BY NAME, LOCALLY: does a definition of this already exist
# in the repo? (scribe_local_dups in scribe_defs.sh carries the why.)
# in the repo? (grimoire_local_dups in grimoire_defs.sh carries the why.)
names=""
if [ -n "$code" ]; then
# `|| true` inside: an early-exiting `head` must not void its own output.
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12 || true)
names=$(printf '%s' "$code" | grimoire_defs | sort -u | head -12 || true)
fi
local_lines=""
if [ -n "$repo_root" ] && [ -n "$names" ]; then
local_lines=$(scribe_local_dups "$repo_root" "$rel_path" <<< "$names") || local_lines=""
local_lines=$(grimoire_local_dups "$repo_root" "$rel_path" <<< "$names") || local_lines=""
[ -n "$local_lines" ] && local_lines="${local_lines}"$'\n'
fi
@@ -114,13 +114,13 @@ fi
# and no `head -1`, whose early exit is a SIGPIPE trap under pipefail.
shapes="$names"
if [ -z "$shapes" ] && [ -f "$file_path" ]; then
old_first=$(scribe_json_pick "$event_flat" '.tool_input.old_string')
[ -n "$old_first" ] || old_first=$(scribe_json_pick "$event_flat" '.tool_input.old_str')
old_first=$(grimoire_json_pick "$event_flat" '.tool_input.old_string')
[ -n "$old_first" ] || old_first=$(grimoire_json_pick "$event_flat" '.tool_input.old_str')
old_first=$(printf '%s' "$old_first" | grep -m1 -v '^[[:space:]]*$' || true)
if [ -n "$old_first" ]; then
ln=$(grep -nF -m1 -- "$old_first" "$file_path" 2>/dev/null | cut -d: -f1) || ln=""
if [ -n "$ln" ]; then
shapes=$(head -n "$ln" "$file_path" | scribe_defs | tail -1 || true)
shapes=$(head -n "$ln" "$file_path" | grimoire_defs | tail -1 || true)
fi
fi
fi
@@ -128,16 +128,16 @@ shapes_q=""
if [ -n "$shapes" ]; then
enc=$(printf '%s\n' "$shapes" \
| awk -F'\t' 'NF>=2 {printf "%s%s:%s", (n++?",":""), $1, $2}' \
| scribe_urlenc) || enc=""
| grimoire_urlenc) || enc=""
[ -n "$enc" ] && shapes_q="&shapes=${enc}"
fi
scribe_config || : # sets url/token; unconfigured is handled just below
grimoire_config || : # sets url/token; unconfigured is handled just below
# 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
scribe_json_out PreToolUse "$local_context"
grimoire_json_out PreToolUse "$local_context"
fi
exit 0
fi
@@ -153,13 +153,13 @@ q=$(printf '%s' "$code" | head -c 1200)
# Encoded whole, never line by line: a payload encoded per line comes back as
# separately-encoded lines joined by raw newlines, an invalid URL, and code is
# full of newlines. scribe_urlenc reads bytes and has no notion of a line.
path_enc=$(printf '%s' "$rel_path" | scribe_urlenc)
# full of newlines. grimoire_urlenc reads bytes and has no notion of a line.
path_enc=$(printf '%s' "$rel_path" | grimoire_urlenc)
[ -n "$path_enc" ] || exit 0
code_enc=$(printf '%s' "$q" | scribe_urlenc)
code_enc=$(printf '%s' "$q" | grimoire_urlenc)
# Scope to this directory's project — a `.scribe` marker, else the git remote.
scope=$(scribe_scope_query "$lookup_dir")
# Scope to this directory's project — a `.grimoire` marker, else the git remote.
scope=$(grimoire_scope_query "$lookup_dir")
repo_q=""
[ -n "$scope" ] && repo_q="&${scope}"
@@ -173,7 +173,7 @@ repo_q=""
# exact file being edited — updating the record is part of the edit"). They
# dedup separately: a reuse hint shown early in the session must not suppress
# the sync nudge when the recorded file itself is edited later.
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
state_dir="${TMPDIR:-/tmp}/grimoire-priorart"
mkdir -p "$state_dir" 2>/dev/null || true
#
# A THIRD channel: the ledger's derive arm names a duplicate family
@@ -186,7 +186,7 @@ mkdir -p "$state_dir" 2>/dev/null || true
# re-offered on every subsequent write in the session.
#
# A FIFTH: the Systems whose RULINGS were shown in full. SHARED
# with scribe_tool_rules.sh, like the rule file: an area's rulings shown before
# with grimoire_tool_rules.sh, like the rule file: an area's rulings shown before
# a command must not be repeated in full before the next edit there.
idfile=""
syncfile=""
@@ -202,18 +202,18 @@ if [ -n "$session_id" ]; then
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
# What this write defined, for the end-of-turn question. A
# Write onto a path that does not exist yet creates a file, which may be a
# unit in its own right (see scribe_written_append). Written before any
# unit in its own right (see grimoire_written_append). Written before any
# server call: the ask at the end of the turn must not depend on this
# write's hint having been answered.
fresh=""
[ -e "$file_path" ] || fresh="new"
printf '%s\n' "$shapes" | scribe_written_append "$safe_sid" "$rel_path" "$fresh" "$code"
printf '%s\n' "$shapes" | grimoire_written_append "$safe_sid" "$rel_path" "$fresh" "$code"
idfile="$state_dir/${safe_sid}.ids"
syncfile="$state_dir/${safe_sid}.sync.ids"
derivefile="$state_dir/${safe_sid}.derive.ids"
rulefile="$state_dir/${safe_sid}.rules.ids"
rulingsfile="$state_dir/${safe_sid}.rulings.ids"
rulings_q=$(scribe_rulings_query "$rulingsfile")
rulings_q=$(grimoire_rulings_query "$rulingsfile")
if [ -f "$idfile" ]; then
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
@@ -223,16 +223,16 @@ if [ -n "$session_id" ]; then
[ -n "$sync_seen" ] && sync_exclude_q="&exclude_sync_ids=${sync_seen}"
fi
if [ -f "$derivefile" ]; then
derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | scribe_urlenc) || derive_seen=""
derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | grimoire_urlenc) || derive_seen=""
[ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}"
fi
# Ageing, not a flat read, and the ONLY ledger here that ages: the
# note channels above are a different question with a different answer, and
# this arm's sibling hook reads the same rule file through the same helper.
rule_seen=$(scribe_rules_live "$rulefile")
rule_seen=$(grimoire_rules_live "$rulefile")
[ -n "$rule_seen" ] && rule_exclude_q="&exclude_rule_ids=${rule_seen}"
# What the session actually OPENED, as against what it was shown.
rule_exclude_q="${rule_exclude_q}$(scribe_held_query "$state_dir/${safe_sid}.opened.ids")"
rule_exclude_q="${rule_exclude_q}$(grimoire_held_query "$state_dir/${safe_sid}.opened.ids")"
fi
# Not `|| exit 0`: an unreachable instance must not discard a local finding
@@ -245,34 +245,34 @@ body=$(curl -fsS --max-time 5 \
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${derive_exclude_q}${rule_exclude_q}${rulings_q}${shapes_q}" 2>/dev/null) || { body=""; reached=0; }
unreached_context=""
if [ "$reached" = 1 ]; then
scribe_reached "$state_dir" "${safe_sid:-nosession}"
grimoire_reached "$state_dir" "${safe_sid:-nosession}"
else
unreached_context=$(scribe_unreached "$state_dir" "${safe_sid:-nosession}" 5 "$rel_path")
unreached_context=$(grimoire_unreached "$state_dir" "${safe_sid:-nosession}" 5 "$rel_path")
fi
context=""
body_flat=""
if [ -n "$body" ]; then
body_flat=$(printf '%s' "$body" | scribe_json_flat)
context=$(scribe_json_pick "$body_flat" '.context')
body_flat=$(printf '%s' "$body" | grimoire_json_flat)
context=$(grimoire_json_pick "$body_flat" '.context')
# Remember what was surfaced so it isn't shown again this session — each
# class into its own channel: sync ids (snippets recording the edited file)
# to the sync file, everything else to the reuse file.
if [ -n "$context" ]; then
if [ -n "$idfile" ]; then
scribe_json_list_minus "$body_flat" '.note_ids' '.sync_note_ids' >> "$idfile" || true
grimoire_json_list_minus "$body_flat" '.note_ids' '.sync_note_ids' >> "$idfile" || true
fi
if [ -n "$syncfile" ]; then
scribe_json_list "$body_flat" '.sync_note_ids' >> "$syncfile" || true
grimoire_json_list "$body_flat" '.sync_note_ids' >> "$syncfile" || true
fi
if [ -n "$rulefile" ]; then
scribe_json_list "$body_flat" '.rule_ids' | scribe_rules_append "$rulefile"
grimoire_json_list "$body_flat" '.rule_ids' | grimoire_rules_append "$rulefile"
fi
if [ -n "$derivefile" ]; then
scribe_json_list "$body_flat" '.derive_keys' >> "$derivefile" || true
grimoire_json_list "$body_flat" '.derive_keys' >> "$derivefile" || true
fi
if [ -n "$rulingsfile" ]; then
scribe_json_list "$body_flat" '.ruling_system_ids' >> "$rulingsfile" || true
grimoire_json_list "$body_flat" '.ruling_system_ids' >> "$rulingsfile" || true
fi
fi
fi
@@ -287,7 +287,7 @@ fi
# of those copies is recorded" is a claim only an answer can back — the
# unreached line says what actually happened instead.
if [ -n "$local_lines" ] && [ "$reached" = 1 ]; then
n_recorded=$(scribe_json_len "$body_flat" '.note_ids')
n_recorded=$(grimoire_json_len "$body_flat" '.note_ids')
if [ "${n_recorded:-0}" = "0" ] || [ "$n_recorded" = "" ]; then
local_context="${local_context}"$'\n'"> None of those existing copies is recorded in Grimoire. If the version being written is the canonical one — or this edit is consolidating the copies — record it now with create_snippet (name, code, when-to-reach-for-it, location) so the next session is offered it instead of writing another copy."
fi
@@ -309,19 +309,19 @@ fi
# no "before" and nothing to break.
contract_context=""
if [ -n "$repo_root" ] && [ -n "$code" ]; then
old_code=$(scribe_json_pick "$event_flat" '.tool_input.old_string')
[ -n "$old_code" ] || old_code=$(scribe_json_pick "$event_flat" '.tool_input.old_str')
old_code=$(grimoire_json_pick "$event_flat" '.tool_input.old_string')
[ -n "$old_code" ] || old_code=$(grimoire_json_pick "$event_flat" '.tool_input.old_str')
# The SUBJECT is the definition whose contract may have moved. `shapes`
# already holds either the definitions in the payload or — for an edit
# inside a function body — the one enclosing the edit, which is exactly the
# thing whose callers matter. CSS rows are skipped: a class has readers, but
# they are markup files and `scribe_local_dups` already speaks for those.
# they are markup files and `grimoire_local_dups` already speaks for those.
subject=$(printf '%s\n' "$shapes" \
| awk -F'\t' 'NF>=2 && $1!="css" {print $2; exit}')
if [ -n "$old_code" ] && [ -n "$subject" ]; then
contract_file=""
[ -n "${safe_sid:-}" ] && contract_file="$state_dir/${safe_sid}.contract.ids"
contract_context=$(scribe_contract_block \
contract_context=$(grimoire_contract_block \
"$repo_root" "$rel_path" "$subject" "$old_code" "$code" "$contract_file" \
2>/dev/null) || contract_context=""
fi
@@ -347,5 +347,5 @@ fi
[ -n "$combined" ] || exit 0
# No permissionDecision: this is a nudge, not a gate. The write goes ahead.
scribe_json_out PreToolUse "$combined"
grimoire_json_out PreToolUse "$combined"
exit 0
@@ -40,46 +40,46 @@
# here is one extra line, in the direction that shows more rather than less.
set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# shellcheck source=plugin/hooks/grimoire_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/grimoire_defs.sh"
event=$(cat 2>/dev/null || true)
[ -n "$event" ] || exit 0
event_flat=$(printf '%s' "$event" | scribe_json_flat)
session_id=$(scribe_json_pick "$event_flat" '.session_id')
event_flat=$(printf '%s' "$event" | grimoire_json_flat)
session_id=$(grimoire_json_pick "$event_flat" '.session_id')
[ -n "$session_id" ] || exit 0
# The matcher in hooks.json already narrows to the get_rule tools, but the
# server segment of an MCP tool name varies with how the plugin was installed,
# so the id is read from whichever field is actually present rather than from
# an assumed tool name. An event that carries none simply records nothing.
rule_id=$(scribe_json_pick "$event_flat" '.tool_input.rule_id')
rule_id=$(grimoire_json_pick "$event_flat" '.tool_input.rule_id')
rule_id=$(printf '%s' "$rule_id" | tr -cd '0-9')
[ -n "$rule_id" ] || exit 0
# The same directory the naming ledger uses. One session keeps its state in one
# place, and the prior-art name is kept for the reason scribe_tool_rules.sh
# place, and the prior-art name is kept for the reason grimoire_tool_rules.sh
# gives: renaming it would orphan every live session's state for a cosmetic
# gain.
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
state_dir="${TMPDIR:-/tmp}/grimoire-priorart"
mkdir -p "$state_dir" 2>/dev/null || true
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
# Stamped and append-only, exactly like the naming ledger — so the same reader
# (`scribe_rules_live`) ages both, and the last entry for an id wins.
printf '%s\n' "$rule_id" | scribe_rules_append "$state_dir/${safe_sid}.opened.ids"
# (`grimoire_rules_live`) ages both, and the last entry for an id wins.
printf '%s\n' "$rule_id" | grimoire_rules_append "$state_dir/${safe_sid}.opened.ids"
# ── What came just before the open ───────────────────────────────────────
#
# scribe_moment.sh writes every call to `scribe-moment/<sid>.acts`. The ones
# grimoire_moment.sh writes every call to `grimoire-moment/<sid>.acts`. The ones
# from the last three minutes go to the server with this rule's id: a rule
# opened just after a moment fired is evidence it belongs on that moment, and
# once that repeats across sessions the server answers with one line asking
# the reader to offer the operator the mount. Nothing is mounted by this.
command -v curl >/dev/null 2>&1 || exit 0
scribe_config || exit 0
acts_file="${TMPDIR:-/tmp}/scribe-moment/${safe_sid}.acts"
grimoire_config || exit 0
acts_file="${TMPDIR:-/tmp}/grimoire-moment/${safe_sid}.acts"
[ -f "$acts_file" ] || exit 0
now=$(date +%s 2>/dev/null) || exit 0
acts=$(awk -F '\t' -v since=$((now - 180)) \
@@ -87,9 +87,9 @@ acts=$(awk -F '\t' -v since=$((now - 180)) \
| tail -n 30 | paste -sd ',' - 2>/dev/null)
[ -n "$acts" ] || exit 0
sid_esc=$(printf '%s' "$session_id" | scribe_json_escape) || exit 0
event_cwd=$(scribe_json_pick "$event_flat" '.cwd')
scope=$(scribe_scope_query "${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}")
sid_esc=$(printf '%s' "$session_id" | grimoire_json_escape) || exit 0
event_cwd=$(grimoire_json_pick "$event_flat" '.cwd')
scope=$(grimoire_scope_query "${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}")
body=$(printf '{"rule_id":%s,"session_id":"%s","acts":[%s]}' "$rule_id" "$sid_esc" "$acts" \
| curl -fsS --max-time 3 \
-H "Authorization: Bearer ${token}" \
@@ -97,7 +97,7 @@ body=$(printf '{"rule_id":%s,"session_id":"%s","acts":[%s]}' "$rule_id" "$sid_es
--data-binary @- \
"${url%/}/api/plugin/rule-opened${scope:+?$scope}" 2>/dev/null) || exit 0
context=$(scribe_json_pick "$(printf '%s' "$body" | scribe_json_flat)" '.context')
context=$(grimoire_json_pick "$(printf '%s' "$body" | grimoire_json_flat)" '.context')
[ -n "$context" ] || exit 0
scribe_json_out PostToolUse "$context"
grimoire_json_out PostToolUse "$context"
exit 0
@@ -28,28 +28,28 @@
# turn a successful tool call into a hook error.
set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# shellcheck source=plugin/hooks/grimoire_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/grimoire_defs.sh"
event=$(cat 2>/dev/null || true)
[ -n "$event" ] || exit 0
event_flat=$(printf '%s' "$event" | scribe_json_flat)
session_id=$(scribe_json_pick "$event_flat" '.session_id')
event_flat=$(printf '%s' "$event" | grimoire_json_flat)
session_id=$(grimoire_json_pick "$event_flat" '.session_id')
[ -n "$session_id" ] || exit 0
# The matcher in hooks.json narrows to the rule_outcome tools, but the server
# segment of an MCP tool name varies with how the plugin was installed, so the
# id is read from the field rather than from an assumed tool name — the same
# reasoning scribe_record_opened.sh gives.
rule_id=$(scribe_json_pick "$event_flat" '.tool_input.rule_id')
# reasoning grimoire_record_opened.sh gives.
rule_id=$(grimoire_json_pick "$event_flat" '.tool_input.rule_id')
rule_id=$(printf '%s' "$rule_id" | tr -cd '0-9')
[ -n "$rule_id" ] || exit 0
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
state_dir="${TMPDIR:-/tmp}/grimoire-priorart"
mkdir -p "$state_dir" 2>/dev/null || true
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
# Stamped and append-only like its two siblings, so one reader ages them all.
printf '%s\n' "$rule_id" | scribe_rules_append "$state_dir/${safe_sid}.acted.ids"
printf '%s\n' "$rule_id" | grimoire_rules_append "$state_dir/${safe_sid}.acted.ids"
exit 0
@@ -26,23 +26,23 @@
# not stop the reply about it, and the per-session cap counts both doors.
#
# Env:
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
# GRIMOIRE_URL / GRIMOIRE_TOKEN override for the settings.json dogfooding path.
command -v curl >/dev/null 2>&1 || exit 0
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# shellcheck source=plugin/hooks/grimoire_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/grimoire_defs.sh"
event=$(cat 2>/dev/null || true)
event_flat=$(printf '%s' "$event" | scribe_json_flat)
transcript=$(scribe_json_pick "$event_flat" '.transcript_path')
session_id=$(scribe_json_pick "$event_flat" '.session_id')
active=$(scribe_json_pick "$event_flat" '.stop_hook_active')
event_cwd=$(scribe_json_pick "$event_flat" '.cwd')
event_flat=$(printf '%s' "$event" | grimoire_json_flat)
transcript=$(grimoire_json_pick "$event_flat" '.transcript_path')
session_id=$(grimoire_json_pick "$event_flat" '.session_id')
active=$(grimoire_json_pick "$event_flat" '.stop_hook_active')
event_cwd=$(grimoire_json_pick "$event_flat" '.cwd')
[ -n "$transcript" ] && [ -f "$transcript" ] && [ -n "$session_id" ] || exit 0
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
state_dir="${TMPDIR:-/tmp}/grimoire-priorart"
mkdir -p "$state_dir" 2>/dev/null || true
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
rulefile="$state_dir/${safe_sid}.rules.ids"
@@ -62,21 +62,21 @@ else
rm -f "$reportfile" 2>/dev/null || true
rewrite=false
fi
scribe_config || exit 0
grimoire_config || exit 0
facts=$(scribe_turn_facts "$transcript")
[ "$(scribe_turn_fact "$facts" bounded)" = "1" ] || exit 0
reply=$(scribe_turn_fact "$facts" reply | scribe_json_unescape)
facts=$(grimoire_turn_facts "$transcript")
[ "$(grimoire_turn_fact "$facts" bounded)" = "1" ] || exit 0
reply=$(grimoire_turn_fact "$facts" reply | grimoire_json_unescape)
# The reply may not be in the transcript yet when the hook fires: an empty
# reply is "cannot tell", never "missing everything".
[ -n "$(printf '%s' "$reply" | tr -d '[:space:]')" ] || exit 0
# How many tasks this turn closed, and which — successful closes only, this
# session only (scribe_turn.awk). A task created already done closes with no
# session only (grimoire_turn.awk). A task created already done closes with no
# id, so the count is what says a check is due. Digits and commas only, so
# both drop straight into the JSON.
closed=$(scribe_turn_fact "$facts" closed | tr -cd '0-9')
closed=$(grimoire_turn_fact "$facts" closed | tr -cd '0-9')
closed=${closed:-0}
task_ids=$(scribe_turn_fact "$facts" task_ids | tr -cd '0-9,' | sed 's/,,*/,/g; s/^,//; s/,$//')
task_ids=$(grimoire_turn_fact "$facts" task_ids | tr -cd '0-9,' | sed 's/,,*/,/g; s/^,//; s/,$//')
[ "$rewrite" = "true" ] && [ "$closed" = "0" ] && exit 0
# Bounded before encoding. The server reads the head and the tail — the part
@@ -85,18 +85,18 @@ task_ids=$(scribe_turn_fact "$facts" task_ids | tr -cd '0-9,' | sed 's/,,*/,/g;
if [ "${#reply}" -gt 12000 ]; then
reply="${reply:0:4000} … ${reply: -8000}"
fi
reply_esc=$(printf '%s' "$reply" | scribe_json_escape) || exit 0
reply_esc=$(printf '%s' "$reply" | grimoire_json_escape) || exit 0
closing=""
if [ "$closed" != "0" ]; then
closing=$(printf ',"closed":%d,"closed_task_ids":[%s],"rewrite":%s' "$closed" "$task_ids" "$rewrite")
fi
query=""
scope=$(scribe_scope_query "${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}")
scope=$(grimoire_scope_query "${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}")
[ -n "$scope" ] && query="&${scope}"
rule_seen=$(scribe_rules_live "$rulefile")
rule_seen=$(grimoire_rules_live "$rulefile")
[ -n "$rule_seen" ] && query="${query}&exclude_rule_ids=${rule_seen}"
query="${query}$(scribe_held_query "$state_dir/${safe_sid}.opened.ids")"
query="${query}$(grimoire_held_query "$state_dir/${safe_sid}.opened.ids")"
if [ -f "$stopfile" ]; then
stopped=$(grep -E '^[0-9]+$' "$stopfile" 2>/dev/null | paste -sd, -)
[ -n "$stopped" ] && query="${query}&stopped_rule_ids=${stopped}"
@@ -110,17 +110,17 @@ answer=$(printf '{"reply":"%s"%s}' "$reply_esc" "$closing" | curl -fsS --max-tim
"${url%/}/api/plugin/reply-rules${query:+?$query}" 2>/dev/null) || exit 0
[ "$rewrite" = "true" ] && exit 0
answer_flat=$(printf '%s' "$answer" | scribe_json_flat)
reason=$(scribe_json_pick "$answer_flat" '.reason')
answer_flat=$(printf '%s' "$answer" | grimoire_json_flat)
reason=$(grimoire_json_pick "$answer_flat" '.reason')
[ -n "$reason" ] || exit 0
[ "$(scribe_json_pick "$answer_flat" '.report_check')" = "blocked" ] && : > "$reportfile" 2>/dev/null
[ "$(grimoire_json_pick "$answer_flat" '.report_check')" = "blocked" ] && : > "$reportfile" 2>/dev/null
# Recorded BEFORE the block is emitted, for the act checkpoint's reason: a
# hold that is shown and not recorded is one that can be shown again.
for id in $(scribe_json_list "$answer_flat" '.rule_ids'); do
scribe_checkpoint_allowed "$stopfile" "$id" || true
for id in $(grimoire_json_list "$answer_flat" '.rule_ids'); do
grimoire_checkpoint_allowed "$stopfile" "$id" || true
done
scribe_json_list "$answer_flat" '.rule_ids' | scribe_rules_append "$rulefile"
grimoire_json_list "$answer_flat" '.rule_ids' | grimoire_rules_append "$rulefile"
printf '{"decision":"block","reason":"%s"}\n' "$(printf '%s' "$reason" | scribe_json_escape)"
printf '{"decision":"block","reason":"%s"}\n' "$(printf '%s' "$reason" | grimoire_json_escape)"
exit 0
@@ -2,9 +2,9 @@
# 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 (scribe_static_context.md) — where Grimoire's reflexes
# are stated (the using-scribe skill), Claude Code's memory files, /compact,
# /scribe:sync, and what to do when Grimoire is unavailable. It carries only what
# 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.
#
@@ -28,7 +28,7 @@
# plus a post-compaction reload from the instance.
#
# The other half: a host hook cannot make the model flush before a compaction,
# but scribe_precompact_preserve.sh steers the
# 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
@@ -37,8 +37,8 @@
# 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; SCRIBE_URL /
# SCRIBE_TOKEN override for the settings.json dogfooding path.
# 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
@@ -46,8 +46,8 @@
# allowed to fail quietly; see the comment at the status block below.
set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# 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
@@ -58,12 +58,12 @@ 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" | scribe_json_flat)
source=$(scribe_json_pick "$event_flat" '.source')
event_flat=$(printf '%s' "$event" | grimoire_json_flat)
source=$(grimoire_json_pick "$event_flat" '.source')
# --- The rule ledger outlives the context it describes ---
#
# scribe_prior_art.sh and scribe_tool_rules.sh record every rule id they have
# 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.
@@ -105,21 +105,21 @@ source=$(scribe_json_pick "$event_flat" '.source')
# first minute of a session unreachable for the rest of it, through any number
# of compactions.
#
# `scribe_clear_session_ledgers` matches on the naming convention — a
# `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 `scribe_defs.sh`'s `SCRIBE_LEDGER_DIRS` — plural, because the
# 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=$(scribe_json_pick "$event_flat" '.session_id')
sid=$(grimoire_json_pick "$event_flat" '.session_id')
if [ -n "$sid" ]; then
safe_sid=$(printf '%s' "$sid" | tr -c 'A-Za-z0-9._-' '_')
scribe_clear_session_ledgers "$safe_sid"
grimoire_clear_session_ledgers "$safe_sid"
fi
;;
esac
@@ -131,14 +131,14 @@ append() { if [ -n "$out" ]; then out="${out}"$'\n\n---\n\n'"$1"; else out="$1";
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/scribe_static_context.md" ] && out=$(cat "$here/scribe_static_context.md")
[ -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/…/scribe-plugin/ git clone — pulls on its own
# cache/…/scribe/<version>/ what EXECUTES — refreshed only when the
# 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
@@ -151,7 +151,7 @@ prepend() { if [ -n "$out" ]; then out="$1"$'\n\n---\n\n'"${out}"; else out="$1"
# appears there.
manifest="$here/../.claude-plugin/plugin.json"
if [ -f "$manifest" ]; then
plugin_version=$(scribe_json_pick "$(scribe_json_flat < "$manifest")" '.version')
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
@@ -160,28 +160,28 @@ 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.
scribe_config || :
grimoire_config || :
dyn=""
status=""
if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
# Which project is this directory's? A `.scribe` marker first, then the git
# remote. scribe_scope_query answers that, and is shared with the
# 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=$(scribe_marker_file "$repo_dir")
marker_read=$(scribe_marker_read "$marker")
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=$(scribe_scope_query "$repo_dir")
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=$(scribe_json_pick "$event_flat" '.session_id')
q="source=$(printf '%s' "$source" | scribe_urlenc)"
[ -n "$sid_now" ] && q="${q}&session_id=$(printf '%s' "$sid_now" | scribe_urlenc)"
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
@@ -212,8 +212,8 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
esac
body_flat=""
if [ "$ctx_rc" = 0 ] && [ "${ctx_code#2}" != "$ctx_code" ] && [ -n "$body" ]; then
body_flat=$(printf '%s' "$body" | scribe_json_flat)
dyn=$(scribe_json_pick "$body_flat" '.context')
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
@@ -235,16 +235,16 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
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-scribe skill still applies."
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 SCRIBE_TOKEN. Tools still work; ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\`."
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 SCRIBE_URL + SCRIBE_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()\`."
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"
@@ -262,14 +262,14 @@ fi
# 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 "$(scribe_json_pick "$body_flat" '.project.id')" ]; then
host=$(scribe_url_host "$url")
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 \`.scribe\` 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}/.scribe\`. Future sessions here load that project on their own."
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
@@ -281,5 +281,5 @@ fi
# Nothing at all to inject → stay silent.
[ -n "$out" ] || exit 0
scribe_json_out SessionStart "$out"
grimoire_json_out SessionStart "$out"
exit 0
@@ -21,29 +21,29 @@
# session is ending whether this succeeds or not.
set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# shellcheck source=plugin/hooks/grimoire_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/grimoire_defs.sh"
event=$(cat 2>/dev/null || true)
[ -n "$event" ] || exit 0
event_flat=$(printf '%s' "$event" | scribe_json_flat)
session_id=$(scribe_json_pick "$event_flat" '.session_id')
event_flat=$(printf '%s' "$event" | grimoire_json_flat)
session_id=$(grimoire_json_pick "$event_flat" '.session_id')
[ -n "$session_id" ] || exit 0
# The moment arm's per-session files (its tool cache and the acts ledger)
# describe a session that is over, on any exit — a clear
# included, since the next conversation is a new session id.
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
rm -f "${TMPDIR:-/tmp}/scribe-moment/${safe_sid}".* 2>/dev/null || true
rm -f "${TMPDIR:-/tmp}/grimoire-moment/${safe_sid}".* 2>/dev/null || true
reason=$(scribe_json_pick "$event_flat" '.reason')
reason=$(grimoire_json_pick "$event_flat" '.reason')
[ "$reason" = "clear" ] && exit 0
command -v curl >/dev/null 2>&1 || exit 0
scribe_config || exit 0
grimoire_config || exit 0
sid_enc=$(printf '%s' "$session_id" | scribe_urlenc) || exit 0
sid_enc=$(printf '%s' "$session_id" | grimoire_urlenc) || exit 0
curl -fsS --max-time 4 \
-H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/release-session?session_id=${sid_enc}" \
@@ -2,15 +2,15 @@
# Grimoire plugin — Stop hook: the turn's new shapes are judged by the agent
# that wrote them.
#
# The write hooks (scribe_prior_art.sh, scribe_after_write.sh) append every
# The write hooks (grimoire_prior_art.sh, grimoire_after_write.sh) append every
# definition a write names to a session ledger, `<sid>.written.ids`
# (`path<TAB>kind<TAB>name`, see scribe_written_append). At the end of the
# (`path<TAB>kind<TAB>name`, see grimoire_written_append). At the end of the
# turn this hook sends that ledger to the instance, which answers with what
# nobody has judged yet. If anything is, the hook blocks ONCE, in the server's
# words: the agent that built the code is the one participant who knows what
# it is, and the end of the turn is the last moment that is still true.
#
# THE SAME DISCIPLINE AS the completion-section check in scribe_reply_check.sh:
# THE SAME DISCIPLINE AS the completion-section check in grimoire_reply_check.sh:
# - it blocks only on a `reason` the instance returned, which it returns
# only for a block it RECORDED — an unconfigured or unreachable instance
# never stops a session, and every intervention is one the numbers see;
@@ -28,25 +28,25 @@
# Config (same as the other hooks):
# 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.
# GRIMOIRE_URL / GRIMOIRE_TOKEN override for the settings.json dogfooding path.
set -uo pipefail
command -v curl >/dev/null 2>&1 || exit 0
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# shellcheck source=plugin/hooks/grimoire_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/grimoire_defs.sh"
# Stop delivers { session_id, transcript_path, cwd, hook_event_name, stop_hook_active }.
event=$(cat 2>/dev/null || true)
event_flat=$(printf '%s' "$event" | scribe_json_flat)
session_id=$(scribe_json_pick "$event_flat" '.session_id')
active=$(scribe_json_pick "$event_flat" '.stop_hook_active')
event_cwd=$(scribe_json_pick "$event_flat" '.cwd')
event_flat=$(printf '%s' "$event" | grimoire_json_flat)
session_id=$(grimoire_json_pick "$event_flat" '.session_id')
active=$(grimoire_json_pick "$event_flat" '.stop_hook_active')
event_cwd=$(grimoire_json_pick "$event_flat" '.cwd')
[ -n "$session_id" ] || exit 0
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
ledger="${TMPDIR:-/tmp}/scribe-priorart/${safe_sid}.written.ids"
state_dir="${TMPDIR:-/tmp}/scribe-shapecheck"
ledger="${TMPDIR:-/tmp}/grimoire-priorart/${safe_sid}.written.ids"
state_dir="${TMPDIR:-/tmp}/grimoire-shapecheck"
mkdir -p "$state_dir" 2>/dev/null || true
marker="$state_dir/${safe_sid}.blocked"
@@ -63,15 +63,15 @@ if [ ! -s "$ledger" ]; then
rm -f "$marker" 2>/dev/null || true
exit 0
fi
scribe_config || exit 0
grimoire_config || exit 0
# Unique lines, oldest first, capped: the request stays a GET (a read-scoped
# key runs the plugin), and a turn that wrote more than this generated code.
written=$(awk '!seen[$0]++' "$ledger" 2>/dev/null | head -n 80)
written_enc=$(printf '%s' "$written" | scribe_urlenc) || exit 0
written_enc=$(printf '%s' "$written" | grimoire_urlenc) || exit 0
dir="${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}"
q="phase=${phase}&written=${written_enc}"
scope=$(scribe_scope_query "$dir")
scope=$(grimoire_scope_query "$dir")
[ -n "$scope" ] && q="${q}&${scope}"
# The repo rides along even when a marker names the project: the instance
# needs it to tell the agent which repo a just-written shape belongs to.
@@ -80,7 +80,7 @@ case "$scope" in
*)
remote=$(git -C "$dir" remote get-url origin 2>/dev/null || true)
if [ -n "$remote" ]; then
remote_enc=$(printf '%s' "$remote" | scribe_urlenc) || remote_enc=""
remote_enc=$(printf '%s' "$remote" | grimoire_urlenc) || remote_enc=""
[ -n "$remote_enc" ] && q="${q}&repo=${remote_enc}"
fi
;;
@@ -95,11 +95,11 @@ if [ "$phase" = "after" ]; then
exit 0
fi
reason=$(scribe_json_pick "$(printf '%s' "$answer" | scribe_json_flat)" '.reason')
reason=$(grimoire_json_pick "$(printf '%s' "$answer" | grimoire_json_flat)" '.reason')
if [ -z "$reason" ]; then
rm -f "$ledger" 2>/dev/null || true
exit 0
fi
: > "$marker" 2>/dev/null || true
printf '{"decision":"block","reason":"%s"}\n' "$(printf '%s' "$reason" | scribe_json_escape)"
printf '{"decision":"block","reason":"%s"}\n' "$(printf '%s' "$reason" | grimoire_json_escape)"
exit 0
@@ -1,8 +1,8 @@
# Grimoire — Claude Code adapter
This session is connected to **Grimoire**, the operator's system of record for
their work, through the `scribe` MCP tools. How to work with Grimoire is stated
once, in the **`using-scribe`** skill: reach for it at the start of the session
their work, through the `grimoire` MCP tools. How to work with Grimoire is stated
once, in the **`using-grimoire`** skill: reach for it at the start of the session
and whenever you are unsure what Grimoire expects. Each tool's contract is in its
description, and the process skills (writing-plans, reporting-back,
reusing-code, systematic-debugging, verification, brainstorming,
@@ -12,13 +12,13 @@ What only Claude Code needs said:
- **Keep one copy — in Grimoire, not Claude Code's local memory.** Claude
Code's local memory is `CLAUDE.md` and auto-memory: the operator's rules,
plans and project notes go to Grimoire instead, and using-scribe says how
plans and project notes go to Grimoire instead, and using-grimoire says how
Grimoire works alongside them.
- **Lines injected beside your work are retrieval.** When the operator sends a
message, and before a write or a command, Grimoire may add rules, preferences,
notes and prior art that resemble what you are doing — and, when a tool call
or the reply ending a turn reaches a moment of work, the rules mounted on
it. Open the ones that apply; using-scribe says what a quiet turn means and
it. Open the ones that apply; using-grimoire says what a quiet turn means and
how to correct a moment that fired wrongly.
- **Compact at clean seams.** Because work is recorded as you go, a compaction
is safe once in-flight state is logged. After finishing a block of work in a
@@ -27,9 +27,9 @@ What only Claude Code needs said:
seams, not every turn.
- **When the operator wraps up, hand off first.** Log where each task you
worked stands before the session ends; the plugin releases your claims at
session end, but only you can say what happened (using-scribe, "Hand off").
- **Stored Processes arrive as skills** (`scribe-proc-*`), refreshed at session
start. After a Process is added or edited, `/scribe:sync` makes it available
session end, but only you can say what happened (using-grimoire, "Hand off").
- **Stored Processes arrive as skills** (`grimoire-proc-*`), refreshed at session
start. After a Process is added or edited, `/grimoire:sync` makes it available
straight away.
- **If the Grimoire tools are unavailable, say so** rather than silently falling
back to local notes.
@@ -2,7 +2,7 @@
# Grimoire plugin — sync stored Processes into auto-surfacing local skills.
#
# Fetches `GET /api/plugin/processes` and writes one
# ~/.claude/skills/scribe-proc-<slug>/SKILL.md
# ~/.claude/skills/grimoire-proc-<slug>/SKILL.md
# per Process. The stub's frontmatter `description` is the auto-surface trigger;
# its body tells Claude to call get_process(<name>) and follow the LIVE procedure
# from Grimoire (the DB stays the single source of truth — the stub is a pointer).
@@ -13,69 +13,69 @@
# session, so a freshly written stub auto-surfaces without a restart.
#
# TRIGGERS: this runs at SessionStart (alongside the context hook) so stubs stay
# fresh each session, and on demand via the `/scribe:sync` command.
# fresh each session, and on demand via the `/grimoire:sync` command.
#
# FAIL-OPEN & SILENT: never blocks a session; emits NOTHING on stdout (so it's
# safe as a second SessionStart hook). On any fetch failure it exits without
# touching existing stubs — a transient outage must not wipe the user's skills.
# Config mirrors the context hook: CLAUDE_PLUGIN_OPTION_API_ENDPOINT /
# CLAUDE_PLUGIN_OPTION_API_TOKEN (userConfig key UPPERCASED by Claude Code),
# with SCRIBE_URL / SCRIBE_TOKEN as the override.
# with GRIMOIRE_URL / GRIMOIRE_TOKEN as the override.
set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# shellcheck source=plugin/hooks/grimoire_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/grimoire_defs.sh"
command -v curl >/dev/null 2>&1 || exit 0
scribe_config || exit 0
grimoire_config || exit 0
body=$(curl -fsS --max-time 8 \
-H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/processes" 2>/dev/null) || exit 0
[ -n "$body" ] || exit 0
body_flat=$(printf '%s' "$body" | scribe_json_flat)
body_flat=$(printf '%s' "$body" | grimoire_json_flat)
# The array's LENGTH, which is "" when `.processes` is not an array at all —
# a different answer from 0, and the one that means the response was not what
# this hook expects.
count=$(scribe_json_len "$body_flat" '.processes')
count=$(grimoire_json_len "$body_flat" '.processes')
case "$count" in ''|*[!0-9]*) exit 0 ;; esac
skills_dir="${HOME}/.claude/skills"
mkdir -p "$skills_dir" 2>/dev/null || exit 0
# Slugs written this run — anything else under scribe-proc-* is pruned below.
# Slugs written this run — anything else under grimoire-proc-* is pruned below.
managed=" "
i=0
while [ "$i" -lt "$count" ]; do
name=$(scribe_json_pick "$body_flat" ".processes[$i].name")
slug=$(scribe_json_pick "$body_flat" ".processes[$i].slug")
desc=$(scribe_json_pick "$body_flat" ".processes[$i].description")
name=$(grimoire_json_pick "$body_flat" ".processes[$i].name")
slug=$(grimoire_json_pick "$body_flat" ".processes[$i].slug")
desc=$(grimoire_json_pick "$body_flat" ".processes[$i].description")
i=$((i + 1))
[ -n "$slug" ] && [ -n "$name" ] || continue
dir="${skills_dir}/scribe-proc-${slug}"
dir="${skills_dir}/grimoire-proc-${slug}"
mkdir -p "$dir" 2>/dev/null || continue
# description folded to one line — YAML scalar must not contain a newline.
desc=$(printf '%s' "$desc" | tr '\n' ' ')
{
printf -- '---\n'
printf 'name: scribe-proc-%s\n' "$slug"
printf 'name: grimoire-proc-%s\n' "$slug"
printf 'description: %s\n' "$desc"
printf -- '---\n\n'
printf '<!-- GENERATED by the Grimoire plugin (scribe_sync_processes.sh). Do not edit here; edit the Process in Grimoire and re-sync with /scribe:sync. -->\n\n'
printf '<!-- GENERATED by the Grimoire plugin (grimoire_sync_processes.sh). Do not edit here; edit the Process in Grimoire and re-sync with /grimoire:sync. -->\n\n'
printf 'This is a saved **Grimoire Process**: `%s`.\n\n' "$name"
printf 'Do not improvise it. Call `get_process("%s")` via the Grimoire MCP server to fetch the live procedure, then follow the returned body verbatim — including any "clarify first" steps it contains.\n' "$name"
} > "${dir}/SKILL.md" 2>/dev/null || continue
managed="${managed}${slug} "
done
# Prune stubs whose Process no longer exists (scoped to our scribe-proc-* prefix).
for d in "${skills_dir}"/scribe-proc-*; do
# Prune stubs whose Process no longer exists (scoped to our grimoire-proc-* prefix).
for d in "${skills_dir}"/grimoire-proc-*; do
[ -d "$d" ] || continue
slug=$(basename "$d"); slug=${slug#scribe-proc-}
slug=$(basename "$d"); slug=${slug#grimoire-proc-}
case "$managed" in
*" $slug "*) : ;;
*) rm -rf "$d" 2>/dev/null ;;
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Grimoire — PreToolUse rule arm for ACTIONS.
#
# The sibling of scribe_prior_art.sh. That hook is registered on Write|Edit and
# The sibling of grimoire_prior_art.sh. That hook is registered on Write|Edit and
# asks "what is recorded about the file being written". This one asks "does a
# standing rule speak to the command about to be run" — which is how a rule
# about which tool to reach for arrives at all.
@@ -13,41 +13,41 @@
#
# SILENT ON OUTAGE, deliberately, unlike the prior-art hook. A write is
# occasional; a Bash call is not, and an "instance did not answer" line before
# every command is the noise that gets a channel muted. scribe_prior_art.sh
# every command is the noise that gets a channel muted. grimoire_prior_art.sh
# still speaks for both when the instance is down.
#
# Env:
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
# GRIMOIRE_URL / GRIMOIRE_TOKEN override for the settings.json dogfooding path.
command -v curl >/dev/null 2>&1 || exit 0
# Sourced FIRST, because the JSON reader below lives there. It defines
# functions and clears `url`/`token`; nothing here runs before it is needed.
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# shellcheck source=plugin/hooks/grimoire_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/grimoire_defs.sh"
# PreToolUse delivers { session_id, cwd, tool_name, tool_input: {...}, ... }
event=$(cat 2>/dev/null || true)
event_flat=$(printf '%s' "$event" | scribe_json_flat)
tool_name=$(scribe_json_pick "$event_flat" '.tool_name')
session_id=$(scribe_json_pick "$event_flat" '.session_id')
event_cwd=$(scribe_json_pick "$event_flat" '.cwd')
event_flat=$(printf '%s' "$event" | grimoire_json_flat)
tool_name=$(grimoire_json_pick "$event_flat" '.tool_name')
session_id=$(grimoire_json_pick "$event_flat" '.session_id')
event_cwd=$(grimoire_json_pick "$event_flat" '.cwd')
[ -n "$tool_name" ] || exit 0
# The action, as text. `.command` is Bash's field; the fallbacks let the matcher
# in hooks.json widen to other tools without this script changing — which is the
# whole reason the server side takes a name and a string rather than a schema.
command_text=$(scribe_json_pick "$event_flat" '.tool_input.command')
[ -n "$command_text" ] || command_text=$(scribe_json_pick "$event_flat" '.tool_input.url')
[ -n "$command_text" ] || command_text=$(scribe_json_pick "$event_flat" '.tool_input.prompt')
command_text=$(grimoire_json_pick "$event_flat" '.tool_input.command')
[ -n "$command_text" ] || command_text=$(grimoire_json_pick "$event_flat" '.tool_input.url')
[ -n "$command_text" ] || command_text=$(grimoire_json_pick "$event_flat" '.tool_input.prompt')
[ -n "$command_text" ] || exit 0
# scribe_config, not a hand-rolled pair of parameter expansions: it also treats
# grimoire_config, not a hand-rolled pair of parameter expansions: it also treats
# an UNEXPANDED `${...}` placeholder as unset, which would otherwise be sent as
# a garbage Bearer token and 401 on every call.
scribe_config || exit 0
grimoire_config || exit 0
# Bounded before encoding: a heredoc or a pasted script can be enormous, and
# the verb and its target — the part a rule is about — sit at the front. The
@@ -57,14 +57,14 @@ command_text=$(printf '%s' "$command_text" | head -c 2000)
# Whole, never line by line: per-line encoding joins a multi-line command with
# raw newlines — an invalid URL.
# scribe_urlenc reads bytes and has no notion of a line.
cmd_enc=$(printf '%s' "$command_text" | scribe_urlenc)
tool_enc=$(printf '%s' "$tool_name" | scribe_urlenc)
# grimoire_urlenc reads bytes and has no notion of a line.
cmd_enc=$(printf '%s' "$command_text" | grimoire_urlenc)
tool_enc=$(printf '%s' "$tool_name" | grimoire_urlenc)
[ -n "$cmd_enc" ] && [ -n "$tool_enc" ] || exit 0
repo_q=""
lookup_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
scope=$(scribe_scope_query "$lookup_dir")
scope=$(grimoire_scope_query "$lookup_dir")
[ -n "$scope" ] && repo_q="&${scope}"
# Where the command runs, against the repo's root: the paths a
@@ -73,12 +73,12 @@ scope=$(scribe_scope_query "$lookup_dir")
where_q=""
repo_root=$(git -C "$lookup_dir" rev-parse --show-toplevel 2>/dev/null || true)
if [ -n "$repo_root" ]; then
where_q="&root=$(printf '%s' "$repo_root" | scribe_urlenc)&cwd=$(printf '%s' "$lookup_dir" | scribe_urlenc)"
where_q="&root=$(printf '%s' "$repo_root" | grimoire_urlenc)&cwd=$(printf '%s' "$lookup_dir" | grimoire_urlenc)"
fi
# THE SHARED SESSION LEDGER, and the thing most worth getting right here.
#
# scribe_prior_art.sh keeps the rules it has already named in
# grimoire_prior_art.sh keeps the rules it has already named in
# <state>/<sid>.rules.ids and passes them as exclude_rule_ids. This hook reads
# and appends to that SAME file rather than keeping its own: two ledgers would
# mean a rule named by one arm gets re-offered by the other, and the hint that
@@ -86,7 +86,7 @@ fi
#
# The directory keeps the prior-art name on purpose — renaming it would orphan
# every live session's state for a cosmetic gain.
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
state_dir="${TMPDIR:-/tmp}/grimoire-priorart"
mkdir -p "$state_dir" 2>/dev/null || true
rulefile=""
rule_exclude_q=""
@@ -96,15 +96,15 @@ if [ -n "$session_id" ]; then
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
rulefile="$state_dir/${safe_sid}.rules.ids"
# Ageing, not a flat read: an id named two hours ago is not one the
# session is still holding. scribe_rules_live carries the reasoning.
rule_seen=$(scribe_rules_live "$rulefile")
# session is still holding. grimoire_rules_live carries the reasoning.
rule_seen=$(grimoire_rules_live "$rulefile")
[ -n "$rule_seen" ] && rule_exclude_q="&exclude_rule_ids=${rule_seen}"
# What the session actually OPENED, as against what it was shown.
rule_exclude_q="${rule_exclude_q}$(scribe_held_query "$state_dir/${safe_sid}.opened.ids")"
rule_exclude_q="${rule_exclude_q}$(grimoire_held_query "$state_dir/${safe_sid}.opened.ids")"
# The Systems whose rulings were shown in full — shared with the write-path
# hook, for the reason the rule file is.
rulingsfile="$state_dir/${safe_sid}.rulings.ids"
rulings_q=$(scribe_rulings_query "$rulingsfile")
rulings_q=$(grimoire_rulings_query "$rulingsfile")
fi
# `|| exit 0` here, unlike the prior-art hook: there is no local arm whose
@@ -114,8 +114,8 @@ body=$(curl -fsS --max-time 5 \
-H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/tool-rules?tool=${tool_enc}&command=${cmd_enc}${repo_q}${where_q}${rule_exclude_q}${rulings_q}" 2>/dev/null) || exit 0
body_flat=$(printf '%s' "$body" | scribe_json_flat)
context=$(scribe_json_pick "$body_flat" '.context')
body_flat=$(printf '%s' "$body" | grimoire_json_flat)
context=$(grimoire_json_pick "$body_flat" '.context')
# Remember what was named so it is not repeated this session. BEFORE the
# checkpoint branch and before the empty-context exit: the ledger records what
@@ -123,15 +123,15 @@ context=$(scribe_json_pick "$body_flat" '.context')
# renders it. Doing it inside one branch is how the two arms' ledgers came to
# disagree once already.
if [ -n "$rulefile" ]; then
scribe_json_list "$body_flat" '.rule_ids' | scribe_rules_append "$rulefile"
grimoire_json_list "$body_flat" '.rule_ids' | grimoire_rules_append "$rulefile"
fi
if [ -n "$rulingsfile" ]; then
scribe_json_list "$body_flat" '.ruling_system_ids' >> "$rulingsfile" || true
grimoire_json_list "$body_flat" '.ruling_system_ids' >> "$rulingsfile" || true
fi
# ── The pre-act checkpoint ───────────────────────────────────────────────
#
# WHY THIS ARM AND NOT THE WRITE PATH. scribe_prior_art.sh carries an explicit,
# WHY THIS ARM AND NOT THE WRITE PATH. grimoire_prior_art.sh carries an explicit,
# tested property that it never returns a permissionDecision — a recall aid may
# not stand in the way of a write, which is the operator's decision and is
# guarded by test_hook_never_returns_a_permission_decision. No equivalent
@@ -147,18 +147,18 @@ fi
# exists. It costs the operator nothing: no prompt reaches them, and the model
# clears it by reading one record and re-submitting.
#
# It cannot recur. `scribe_checkpoint_allowed` holds at most one act per rule
# It cannot recur. `grimoire_checkpoint_allowed` holds at most one act per rule
# and at most five per session, so the worst case of a mis-set floor is a noisy
# session rather than one that cannot move.
cp_rule=$(scribe_json_pick "$body_flat" '.checkpoint.rule_id')
cp_reason=$(scribe_json_pick "$body_flat" '.checkpoint.reason')
cp_rule=$(grimoire_json_pick "$body_flat" '.checkpoint.rule_id')
cp_reason=$(grimoire_json_pick "$body_flat" '.checkpoint.reason')
if [ -n "$cp_rule" ] && [ -n "$cp_reason" ] && [ -n "$session_id" ]; then
if scribe_checkpoint_allowed "$state_dir/${safe_sid}.checkpoint.ids" "$cp_rule"; then
scribe_json_deny PreToolUse "$cp_reason"
if grimoire_checkpoint_allowed "$state_dir/${safe_sid}.checkpoint.ids" "$cp_rule"; then
grimoire_json_deny PreToolUse "$cp_reason"
exit 0
fi
fi
[ -n "$context" ] || exit 0
scribe_json_out PreToolUse "$context"
grimoire_json_out PreToolUse "$context"
exit 0
@@ -1,8 +1,8 @@
# Grimoire plugin — what happened in the LAST TURN of a transcript.
#
# Reads the flat `IDX<TAB>PATH<TAB>VALUE` stream that scribe_json.awk produces
# Reads the flat `IDX<TAB>PATH<TAB>VALUE` stream that grimoire_json.awk produces
# in `mode=lines` from a Claude Code transcript, and answers the four questions
# the Stop hooks ask (scribe_reply_check.sh). It replaces a thirty-line jq program; the shape
# the Stop hooks ask (grimoire_reply_check.sh). It replaces a thirty-line jq program; the shape
# of the answer is unchanged, so the hook around it reads the same.
#
# bounded 1 if a user prompt was found in the window, else empty. A window
@@ -12,7 +12,7 @@
# closed how many task-closing tool calls SUCCEEDED in that turn.
# task_ids their task ids, comma-joined, for the server to record.
# reply the assistant text after the last action, STILL JSON-ESCAPED and
# on one line. The caller decodes it with scribe_json_unescape —
# on one line. The caller decodes it with grimoire_json_unescape —
# decoding here would put newlines into a line-oriented format.
#
# A SIDECHAIN IS NOT THIS SESSION. Subagent records interleave into the same
+14 -14
View File
@@ -5,11 +5,11 @@
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_session_context.sh\""
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/grimoire_session_context.sh\""
},
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_sync_processes.sh\""
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/grimoire_sync_processes.sh\""
}
]
}
@@ -19,7 +19,7 @@
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_autoinject.sh\""
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/grimoire_autoinject.sh\""
}
]
}
@@ -30,7 +30,7 @@
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_prior_art.sh\""
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/grimoire_prior_art.sh\""
}
]
},
@@ -39,7 +39,7 @@
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_tool_rules.sh\""
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/grimoire_tool_rules.sh\""
}
]
},
@@ -48,7 +48,7 @@
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_moment.sh\""
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/grimoire_moment.sh\""
}
]
}
@@ -59,7 +59,7 @@
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_after_write.sh\""
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/grimoire_after_write.sh\""
}
]
},
@@ -68,7 +68,7 @@
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_record_opened.sh\""
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/grimoire_record_opened.sh\""
}
]
},
@@ -77,7 +77,7 @@
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_record_outcome.sh\""
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/grimoire_record_outcome.sh\""
}
]
},
@@ -86,7 +86,7 @@
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_claim_session.sh\""
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/grimoire_claim_session.sh\""
}
]
}
@@ -96,7 +96,7 @@
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_precompact_preserve.sh\""
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/grimoire_precompact_preserve.sh\""
}
]
}
@@ -106,11 +106,11 @@
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_reply_check.sh\""
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/grimoire_reply_check.sh\""
},
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_shape_check.sh\""
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/grimoire_shape_check.sh\""
}
]
}
@@ -120,7 +120,7 @@
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_session_end.sh\""
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/grimoire_session_end.sh\""
}
]
}
@@ -1,5 +1,5 @@
---
name: using-scribe
name: using-grimoire
description: Use at the START of every session, and before answering anything about the operator's work or starting any task — establishes the Grimoire-first reflex. You hold none of the operator's rules: they arrive by retrieval when your work matches one, and what_might_apply is how you ask before a consequential act — it returns the wide net of candidates with no bar. Call enter_project when a repo/project is in scope. Then recall before acting, update over duplicate, plan in Grimoire not in files.
metadata:
moments: session.start
@@ -9,7 +9,7 @@ metadata:
Grimoire is the operator's self-hosted system of record (notes, tasks, issues,
projects, milestones, systems) and rulebook, reachable
through the bundled `scribe` MCP server. Its value is mostly in what it
through the bundled `grimoire` MCP server. Its value is mostly in what it
**already holds** — so make reading it a reflex, not something you wait to be
asked for.
@@ -1,6 +1,6 @@
# When a record doesn't reach the moment it should
Part of the using-scribe skill. Read it when a rule should have governed a
Part of the using-grimoire skill. Read it when a rule should have governed a
moment and never arrived, when one arrives on every turn and never applies,
or before touching a retrieval floor.
@@ -1,6 +1,6 @@
# Moments — rules that arrive when the work reaches a point
Part of the using-scribe skill. Read it when a line says a rule arrived *at* a
Part of the using-grimoire skill. Read it when a line says a rule arrived *at* a
moment, when a reply is held for one read, when an action reached the wrong
moment or none, when a mounted rule arrived and did not apply, and when a line
proposes mounting or unmounting a rule.
@@ -1,6 +1,6 @@
# Projects: binding a directory, and starting one
Part of the using-scribe skill. Read it when a working directory maps to no
Part of the using-grimoire skill. Read it when a working directory maps to no
project, or when a new project is about to be created.
## Binding a directory to a project
@@ -8,9 +8,9 @@ project, or when a new project is about to be created.
**A directory does not have to be a git repo to have a project.** A repo is
bound by its remote (`list_repo_bindings` shows the bindings). Anything else —
a notes folder, a server's config directory, a scratch directory — is bound by
a `.scribe` file naming the project:
a `.grimoire` file naming the project:
{"instance": "https://scribe.example.com", "project_id": 2, "project": "Homelab"}
{"instance": "https://grimoire.example.com", "project_id": 2, "project": "Homelab"}
`instance` is what makes the id trustworthy. An id means nothing on its own —
it is a different project on every Grimoire — so a marker that has travelled to
@@ -1,6 +1,6 @@
# Writing a rule, a lesson, or a note that asserts a fact
Part of the using-scribe skill. Read it before `create_rule`,
Part of the using-grimoire skill. Read it before `create_rule`,
`create_project_rule`, `create_preference`, `create_process` or
`create_lesson`; when a lesson
arrives that names the situation you are actually in; when the operator