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
+2 -2
View File
@@ -1,10 +1,10 @@
{
"name": "scribe-plugin",
"name": "grimoire-plugin",
"owner": { "name": "Bryan Van Deusen" },
"description": "Grimoire ships its own Claude Code plugin from this repo, versioned in lockstep with the app + the /api/plugin/context contract.",
"plugins": [
{
"name": "scribe",
"name": "grimoire",
"source": "./plugin",
"description": "Grimoire second brain: MCP tools + session-start push channel + universal process-skills."
}
@@ -0,0 +1,45 @@
"""skill names follow the plugin's name
Revision ID: 0124
Revises: 0123
Create Date: 2026-10-22
The plugin is named `grimoire`, so its skills reach a session as
`grimoire:<skill>` and stored processes as `grimoire-proc-<slug>`. A rule
mounted on one of those skills, a judgment about that mount, and an install's
own action mapping all store the skill's name, and a stored name the harness no
longer sends never matches again. This rewrites the plugin's earlier prefixes,
`scribe:` and `scribe-proc-`, to the current ones.
Cost: three small tables, each one row per mount, judgment or mapping.
"""
from alembic import op
revision = "0124"
down_revision = "0123"
branch_labels = None
depends_on = None
_MOMENT_TABLES = ("rule_moments", "rule_moment_judgments", "moment_mappings")
def _rename(old: str, new: str) -> None:
for table in _MOMENT_TABLES:
op.execute(
f"UPDATE {table} SET moment = regexp_replace(moment, "
f"'^skill\\.{old}(:|-proc-)', 'skill.{new}\\1') "
f"WHERE moment ~ '^skill\\.{old}(:|-proc-)'"
)
op.execute(
f'UPDATE moment_mappings SET "match" = regexp_replace("match", '
f"'skill={old}(:|-proc-)', 'skill={new}\\1', 'g') "
f"WHERE \"match\" ~ 'skill={old}(:|-proc-)'"
)
def upgrade() -> None:
_rename("scribe", "grimoire")
def downgrade() -> None:
_rename("grimoire", "scribe")
+3 -3
View File
@@ -54,7 +54,7 @@ The plugin connects the MCP server and adds the hooks and skills that put Grimoi
```
/plugin marketplace add https://git.fabledsword.com/bvandeusen/FabledScribe.git
/plugin install scribe@scribe-plugin
/plugin install grimoire@grimoire-plugin
```
It asks for your Grimoire base URL, an API key (kept in your OS keychain) and, optionally, a project id. Settings → MCP Access shows the exact command for your instance. [`plugin/README.md`](../plugin/README.md) describes each hook.
@@ -67,9 +67,9 @@ your instance's `/mcp` endpoint:
```json
{
"mcpServers": {
"scribe": {
"grimoire": {
"type": "http",
"url": "https://your-scribe-instance.example.com/mcp",
"url": "https://your-grimoire-instance.example.com/mcp",
"headers": {
"Authorization": "Bearer fmcp_your-api-key"
}
+1 -1
View File
@@ -332,7 +332,7 @@ Home server management. The accent appears on: the wordmark, healthy/online stat
}
/* Per-app accent — set ONE of these on the root for each app */
[data-app="scribe"] { --fs-accent: #5B4A8A; }
[data-app="grimoire"] { --fs-accent: #5B4A8A; }
[data-app="minstrel"] { --fs-accent: #4A6B5C; }
[data-app="forge"] { --fs-accent: #8B5A2B; }
[data-app="roundtable"]{ --fs-accent: #4A5D7E; }
+1 -1
View File
@@ -53,7 +53,7 @@ Tokens (colour, type, space, motion, ...) and written guidance, with inheritance
## Stored processes
Saved procedures, such as a drift audit or a DRY pass. The plugin syncs each one into a Claude Code skill (`/scribe:sync`), which fetches the live procedure when it is invoked.
Saved procedures, such as a drift audit or a DRY pass. The plugin syncs each one into a Claude Code skill (`/grimoire:sync`), which fetches the live procedure when it is invoked.
## Search and context injection
+7 -7
View File
@@ -570,7 +570,7 @@ const copiedSnippetKey = ref<string | null>(null);
// preferences carry across visits without needing a server-side setting.
const _MCP_NAME_KEY = 'mcp_server_name';
const _MCP_SCOPE_KEY = 'mcp_scope';
const _DEFAULT_MCP_NAME = 'scribe';
const _DEFAULT_MCP_NAME = 'grimoire';
const _isValidScope = (v: unknown): v is 'user' | 'project' | 'local' =>
v === 'user' || v === 'project' || v === 'local';
@@ -635,8 +635,8 @@ const DEAD_PCT_WARN = 20; // dead-tuple ratio above this = autovacuum falling be
const pluginInstallCommands = computed(() => {
const mkt = (pluginMarketplaceUrl.value || '').trim()
|| serverMarketplaceUrl.value
|| '<your-scribe-repo>.git';
return `/plugin marketplace add ${mkt}\n/plugin install scribe@scribe-plugin`;
|| '<your-grimoire-repo>.git';
return `/plugin marketplace add ${mkt}\n/plugin install grimoire@grimoire-plugin`;
});
const mcpConfigSnippet = computed(() => JSON.stringify({
@@ -2644,7 +2644,7 @@ async function deleteUser(userId: number) {
<strong>project id</strong> to scope the session-start context.
</li>
<li>
Restart Claude Code — <code>/mcp</code> shows <code>scribe</code> connected and your
Restart Claude Code — <code>/mcp</code> shows <code>grimoire</code> connected and your
standing rules load at session start.
</li>
</ol>
@@ -2669,9 +2669,9 @@ async function deleteUser(userId: number) {
<div class="mcp-client-config">
<label class="mcp-config-field">
<span class="mcp-config-label">Server name</span>
<input v-model="mcpServerName" type="text" placeholder="scribe" class="settings-input" spellcheck="false" />
<input v-model="mcpServerName" type="text" placeholder="grimoire" class="settings-input" spellcheck="false" />
<span class="mcp-hint">
Local label for the MCP-only path below. Examples: <code>scribe</code>, <code>scribe-dev</code>.
Local label for the MCP-only path below. Examples: <code>grimoire</code>, <code>grimoire-dev</code>.
</span>
</label>
<label class="mcp-config-field">
@@ -2705,7 +2705,7 @@ async function deleteUser(userId: number) {
<div v-else-if="mcpClientTab === 'claude-desktop'">
<label class="mcp-config-field">
<span class="mcp-config-label">Server name</span>
<input v-model="mcpServerName" type="text" placeholder="scribe" class="settings-input" spellcheck="false" />
<input v-model="mcpServerName" type="text" placeholder="grimoire" class="settings-input" spellcheck="false" />
<span class="mcp-hint">The key used for this server in the config JSON below.</span>
</label>
<ol>
+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
+31 -38
View File
@@ -195,13 +195,13 @@ PATTERNS: list[tuple[re.Pattern, str, str]] = [
# itself with `command -v jq || exit 0` gives a machine without it no
# context, no rules, no prior art and no process sync, silently —
# `exit 0` is indistinguishable from "nothing to say". A hook may use
# only what POSIX guarantees; scribe_defs.sh has the readers.
# only what POSIX guarantees; grimoire_defs.sh has the readers.
re.compile(r"(?<![\w./-])(jq|tac)(?![\w./-])"),
"jq or tac in a hook",
"A hook may depend only on what POSIX guarantees — jq is not installed "
"by default anywhere the plugin is likely to land, and tac is GNU-only. "
"Read JSON with scribe_json_flat / _pick / _list / _len, write it with "
"scribe_json_out, encode with scribe_urlenc; to take the LAST match of "
"Read JSON with grimoire_json_flat / _pick / _list / _len, write it with "
"grimoire_json_out, encode with grimoire_urlenc; to take the LAST match of "
"a filter, pipe to `tail -1` instead of reversing with tac.",
),
(
@@ -259,7 +259,7 @@ def check_shellcheck() -> None:
for script in hook_scripts():
proc = subprocess.run(
# -x FOLLOWS `# shellcheck source=` directives into the sourced
# file. Without it the shared helpers in scribe_defs.sh are
# file. Without it the shared helpers in grimoire_defs.sh are
# invisible, so every variable they set reads as unassigned
# (SC2154) and every bug inside them goes unlinted at the call
# site — which is the opposite of what sharing them was for.
@@ -277,7 +277,7 @@ def check_shellcheck() -> None:
# Every hook promises never to break the operator's session: unconfigured or
# unreachable, it exits 0. Unconfigured, the enrichment hooks are SILENT — no
# call was owed. scribe_session_context.sh is the exception by design — it
# call was owed. grimoire_session_context.sh is the exception by design — it
# always emits a static behavioural floor that needs no credentials and no
# network, so "silent" would be the wrong assertion for it.
#
@@ -285,7 +285,7 @@ def check_shellcheck() -> None:
# that was owed and did not come back is SAID, once per outage ("> Grimoire did
# not answer …"), so a session can tell "checked, nothing there" from "never
# checked". That line — or silence, when the once-per-outage marker in
# ${TMPDIR:-/tmp}/scribe-priorart/ was set by a run in the last ten minutes —
# ${TMPDIR:-/tmp}/grimoire-priorart/ was set by a run in the last ten minutes —
# is the only output allowed with no working instance; anything else is a hook
# speaking on data it cannot have.
#
@@ -304,10 +304,10 @@ def check_shellcheck() -> None:
_ABSENT_SYM = "zz" + "_absent_" + "9f3a2b"
SMOKE_EVENTS: dict[str, str] = {
"scribe_autoinject.sh": json.dumps(
"grimoire_autoinject.sh": json.dumps(
{"session_id": "smoke", "cwd": ".", "prompt": "a multi-line\nprompt\nhere"}
),
"scribe_prior_art.sh": json.dumps(
"grimoire_prior_art.sh": json.dumps(
{"session_id": "smoke", "cwd": ".", "tool_name": "Edit",
"tool_input": {"file_path": "src/x.py",
"new_string": f"def {_ABSENT_SYM}():\n pass\n"}}
@@ -318,7 +318,7 @@ SMOKE_EVENTS: dict[str, str] = {
# SILENT — it is deliberately not an OUTAGE_SPEAKER, because a Bash call is
# not occasional and an outage line before every command gets the channel
# muted.
"scribe_tool_rules.sh": json.dumps(
"grimoire_tool_rules.sh": json.dumps(
{"session_id": "smoke", "cwd": ".", "tool_name": "Bash",
"tool_input": {"command": "curl -s https://example.invalid/api/v1/runs"}}
),
@@ -326,37 +326,30 @@ SMOKE_EVENTS: dict[str, str] = {
# work.deliver, so a configured run reaches the moment-tools fetch and the
# moment request; with no instance both fail and it must stay SILENT,
# since it fires before every call that can reach a mounted rule.
"scribe_moment.sh": json.dumps(
"grimoire_moment.sh": json.dumps(
{"session_id": "smoke", "cwd": ".", "tool_name": "Bash",
"tool_input": {"command": "git push origin dev"}}
),
"scribe_sync_processes.sh": json.dumps({"source": "startup"}),
"scribe_session_context.sh": json.dumps({"source": "startup"}),
"grimoire_sync_processes.sh": json.dumps({"source": "startup"}),
"grimoire_session_context.sh": json.dumps({"source": "startup"}),
# The after-write hook diffs the working tree; on CI's clean
# checkout there is nothing to report, so silence is the right assertion.
# (On a dirty local tree with a definition just written it may speak —
# that is the hook working, not a failure of the contract.)
"scribe_after_write.sh": json.dumps(
"grimoire_after_write.sh": json.dumps(
{"session_id": "smoke", "cwd": ".", "tool_name": "Bash",
"tool_input": {"command": "true"}, "tool_response": {}}
),
# The Stop-hook report check. A transcript that does
# not exist is the smoke case: nothing to read, so it must stay silent and
# never block, configured or not.
"scribe_report_check.sh": json.dumps(
{"session_id": "smoke", "transcript_path": "/nonexistent/smoke.jsonl",
"cwd": ".", "hook_event_name": "Stop", "stop_hook_active": False}
),
# The reply moment. No transcript, so no reply: silent and
# never a block, configured or not.
"scribe_reply_check.sh": json.dumps(
"grimoire_reply_check.sh": json.dumps(
{"session_id": "smoke", "transcript_path": "/nonexistent/smoke.jsonl",
"cwd": ".", "hook_event_name": "Stop", "stop_hook_active": False}
),
# The end-of-turn shape check. With no ledger for the
# session there is nothing to ask about, and with no instance there is
# nobody to ask: silent, and never a block, configured or not.
"scribe_shape_check.sh": json.dumps(
"grimoire_shape_check.sh": json.dumps(
{"session_id": "smoke", "transcript_path": "/nonexistent/smoke.jsonl",
"cwd": ".", "hook_event_name": "Stop", "stop_hook_active": False}
),
@@ -364,7 +357,7 @@ SMOKE_EVENTS: dict[str, str] = {
# every other hook's: it must exit 0 AND print, because stdout is what
# becomes the summarizer's custom instructions. A silent success here is
# the failure mode, and it would look like every other hook's success.
"scribe_precompact_preserve.sh": json.dumps(
"grimoire_precompact_preserve.sh": json.dumps(
{"session_id": "smoke", "cwd": ".", "hook_event_name": "PreCompact",
"trigger": "manual", "custom_instructions": None}
),
@@ -372,39 +365,39 @@ SMOKE_EVENTS: dict[str, str] = {
# nothing — a PostToolUse hook that emitted output would put a line in
# front of every get_rule call, which is the opposite of its purpose. The
# smoke case is a well-formed open: it must exit 0 and stay silent.
"scribe_record_opened.sh": json.dumps(
"grimoire_record_opened.sh": json.dumps(
{"session_id": "smoke", "cwd": ".",
"tool_name": "mcp__scribe__get_rule", "tool_input": {"rule_id": 1},
"tool_name": "mcp__grimoire__get_rule", "tool_input": {"rule_id": 1},
"tool_response": {}}
),
# The acted-ledger recorder. Same shape and same reasoning as its sibling
# above: TMPDIR only, and silent, because a PostToolUse hook that spoke
# would put a line after every rule_outcome call and turn recording an
# outcome into something with a cost.
"scribe_record_outcome.sh": json.dumps(
"grimoire_record_outcome.sh": json.dumps(
{"session_id": "smoke", "cwd": ".",
"tool_name": "mcp__scribe__rule_outcome",
"tool_name": "mcp__grimoire__rule_outcome",
"tool_input": {"rule_id": 1, "outcome": "applied"},
"tool_response": {}}
),
# The claim-session binder. Silent like the ledgers above —
# a PostToolUse hook that spoke would add a line after every task write —
# and with no instance configured it must exit before reaching for one.
"scribe_claim_session.sh": json.dumps(
"grimoire_claim_session.sh": json.dumps(
{"session_id": "smoke", "cwd": ".",
"tool_name": "mcp__scribe__add_task_log",
"tool_name": "mcp__grimoire__add_task_log",
"tool_input": {"task_id": 1, "content": "smoke"},
"tool_response": {}}
),
# The SessionEnd claim release. Silent: nobody reads
# a SessionEnd hook's output, and with no instance it must exit first.
"scribe_session_end.sh": json.dumps(
"grimoire_session_end.sh": json.dumps(
{"session_id": "smoke", "cwd": ".", "hook_event_name": "SessionEnd",
"reason": "prompt_input_exit"}
),
# The shared library is sourced, never run; executed bare it defines
# functions and exits — silent by construction.
"scribe_defs.sh": "",
"grimoire_defs.sh": "",
}
# The hooks that legitimately produce output with NO credentials, each for its
@@ -414,22 +407,22 @@ SMOKE_EVENTS: dict[str, str] = {
STATIC_EMITTERS = {
# The two-tier SessionStart design: the bundled static tier ships whatever
# the instance does, and that floor is the whole point of the split.
"scribe_session_context.sh": "static floor present",
"grimoire_session_context.sh": "static floor present",
# PreCompact's contract is INVERTED. Its stdout becomes the
# summarizer's custom instructions, and what must survive a summary is
# known without asking anything — so it needs no instance, and silence is
# the failure mode rather than the success one. Read as a generic hook it
# would look like a leak; it is the opposite.
"scribe_precompact_preserve.sh": "preservation instructions present",
"grimoire_precompact_preserve.sh": "preservation instructions present",
}
# The hooks that say so when a configured instance does not answer.
OUTAGE_SPEAKERS = {"scribe_prior_art.sh", "scribe_after_write.sh"}
OUTAGE_SPEAKERS = {"grimoire_prior_art.sh", "grimoire_after_write.sh"}
OUTAGE_LINE = "> Grimoire did not answer the prior-art check"
def _run_hook(script: Path, event: str, env_extra: dict[str, str]) -> subprocess.CompletedProcess:
env = {k: v for k, v in os.environ.items()
if not k.startswith(("SCRIBE_", "CLAUDE_PLUGIN_OPTION_"))}
if not k.startswith(("GRIMOIRE_", "CLAUDE_PLUGIN_OPTION_"))}
env.update(env_extra)
return subprocess.run(
["bash", str(script)], input=event, capture_output=True, text=True,
@@ -445,7 +438,7 @@ def check_fail_open() -> None:
("unconfigured", {}),
# Connection refused immediately — exercises the unreachable-instance
# path without waiting on a real network timeout.
("unreachable", {"SCRIBE_URL": "http://127.0.0.1:1", "SCRIBE_TOKEN": "x"}),
("unreachable", {"GRIMOIRE_URL": "http://127.0.0.1:1", "GRIMOIRE_TOKEN": "x"}),
]
for script in hook_scripts():
rel = script.relative_to(ROOT)
@@ -502,7 +495,7 @@ def check_local_prior_art_needs_no_instance() -> None:
that cannot exist. Together they pin both halves: silent when there is
nothing to say, and speaking when there is — both with no instance at all.
"""
script = HOOKS_DIR / "scribe_prior_art.sh"
script = HOOKS_DIR / "grimoire_prior_art.sh"
if not script.is_file():
skip("prior-art local arm: hook missing")
return
@@ -545,7 +538,7 @@ def check_session_context_reports_its_version() -> None:
is the one where the token never arrives, and a marker that vanished there
would be missing exactly when it is wanted.
"""
script = HOOKS_DIR / "scribe_session_context.sh"
script = HOOKS_DIR / "grimoire_session_context.sh"
if not script.is_file():
skip("version marker: hook missing")
return
+2 -2
View File
@@ -17,7 +17,7 @@ from quart import Quart
# the duplicate gate, the guessed-id refusal, `systems_hint`,
# `placement` and `report_back` — at the moment it applies.
# - The bundled skills (Agent Skills, client-neutral): every reflex in full.
# `using-scribe` owns the working reflexes; the process skills own arcs.
# `using-grimoire` owns the working reflexes; the process skills own arcs.
# - Client adapters (the Claude Code plugin today): timing and that
# client's own conventions, never a copy of the above.
# tests/test_guidance_ownership.py holds the topic registry that enforces it.
@@ -353,7 +353,7 @@ def build_mcp_server() -> MCPServer:
Transport settings (statelessness, DNS-rebinding protection) are not
here: since SDK v2 they belong to the ASGI app, built in `mount_mcp`.
"""
mcp = StrictArgsMCPServer("scribe", instructions=_INSTRUCTIONS.strip())
mcp = StrictArgsMCPServer("grimoire", instructions=_INSTRUCTIONS.strip())
from grimoire.mcp.tools import register_all
register_all(mcp)
return mcp
+1 -1
View File
@@ -84,7 +84,7 @@ async def create_process(
moments: The moments from list_moments this procedure is FOR — a
release procedure is a deliver, a review procedure a verify.
Loading the process then reaches each one, as well as its own
`skill.scribe-proc-<slug>`, so a rule mounted on work.deliver
`skill.grimoire-proc-<slug>`, so a rule mounted on work.deliver
arrives when the release procedure is loaded, before any of its
steps run.
force: Bypass the near-duplicate gate. By default, if a title- or
+1 -1
View File
@@ -1,6 +1,6 @@
"""Restore a Grimoire backup into an empty instance, from the command line.
python -m grimoire.restore /backups/scribe-20261015T020000Z.tar
python -m grimoire.restore /backups/grimoire-20261015T020000Z.tar
This is how a fresh instance is brought back after losing the old one. Run it
inside the Grimoire container, which already has the database settings and has
+6 -6
View File
@@ -73,7 +73,7 @@ async def session_context():
active project is determined — the plugin never pins a project id.
project_id (optional int) — the project named directly, and the only
key a caller outside a git repo has: the plugin's hooks
send it when a `.scribe` marker file names a project. Takes
send it when a `.grimoire` marker file names a project. Takes
precedence over `repo`. Access-checked like any other read — an id
this account cannot read loads no project rather than failing.
source (optional str) — the host's SessionStart source (startup,
@@ -467,7 +467,7 @@ async def write_path_prior_art():
are the ones shown in full on this call.
(Returns a `checkpoint` block on the same contract as /tool-rules —
see that endpoint. The write-path HOOK deliberately does not act on
it: scribe_prior_art.sh carries a tested property that it never
it: grimoire_prior_art.sh carries a tested property that it never
returns a permissionDecision, because a recall aid must not stand in
the way of a write. The block is computed and
returned so that decision can be revisited with evidence rather than
@@ -602,7 +602,7 @@ async def shape_check():
async def claim_session():
"""Bind the harness's session id to the caller's live claim on a task.
Called by `scribe_claim_session.sh` after `update_task` / `add_task_log`.
Called by `grimoire_claim_session.sh` after `update_task` / `add_task_log`.
The server has already stamped the claim on that write; this only says
WHICH session made it — an id the harness reported, not one the model
asserted. A GET for the reason every plugin endpoint is one: a read-scoped
@@ -631,7 +631,7 @@ async def claim_session():
async def release_session():
"""Release the claims a session held, as it ends.
Called by `scribe_session_end.sh`. Best-effort by design: SessionEnd does
Called by `grimoire_session_end.sh`. Best-effort by design: SessionEnd does
not fire on a crash, so the claim's lease — not this call — is what makes a
dead session's claim read as dead. This only makes the common case tidy.
@@ -650,8 +650,8 @@ async def release_session():
async def process_manifest():
"""Stored Processes as skill-stub specs for the plugin's sync script.
The plugin's `scribe_sync_processes.sh` (run at SessionStart and via the
`/scribe:sync` command) curls this and writes one auto-surfacing local skill
The plugin's `grimoire_sync_processes.sh` (run at SessionStart and via the
`/grimoire:sync` command) curls this and writes one auto-surfacing local skill
per Process into ~/.claude/skills/. See services/plugin_context.
build_process_manifest. A read-scoped API key suffices.
"""
+3 -3
View File
@@ -8,7 +8,7 @@ compares them against recorded snippet locations, so "record everything"
becomes a watched number instead of an aspiration.
The definition extractor MIRRORS the write-path hook's awk rules
(plugin/hooks/scribe_prior_art.sh, ARM 1) — one shared notion of "a
(plugin/hooks/grimoire_prior_art.sh, ARM 1) — one shared notion of "a
definition" between the hook and the server, so the metric and the backstop
agree on what counts. The two are pinned together by shared test vectors in
tests/test_pattern_coverage.py; change one, change both.
@@ -69,7 +69,7 @@ _SKIP_DIRS = frozenset({
_MAX_FILE_BYTES = 1_000_000
# --- the definition extractor (mirror of scribe_prior_art.sh ARM 1) ----------
# --- the definition extractor (mirror of grimoire_prior_art.sh ARM 1) ----------
_CSS_RE = re.compile(r"^\s*\.([A-Za-z][A-Za-z0-9_-]*)\s*[,{]")
# Leading declaration modifiers, so the definition keyword is the first word
@@ -105,7 +105,7 @@ _ARROW_RE = re.compile(
#
# The honest tool for .py would be `ast`, which cannot be fooled by prose at
# all. It is not what this uses, because this extractor is mirrored rule for
# rule by an awk program in plugin/hooks/scribe_defs.sh, awk cannot parse
# rule by an awk program in plugin/hooks/grimoire_defs.sh, awk cannot parse
# Python, and a fix only one of the pair can run is the drift the mirror
# exists to prevent. What both can do is blank the SPANS: a triple-quoted
# string or a /* … */ comment is replaced by its own newlines before any
+6 -6
View File
@@ -70,9 +70,9 @@ SKILL_TOOL, SKILL_FIELD = "skill", "skill"
# (`metadata: moments:`), and the declaration ships HERE as defaults, because
# the server never sees the plugin's files. `test_skill_moments` holds the two
# together. The plugin's name qualifies the skill, as the harness names it.
BUNDLED_SKILL_PLUGIN = "scribe"
BUNDLED_SKILL_PLUGIN = "grimoire"
BUNDLED_SKILL_MOMENTS: dict[str, tuple[str, ...]] = {
"using-scribe": ("session.start",),
"using-grimoire": ("session.start",),
"brainstorming": ("work.plan",),
"writing-plans": ("work.plan",),
"reusing-code": ("work.change",),
@@ -83,11 +83,11 @@ BUNDLED_SKILL_MOMENTS: dict[str, tuple[str, ...]] = {
"family-canon": ("work.record",),
}
# A stored process arrives as the skill `scribe-proc-<slug>`
# (scribe_sync_processes.sh). Its moments are the process's own field, read
# A stored process arrives as the skill `grimoire-proc-<slug>`
# (grimoire_sync_processes.sh). Its moments are the process's own field, read
# when it loads — not copied into the stub, which is written once a session
# and would go on firing a moment its process no longer declares.
PROCESS_SKILL_PREFIX = "scribe-proc-"
PROCESS_SKILL_PREFIX = "grimoire-proc-"
PROCESS_MOMENTS_FIELD = "moments"
PROCESS = "process"
@@ -353,7 +353,7 @@ async def moments_for(user_id: int, tool: str, tool_input: dict | None) -> list[
async def process_moments(user_id: int, skill: str) -> list[str]:
"""The moments the stored process behind `scribe-proc-<slug>` declares.
"""The moments the stored process behind `grimoire-proc-<slug>` declares.
The slug is resolved through the same manifest the sync script wrote the
skill from, so the two agree on which process a slug names. Fails open to
+3 -3
View File
@@ -508,8 +508,8 @@ def _slugify(text: str) -> str:
async def build_process_manifest(user_id: int) -> dict:
"""List the user's stored Processes as auto-surfacing skill-stub specs.
The plugin's sync script (scribe_sync_processes.sh) writes one
~/.claude/skills/scribe-proc-<slug>/SKILL.md per entry — `description` is the
The plugin's sync script (grimoire_sync_processes.sh) writes one
~/.claude/skills/grimoire-proc-<slug>/SKILL.md per entry — `description` is the
auto-surface trigger, and the stub body calls get_process(name) for the live
procedure (single source of truth in the DB). Reuses the list_processes query
(note_type='process'). Instance-agnostic: derived from whatever Processes the
@@ -2052,7 +2052,7 @@ async def build_session_context(
Args:
user_id: the operator.
project_id: the resolved active project (0 = none). The endpoint
resolves this from the working repo's remote, or from a `.scribe`
resolves this from the working repo's remote, or from a `.grimoire`
marker file naming the project directly — the only key a session
outside a git repo has. A non-zero id that does not
resolve is reported rather than silently dropped.
+1 -1
View File
@@ -223,7 +223,7 @@ POINTS: dict[str, Point] = dict([
# out would not fail here. What catches that is `assert_registered`, which the
# recorders call as the row is written.
# Keyed by the EXACT string the extractor produces, matched by equality rather
# than by prefix. The paths are relative to `src/` and so begin `scribe/`; a
# than by prefix. The paths are relative to `src/` and so begin `grimoire/`; a
# prefix spelled any other way silently matches nothing, and every site sails
# through a check that looks like it is running.
FAN_OUT_SITES: dict[str, tuple[str, ...]] = {
+1 -1
View File
@@ -953,7 +953,7 @@ async def snippet_consumers(user_id: int, note_id: int) -> dict:
# --- write-path stamping: hook evidence lands as rows -----------------------
#
# The write-path hook (plugin/hooks/scribe_prior_art.sh) fires on every
# The write-path hook (plugin/hooks/grimoire_prior_art.sh) fires on every
# Write/Edit and already carries the two halves of a consumer-map row: the
# file being written and the definitions in (or enclosing) the payload. What
# it could not say on its own is WHICH canon the session is instantiating.
+6 -6
View File
@@ -17,14 +17,14 @@ import pytest
from tests.helpers import http_sink
PLUGIN = Path(__file__).resolve().parents[1] / "plugin"
HOOK = PLUGIN / "hooks" / "scribe_after_write.sh"
HOOK = PLUGIN / "hooks" / "grimoire_after_write.sh"
def _env(tmp_path, url="http://127.0.0.1:9"):
for tool in ("git", "curl", "bash"):
if shutil.which(tool) is None:
pytest.skip(f"hook runtime tool {tool!r} not installed")
return {"PATH": os.environ["PATH"], "SCRIBE_URL": url, "SCRIBE_TOKEN": "t",
return {"PATH": os.environ["PATH"], "GRIMOIRE_URL": url, "GRIMOIRE_TOKEN": "t",
"TMPDIR": str(tmp_path), "HOME": str(tmp_path),
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@x",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@x"}
@@ -75,7 +75,7 @@ def test_after_write_names_what_bash_just_wrote_then_stays_quiet_until_the_next_
# The local by-name arm rides along: `slug` already lives in c.py.
assert "`slug` is already defined in 1 other file(s): c.py" in ctx["additionalContext"]
# Derive keys landed on the SHARED channel the pre-write hook reads.
state = tmp_path / "scribe-priorart" / "s-after-1.derive.ids"
state = tmp_path / "grimoire-priorart" / "s-after-1.derive.ids"
assert "dup:483a" in state.read_text().split()
# Nothing changed → one git status, no request, no output.
@@ -125,7 +125,7 @@ def test_after_write_local_arm_works_without_a_server_and_says_the_server_did_no
assert "Grimoire did not answer the prior-art check for `d.py` within 8s" in ctx
assert "UNCHECKED" in ctx
assert "None of those existing copies is recorded" not in ctx
marker = tmp_path / "scribe-priorart" / "s-local.unreached"
marker = tmp_path / "grimoire-priorart" / "s-local.unreached"
assert marker.is_file() and marker.read_text().isdigit()
# Still down a moment later: the local arm speaks, the outage line does not
# repeat (once per outage, not once per write).
@@ -139,7 +139,7 @@ def test_after_write_local_arm_works_without_a_server_and_says_the_server_did_no
def test_after_write_unconfigured_install_owes_no_call_and_keeps_the_record_nudge(tmp_path):
"""No URL/token → no call was owed, so nothing is "unreached"; the local
arm and the record nudge stand on their own."""
env = {k: v for k, v in _env(tmp_path).items() if k not in ("SCRIBE_URL", "SCRIBE_TOKEN")}
env = {k: v for k, v in _env(tmp_path).items() if k not in ("GRIMOIRE_URL", "GRIMOIRE_TOKEN")}
repo = _repo(tmp_path, env)
(repo / "d.py").write_text("def slug(t):\n return t.lower()\n")
out = _run(repo, env, session="s-unconf")
@@ -147,4 +147,4 @@ def test_after_write_unconfigured_install_owes_no_call_and_keeps_the_record_nudg
assert "`slug` is already defined in 1 other file(s): c.py" in ctx
assert "create_snippet" in ctx
assert "did not answer" not in ctx
assert not (tmp_path / "scribe-priorart" / "s-unconf.unreached").exists()
assert not (tmp_path / "grimoire-priorart" / "s-unconf.unreached").exists()
+1 -1
View File
@@ -25,7 +25,7 @@ from grimoire.services.plugin_context import (
)
from tests.helpers import fake_note, need_tools
DEFS = Path(__file__).resolve().parents[1] / "plugin" / "hooks" / "scribe_defs.sh"
DEFS = Path(__file__).resolve().parents[1] / "plugin" / "hooks" / "grimoire_defs.sh"
# --- the query the server builds ---------------------------------------------
+6 -6
View File
@@ -38,8 +38,8 @@ import pytest
from tests.helpers import need_tools
DEFS = Path(__file__).resolve().parents[1] / "plugin" / "hooks" / "scribe_defs.sh"
HOOK = Path(__file__).resolve().parents[1] / "plugin" / "hooks" / "scribe_prior_art.sh"
DEFS = Path(__file__).resolve().parents[1] / "plugin" / "hooks" / "grimoire_defs.sh"
HOOK = Path(__file__).resolve().parents[1] / "plugin" / "hooks" / "grimoire_prior_art.sh"
def run(script: str) -> str:
@@ -95,15 +95,15 @@ def q(text: str) -> str:
def block(repo, subject, old, new, ledger="") -> str:
return run(
f'scribe_contract_block "{repo}" "lib.py" "{subject}" '
f'grimoire_contract_block "{repo}" "lib.py" "{subject}" '
f'{q(old)} {q(new)} "{ledger}"'
)
# ── scribe_exposed: the three kinds ───────────────────────────────────────
# ── grimoire_exposed: the three kinds ───────────────────────────────────────
def exposed(text: str) -> set[str]:
out = run(f'printf %s {q(text)} | scribe_exposed')
out = run(f'printf %s {q(text)} | grimoire_exposed')
return {ln for ln in out.split("\n") if ln.strip()}
@@ -225,7 +225,7 @@ def test_the_write_hook_still_cannot_stop_a_write():
code = [ln for ln in HOOK.read_text().splitlines()
if not ln.lstrip().startswith("#")]
assert not any("permissionDecision" in ln for ln in code)
assert not any("scribe_json_deny" in ln for ln in code)
assert not any("grimoire_json_deny" in ln for ln in code)
def test_the_hook_asks_the_contract_question_first():
+2 -2
View File
@@ -5,7 +5,7 @@ import pytest
from grimoire.services.db_ready import asyncpg_dsn, wait_for_database
URL = "postgresql+asyncpg://scribe:p%40ss@db:5432/scribe"
URL = "postgresql+asyncpg://grimoire:p%40ss@db:5432/grimoire"
class _Clock:
@@ -45,7 +45,7 @@ def _connect_failing(errors):
def test_dsn_drops_the_driver_and_keeps_the_escaped_password():
assert asyncpg_dsn(URL) == "postgresql://scribe:p%40ss@db:5432/scribe"
assert asyncpg_dsn(URL) == "postgresql://grimoire:p%40ss@db:5432/grimoire"
async def test_returns_at_once_when_the_database_is_up():
+1 -1
View File
@@ -80,7 +80,7 @@ def test_the_skill_carries_the_reflexes_and_defers_the_lists_to_the_tools():
def test_the_plugin_names_the_skill_it_ships():
manifest = (ROOT / "plugin" / ".claude-plugin" / "plugin.json").read_text()
static = (ROOT / "plugin" / "hooks" / "scribe_static_context.md").read_text()
static = (ROOT / "plugin" / "hooks" / "grimoire_static_context.md").read_text()
assert "family-canon" in manifest and "family-canon" in " ".join(static.split())
@@ -1,11 +1,11 @@
"""`.scribe` — the second key a directory can be scoped by.
"""`.grimoire` — the second key a directory can be scoped by.
Every hook in `plugin/hooks/` scopes its request to a project. With only one
key, `git remote get-url origin`, a session outside a git repo has no key at
all, so a session in a plain directory is unscoped everywhere at once — silently, because a missing remote is indistinguishable
from a remote nobody bound.
`scribe_scope_query` is the shared answer, so these run the real shell rather
`grimoire_scope_query` is the shared answer, so these run the real shell rather
than a reimplementation of it. What they pin:
* the marker is found from a subdirectory, the way git finds its root;
@@ -30,8 +30,8 @@ from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
DEFS = ROOT / "plugin" / "hooks" / "scribe_defs.sh"
INSTANCE = "https://scribe.example.com"
DEFS = ROOT / "plugin" / "hooks" / "grimoire_defs.sh"
INSTANCE = "https://grimoire.example.com"
def _sh(script: str, cwd: Path, env_extra: dict | None = None) -> str:
@@ -48,7 +48,7 @@ def _sh(script: str, cwd: Path, env_extra: dict | None = None) -> str:
def _marker(d: Path, **fields) -> None:
(d / ".scribe").write_text(json.dumps(fields))
(d / ".grimoire").write_text(json.dumps(fields))
def _git_repo(d: Path, remote: str) -> None:
@@ -60,31 +60,31 @@ def _git_repo(d: Path, remote: str) -> None:
def test_a_bare_integer_is_a_valid_marker(tmp_path):
"""What someone writes by hand. It parses as JSON already, so one filter
reads it and the written form alike."""
(tmp_path / ".scribe").write_text("7\n")
assert _sh(f'scribe_scope_query "{tmp_path}"', tmp_path) == "project_id=7"
(tmp_path / ".grimoire").write_text("7\n")
assert _sh(f'grimoire_scope_query "{tmp_path}"', tmp_path) == "project_id=7"
def test_the_marker_is_found_from_a_subdirectory(tmp_path):
_marker(tmp_path, instance=INSTANCE, project_id=2, project="FabledScribe")
deep = tmp_path / "src" / "grimoire" / "services"
deep.mkdir(parents=True)
assert _sh(f'scribe_scope_query "{deep}"', tmp_path) == "project_id=2"
assert _sh(f'grimoire_scope_query "{deep}"', tmp_path) == "project_id=2"
def test_a_matching_instance_is_honoured(tmp_path):
"""Host-only comparison, so http/https and a trailing slash are not a
mismatch — only a genuinely different Grimoire is."""
_marker(tmp_path, instance="http://scribe.example.com/", project_id=2)
assert _sh(f'scribe_scope_query "{tmp_path}"', tmp_path) == "project_id=2"
_marker(tmp_path, instance="http://grimoire.example.com/", project_id=2)
assert _sh(f'grimoire_scope_query "{tmp_path}"', tmp_path) == "project_id=2"
def test_a_marker_for_another_instance_is_refused_with_a_reason(tmp_path):
"""The case the `instance` field exists for. No project beats the wrong
project, and the reason is carried so the session can say which it was."""
_marker(tmp_path, instance="https://someone-elses.example.org", project_id=2)
assert _sh(f'scribe_scope_query "{tmp_path}"', tmp_path) == ""
said = _sh(f'scribe_marker_read "{tmp_path}/.scribe"', tmp_path)
assert "someone-elses.example.org" in said and "scribe.example.com" in said
assert _sh(f'grimoire_scope_query "{tmp_path}"', tmp_path) == ""
said = _sh(f'grimoire_marker_read "{tmp_path}/.grimoire"', tmp_path)
assert "someone-elses.example.org" in said and "grimoire.example.com" in said
def test_the_marker_beats_a_git_remote(tmp_path):
@@ -92,21 +92,21 @@ def test_the_marker_beats_a_git_remote(tmp_path):
code happens to be pushed. This is also how a directory overrides its
binding."""
_git_repo(tmp_path, "git@git.example.com:someone/thing.git")
assert _sh(f'scribe_scope_query "{tmp_path}"', tmp_path).startswith("repo=")
assert _sh(f'grimoire_scope_query "{tmp_path}"', tmp_path).startswith("repo=")
_marker(tmp_path, instance=INSTANCE, project_id=2)
assert _sh(f'scribe_scope_query "{tmp_path}"', tmp_path) == "project_id=2"
assert _sh(f'grimoire_scope_query "{tmp_path}"', tmp_path) == "project_id=2"
def test_without_a_marker_the_git_remote_still_answers(tmp_path):
"""The path every existing install is on — it must not have moved."""
_git_repo(tmp_path, "git@git.example.com:someone/thing.git")
got = _sh(f'scribe_scope_query "{tmp_path}"', tmp_path)
got = _sh(f'grimoire_scope_query "{tmp_path}"', tmp_path)
assert got.startswith("repo=") and "git.example.com" in got.replace("%2F", "/")
def test_a_plain_directory_with_no_marker_scopes_to_nothing(tmp_path):
"""Not an error — the caller sends no scope and the server says so."""
assert _sh(f'scribe_scope_query "{tmp_path}"', tmp_path) == ""
assert _sh(f'grimoire_scope_query "{tmp_path}"', tmp_path) == ""
@pytest.mark.parametrize("body", ["", "not json at all", "{}", '{"project_id": 0}',
@@ -114,8 +114,8 @@ def test_a_plain_directory_with_no_marker_scopes_to_nothing(tmp_path):
def test_an_unusable_marker_is_refused_rather_than_sent(tmp_path, body):
"""A marker is operator-written and can say anything. Nothing that is not
a positive integer may reach the query string."""
(tmp_path / ".scribe").write_text(body)
assert _sh(f'scribe_scope_query "{tmp_path}"', tmp_path) == ""
(tmp_path / ".grimoire").write_text(body)
assert _sh(f'grimoire_scope_query "{tmp_path}"', tmp_path) == ""
def test_the_reason_survives_the_command_substitution_that_fetches_it(tmp_path):
@@ -133,7 +133,7 @@ def test_the_reason_survives_the_command_substitution_that_fetches_it(tmp_path):
"""
_marker(tmp_path, instance="https://elsewhere.example.org", project_id=2)
got = _sh(
f'read_out=$(scribe_marker_read "$(scribe_marker_file "{tmp_path}")")\n'
f'read_out=$(grimoire_marker_read "$(grimoire_marker_file "{tmp_path}")")\n'
'printf "id=[%s] why=[%s]" "${read_out%%$\'\\t\'*}" "${read_out#*$\'\\t\'}"',
tmp_path,
)
@@ -146,4 +146,4 @@ def test_the_walk_up_terminates_at_the_root(tmp_path):
is several levels down from /, so this really does walk."""
deep = tmp_path / "a" / "b" / "c"
deep.mkdir(parents=True)
assert _sh(f'scribe_scope_query "{deep}"', tmp_path) == ""
assert _sh(f'grimoire_scope_query "{deep}"', tmp_path) == ""
+9 -9
View File
@@ -97,7 +97,7 @@ def delivered_surfaces() -> dict[str, str]:
surfaces = {
"instructions": match.group(1),
"docstrings": "".join(p.read_text() for p in sorted((ROOT / "src/grimoire/mcp/tools").glob("*.py"))),
"static": (ROOT / "plugin/hooks/scribe_static_context.md").read_text(),
"static": (ROOT / "plugin/hooks/grimoire_static_context.md").read_text(),
"commands": "".join(p.read_text() for p in sorted((ROOT / "plugin/commands").glob("*.md"))),
"live": _live_session_context_source(),
"shapes": _delivered_shapes(),
@@ -122,11 +122,11 @@ class Topic(NamedTuple):
shared_with: tuple[str, ...] = () # other surfaces allowed the statement, with a reason
U = "skill:using-scribe"
U = "skill:using-grimoire"
TOPICS: tuple[Topic, ...] = (
# ── the working reflexes — owned by the using-scribe skill ──
Topic("scribe is the system of record; keep one copy", U, ("one copy",),
# ── the working reflexes — owned by the using-grimoire skill ──
Topic("grimoire is the system of record; keep one copy", U, ("one copy",),
"let any existing local memory shrink", index=("one copy",)),
Topic("orient: enter the project, check repo bindings", U, ("enter_project", "list_repo_bindings"),
"the milestones and open tasks worked on most recently",
@@ -195,7 +195,7 @@ TOPICS: tuple[Topic, ...] = (
"whichever home it gets"),
Topic("a rule vs the other entities", U, ("standing instruction",), "first ask whether it's a rule at all"),
# Moments. Delivery at a moment and its in-session
# corrections are owned by using-scribe's moments.md; the index carries one
# corrections are owned by using-grimoire's moments.md; the index carries one
# clause, and a new rule's moments are asked where its home and trigger are.
Topic("rules mounted on a moment arrive by lookup; correct a misfire in-session", U,
("list_moments", "map_action", "unmap_action", "judge_rule_moments"),
@@ -294,7 +294,7 @@ TOPICS: tuple[Topic, ...] = (
# ── per-tool contracts and in-band behaviour — owned by the server ──
Topic("closing a task cues the report", "docstrings", ("report_back",), "reporting this to the operator?"),
# The agent is the judge. Two topics, not one, because they fire
# at different moments and say different things: using-scribe owns the
# at different moments and say different things: using-grimoire owns the
# authority — what you may decide and that deciding means READING, not
# thresholding — while reporting-back owns the shape a report takes when
# the judgment is made. They are stated separately rather than shared so
@@ -323,7 +323,7 @@ TOPICS: tuple[Topic, ...] = (
Topic("an unbound repo gets a bind hint", "live", ("bind_repo",), "isn't mapped to a grimoire project"),
# ── the Claude Code adapter's own conventions ──
Topic("compact at clean seams", "static", ("/compact",), "compact at clean seams"),
Topic("stored processes sync into local skills", "commands", ("scribe-proc-",), "regenerate the local skill stubs"),
Topic("stored processes sync into local skills", "commands", ("grimoire-proc-",), "regenerate the local skill stubs"),
Topic("say so when grimoire's tools are unavailable", "static", ("tools are unavailable",),
"rather than silently falling back to local notes"),
)
@@ -430,7 +430,7 @@ def test_the_ownership_guards_can_fail():
# - "claude", "~/.claude", ".claude/" the client itself and its paths
# - "claude.md", "memory.md", "auto-memory", "automemoryenabled"
# Claude Code's local memory files/setting
# - "/compact", "/scribe:" Claude Code slash commands
# - "/compact", "/grimoire:" Claude Code slash commands
# - "sessionstart", "userpromptsubmit", "pretooluse", "posttooluse"
# Claude Code hook event names
# - "write/edit" Claude Code's editor tool names
@@ -439,7 +439,7 @@ def test_the_ownership_guards_can_fail():
# name, while "bash" in prose is just the shell.
CLIENT_SPECIFIC = (
"claude", "~/.claude", ".claude/", "claude.md", "memory.md", "auto-memory",
"automemoryenabled", "/compact", "/scribe:", "sessionstart", "userpromptsubmit",
"automemoryenabled", "/compact", "/grimoire:", "sessionstart", "userpromptsubmit",
"pretooluse", "posttooluse", "write/edit", "claude_plugin_root",
)
CLIENT_TOOL_NAME = re.compile(r"\bBash\b")
+7 -7
View File
@@ -1,7 +1,7 @@
"""The by-name duplicate arm confirms its grep hits against the real extractor.
The extractors that decide what a payload DEFINES blank comment and string
spans before any line matcher runs. This is the other end — `scribe_local_dups`,
spans before any line matcher runs. This is the other end — `grimoire_local_dups`,
which decides which OTHER files already define that name — and a plain
`git grep` cannot do that.
@@ -34,7 +34,7 @@ from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
DEFS = ROOT / "plugin" / "hooks" / "scribe_defs.sh"
DEFS = ROOT / "plugin" / "hooks" / "grimoire_defs.sh"
@pytest.fixture
@@ -62,7 +62,7 @@ def repo(tmp_path):
def run(names: str, writing: str = "new.py") -> str:
script = (f'set -uo pipefail\n. "{DEFS}"\n'
f'printf %s "$NAMES" | scribe_local_dups "{root}" {writing}\n')
f'printf %s "$NAMES" | grimoire_local_dups "{root}" {writing}\n')
out = subprocess.run(["bash", "-c", script], capture_output=True,
text=True, env={**env, "NAMES": names}, timeout=120)
assert out.returncode == 0, out.stderr
@@ -117,7 +117,7 @@ def test_the_repos_own_prose_does_not_define_with():
def arm(name: str) -> str:
script = (f'set -uo pipefail\n. "{DEFS}"\n'
f'printf "sym\\t{name}\\n" | scribe_local_dups "{ROOT}" nothing.py\n')
f'printf "sym\\t{name}\\n" | grimoire_local_dups "{ROOT}" nothing.py\n')
out = subprocess.run(["bash", "-c", script], capture_output=True,
text=True, timeout=180)
assert out.returncode == 0, out.stderr
@@ -168,10 +168,10 @@ def test_the_candidate_cap_is_raised_above_the_display_cap():
anyone looked at them. Asserted on structure because the ordering is what
matters, not the numbers."""
src = DEFS.read_text()
for const in ("_SCRIBE_DUP_CANDIDATES=", "_SCRIBE_DUP_SHOWN="):
for const in ("_GRIMOIRE_DUP_CANDIDATES=", "_GRIMOIRE_DUP_SHOWN="):
assert const in src, f"{const.rstrip(chr(61))} is gone — the two caps were folded back together"
cands = int(src.split("_SCRIBE_DUP_CANDIDATES=")[1].split("\n")[0])
shown = int(src.split("_SCRIBE_DUP_SHOWN=")[1].split("\n")[0])
cands = int(src.split("_GRIMOIRE_DUP_CANDIDATES=")[1].split("\n")[0])
shown = int(src.split("_GRIMOIRE_DUP_SHOWN=")[1].split("\n")[0])
assert cands > shown, (
f"candidates ({cands}) must exceed the display cap ({shown}), or "
"confirmation can only ever shrink an already-truncated window"
+27 -27
View File
@@ -4,7 +4,7 @@ WHY NOT jq, AND WHY THIS NEEDS TESTING AT ALL. jq is absent by default on
macOS, on the Debian/Ubuntu slim images, on Alpine and in most CI containers,
and a hook that exits quietly without it gives the operator no session context,
no rules, no prior art and no process sync, with not one word saying why. The
parser is `plugin/hooks/scribe_json.awk`, written in POSIX awk, and a parser is exactly
parser is `plugin/hooks/grimoire_json.awk`, written in POSIX awk, and a parser is exactly
the kind of thing that works on the six documents you tried it on.
So the centre of this file is a DIFFERENTIAL against Python's `json`: the same
@@ -30,13 +30,13 @@ import pytest
from tests.helpers import need_tools
HOOKS = Path(__file__).resolve().parents[1] / "plugin" / "hooks"
DEFS = HOOKS / "scribe_defs.sh"
PARSER = HOOKS / "scribe_json.awk"
TURN = HOOKS / "scribe_turn.awk"
DEFS = HOOKS / "grimoire_defs.sh"
PARSER = HOOKS / "grimoire_json.awk"
TURN = HOOKS / "grimoire_turn.awk"
def sh(script: str, stdin: str = "") -> str:
"""Run a snippet with scribe_defs.sh sourced, under the hooks' own flags.
"""Run a snippet with grimoire_defs.sh sourced, under the hooks' own flags.
BYTES IN, BYTES OUT, decoded here — NOT `text=True`. Text mode turns on
universal newlines, which rewrites a carriage return in the output to a
@@ -65,7 +65,7 @@ def flat(doc: str, mode: str = "whole") -> list[tuple[str, str, str]]:
# The differential: this parser against Python's.
def reference_flatten(value, path="", out=None):
"""What scribe_json.awk is specified to emit, computed with `json`.
"""What grimoire_json.awk is specified to emit, computed with `json`.
A string's VALUE is its RAW escaped body — the parser deliberately does not
decode, because a newline inside a value would break the line format the
@@ -150,7 +150,7 @@ def test_a_value_survives_the_round_trip_the_hooks_actually_make(value):
character entry matters most: it is longer than the parser's token window,
so it takes the wide-read fallback rather than the fast path."""
doc = json.dumps({"tool_input": {"content": value}}, ensure_ascii=False)
got = sh('flat=$(scribe_json_flat); scribe_json_pick "$flat" \'.tool_input.content\'',
got = sh('flat=$(grimoire_json_flat); grimoire_json_pick "$flat" \'.tool_input.content\'',
stdin=doc)
assert got.rstrip("\n") == value.rstrip("\n")
@@ -170,7 +170,7 @@ def test_an_escaped_character_decodes_to_utf8_in_any_locale(value, locale):
if locale:
env["LC_ALL"] = locale
body = json.dumps(value)[1:-1]
r = subprocess.run(["bash", "-c", f'. "{DEFS}"; scribe_json_unescape'],
r = subprocess.run(["bash", "-c", f'. "{DEFS}"; grimoire_json_unescape'],
input=body.encode(), capture_output=True, env=env)
assert r.returncode == 0, r.stderr.decode()
assert r.stdout == (value + "\n").encode()
@@ -190,14 +190,14 @@ def test_pick_list_and_len_read_what_the_server_sends():
"sync_note_ids": [22], "derive_keys": ["k1", "k2"],
"rule_ids": [], "project": {"id": 7}})
out = sh(r"""
flat=$(scribe_json_flat)
echo "ctx=$(scribe_json_pick "$flat" '.context')"
echo "pid=$(scribe_json_pick "$flat" '.project.id')"
echo "notes=$(scribe_json_list "$flat" '.note_ids' | tr '\n' ',')"
echo "keys=$(scribe_json_list "$flat" '.derive_keys' | tr '\n' ',')"
echo "n=$(scribe_json_len "$flat" '.note_ids')"
echo "rules_n=$(scribe_json_len "$flat" '.rule_ids')"
echo "minus=$(scribe_json_list_minus "$flat" '.note_ids' '.sync_note_ids' | tr '\n' ',')"
flat=$(grimoire_json_flat)
echo "ctx=$(grimoire_json_pick "$flat" '.context')"
echo "pid=$(grimoire_json_pick "$flat" '.project.id')"
echo "notes=$(grimoire_json_list "$flat" '.note_ids' | tr '\n' ',')"
echo "keys=$(grimoire_json_list "$flat" '.derive_keys' | tr '\n' ',')"
echo "n=$(grimoire_json_len "$flat" '.note_ids')"
echo "rules_n=$(grimoire_json_len "$flat" '.rule_ids')"
echo "minus=$(grimoire_json_list_minus "$flat" '.note_ids' '.sync_note_ids' | tr '\n' ',')"
""", stdin=body)
got = dict(line.split("=", 1) for line in out.splitlines())
assert got == {"ctx": "some markdown", "pid": "7", "notes": "11,22,33,",
@@ -209,9 +209,9 @@ def test_an_absent_field_and_a_null_field_both_read_as_empty():
test `[ -n "$x" ]`. A field the server left null and one it never sent mean
the same thing to all of them."""
out = sh(r"""
flat=$(scribe_json_flat)
printf '[%s][%s][%s]\n' "$(scribe_json_pick "$flat" '.nothing')" \
"$(scribe_json_pick "$flat" '.nulled')" "$(scribe_json_pick "$flat" '.real')"
flat=$(grimoire_json_flat)
printf '[%s][%s][%s]\n' "$(grimoire_json_pick "$flat" '.nothing')" \
"$(grimoire_json_pick "$flat" '.nulled')" "$(grimoire_json_pick "$flat" '.real')"
""", stdin=json.dumps({"nulled": None, "real": "here"}))
assert out.strip() == "[][][here]"
@@ -220,9 +220,9 @@ def test_an_absent_array_length_is_empty_not_zero():
"""NOT the same answer as 0, and the distinction is load-bearing: the
record nudge fires when the server said "zero notes" and must not fire
when the server said nothing at all."""
out = sh(r"""flat=$(scribe_json_flat)
printf '[%s][%s]\n' "$(scribe_json_len "$flat" '.note_ids')" \
"$(scribe_json_len "$flat" '.missing')" """,
out = sh(r"""flat=$(grimoire_json_flat)
printf '[%s][%s]\n' "$(grimoire_json_len "$flat" '.note_ids')" \
"$(grimoire_json_len "$flat" '.missing')" """,
stdin=json.dumps({"note_ids": []}))
assert out.strip() == "[0][]"
@@ -252,7 +252,7 @@ def test_the_hook_envelope_is_valid_json_carrying_the_exact_context(value):
"""The one JSON document these hooks WRITE. Five hooks emitted it through
`jq -n --arg c`; getting the escaping wrong corrupts every injected context
that contains a Windows path, a regex or a quoted word."""
out = sh('scribe_json_out PreToolUse "$(cat)"', stdin=value)
out = sh('grimoire_json_out PreToolUse "$(cat)"', stdin=value)
parsed = json.loads(out)
assert parsed["hookSpecificOutput"]["hookEventName"] == "PreToolUse"
assert parsed["hookSpecificOutput"]["additionalContext"] == value.rstrip("\n")
@@ -265,15 +265,15 @@ def test_urlenc_percent_encodes_exactly_the_unreserved_set(value):
an accented letter as one and mawk as two, and an encoder built on substr()
would produce a different URL depending on which awk is installed.
Percent-encoding is defined on BYTES."""
got = sh("scribe_urlenc", stdin=value)
got = sh("grimoire_urlenc", stdin=value)
assert got == quote(value, safe="")
def test_urlenc_and_the_envelope_can_fail():
"""A guard that cannot fail is decoration. If the escaper were a
no-op, this pair would be the assertion that noticed."""
assert sh("scribe_urlenc", stdin="a b") != "a b"
raw = sh('scribe_json_out PreToolUse "$(cat)"', stdin='he said "hi"')
assert sh("grimoire_urlenc", stdin="a b") != "a b"
raw = sh('grimoire_json_out PreToolUse "$(cat)"', stdin='he said "hi"')
assert '\\"hi\\"' in raw, raw
@@ -360,7 +360,7 @@ def test_the_reply_is_the_text_after_the_last_action_and_keeps_its_newlines():
facts = _turn([_prompt(), _text("thinking out loud"), _close("t1", 41),
_text("line one\nline two"), _text("line three")])
assert facts["reply"] == "line one\\nline two\\nline three"
decoded = sh("scribe_json_unescape", stdin=facts["reply"])
decoded = sh("grimoire_json_unescape", stdin=facts["reply"])
assert decoded == "line one\nline two\nline three\n"
assert "thinking out loud" not in decoded
+2 -2
View File
@@ -20,7 +20,7 @@ from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
DEFS = ROOT / "plugin" / "hooks" / "scribe_defs.sh"
DEFS = ROOT / "plugin" / "hooks" / "grimoire_defs.sh"
def test_the_duplicate_arm_still_names_a_name_defined_in_thousands_of_files(tmp_path):
@@ -40,7 +40,7 @@ def test_the_duplicate_arm_still_names_a_name_defined_in_thousands_of_files(tmp_
subprocess.run(["git", "add", "."], cwd=repo, check=True, env=env)
subprocess.run(["git", "commit", "-q", "-m", "base"], cwd=repo, check=True, env=env)
script = f'set -uo pipefail\n. "{DEFS}"\nprintf "sym\\tslug\\n" | scribe_local_dups "{repo}" new.py\n'
script = f'set -uo pipefail\n. "{DEFS}"\nprintf "sym\\tslug\\n" | grimoire_local_dups "{repo}" new.py\n'
out = subprocess.run(["bash", "-c", script], capture_output=True, text=True, env=env, timeout=60)
assert out.returncode == 0, out.stderr
assert "`slug` is already defined in 4 other file(s)" in out.stdout
+6 -6
View File
@@ -3,14 +3,14 @@
tests/test_guidance_ownership.py enforces who states each topic: every topic
stated in full by exactly one owner, with a one-line pointer in the server's
index. How to ask for a rule and what an empty session means are the rules
topic, owned by using-scribe and indexed in `_INSTRUCTIONS`; the Systems
topic, owned by using-grimoire and indexed in `_INSTRUCTIONS`; the Systems
reflex and the snippet-recording triggers are their topics' markers, required
on their owners.
WHAT STAYS HERE
Properties of a CLAIM rather than of who owns a topic: the index fits the
fold; a surface that says rules bind also names what does not; using-scribe
fold; a surface that says rules bind also names what does not; using-grimoire
still sends a session to the reporting-back skill.
"""
from __future__ import annotations
@@ -148,7 +148,7 @@ def test_the_index_names_all_three_strengths():
f"client receives, and a record kind it does not name is one the session "
f"has to guess the force of — which it does by reaching for the kind it "
f"does know. Keep it to a clause; the full statement lives on "
f"using-scribe."
f"using-grimoire."
)
@@ -165,18 +165,18 @@ def test_the_strength_guard_can_fail():
# ── Reporting back ─────────────────────────────────────────────────────
#
# The reporting-back skill carries the shapes, but a skill only helps if it
# fires. The reflex that sends a session to it is stated in using-scribe, and
# fires. The reflex that sends a session to it is stated in using-grimoire, and
# the in-band cue on update_task (`placement`, `report_back`) is the half that
# fires on its own in every client. The server's index carries no REPORT line:
# reporting is a mid-work stance, not a session-start practice.
REPORT_REFLEX = "report back in a shape the operator can read"
REPORT_SURFACES = (
ROOT / "plugin" / "skills" / "using-scribe" / "SKILL.md",
ROOT / "plugin" / "skills" / "using-grimoire" / "SKILL.md",
)
def test_the_reporting_reflex_is_stated_in_using_scribe():
def test_the_reporting_reflex_is_stated_in_using_grimoire():
missing = []
for path in REPORT_SURFACES:
text = " ".join(path.read_text().split()).lower()
+13 -13
View File
@@ -86,7 +86,7 @@ def test_an_unknown_tool_reaches_nothing():
@pytest.mark.parametrize("name", [
"mcp__plugin_scribe_scribe__update_task", "mcp__scribe__update_task",
"mcp__plugin_grimoire_grimoire__update_task", "mcp__grimoire__update_task",
"update_task", "Update_Task",
])
def test_the_server_prefix_and_case_do_not_matter(name):
@@ -115,10 +115,10 @@ def test_loading_a_procedure_reaches_its_own_moment():
def test_a_bundled_skill_also_reaches_the_moment_it_is_for():
"""Loading the planning procedure IS planning: a rule mounted on work.plan
arrives on the load, not only on the plan it goes on to open."""
assert _reached("Skill", {"skill": "scribe:writing-plans"}) == [
"work.plan", "skill.scribe:writing-plans",
assert _reached("Skill", {"skill": "grimoire:writing-plans"}) == [
"work.plan", "skill.grimoire:writing-plans",
]
assert _reached("Skill", {"skill": "scribe:reporting-back"})[0] == "reply.report"
assert _reached("Skill", {"skill": "grimoire:reporting-back"})[0] == "reply.report"
def test_only_the_products_own_skill_reaches_a_bundled_default():
@@ -135,16 +135,16 @@ def test_a_procedure_name_that_is_not_a_moment_reaches_nothing():
# ── a stored process's own moments ───────────────────────────────────────
def test_a_process_declares_moments_its_load_reaches():
hits = ma.resolve("Skill", {"skill": "scribe-proc-release"}, [], ["work.deliver"])
hits = ma.resolve("Skill", {"skill": "grimoire-proc-release"}, [], ["work.deliver"])
assert [(h["moment"], h["via"]) for h in hits] == [
("work.deliver", ma.PROCESS), ("skill.scribe-proc-release", ma.DEFAULT),
("work.deliver", ma.PROCESS), ("skill.grimoire-proc-release", ma.DEFAULT),
]
assert hits[0]["match"] == "skill=scribe-proc-release"
assert hits[0]["match"] == "skill=grimoire-proc-release"
def test_a_declared_moment_another_action_reaches_is_named_once():
rows = [_row("Skill", "skill=scribe-proc-release", "work.deliver")]
hits = ma.resolve("Skill", {"skill": "scribe-proc-release"}, rows, ["work.deliver"])
rows = [_row("Skill", "skill=grimoire-proc-release", "work.deliver")]
hits = ma.resolve("Skill", {"skill": "grimoire-proc-release"}, rows, ["work.deliver"])
assert [h["moment"] for h in hits].count("work.deliver") == 1
@@ -169,11 +169,11 @@ async def test_only_a_process_skill_asks_for_its_process():
asked = AsyncMock(return_value=["work.deliver"])
with patch.object(ma, "list_mappings", AsyncMock(return_value=[])), \
patch.object(ma, "process_moments", asked):
reached = await ma.moments_for(1, "Skill", {"skill": "scribe-proc-release"})
await ma.moments_for(1, "Skill", {"skill": "scribe:verification"})
reached = await ma.moments_for(1, "Skill", {"skill": "grimoire-proc-release"})
await ma.moments_for(1, "Skill", {"skill": "grimoire:verification"})
await ma.moments_for(1, "Bash", {"command": "git push"})
assert "work.deliver" in [h["moment"] for h in reached]
asked.assert_awaited_once_with(1, "scribe-proc-release")
asked.assert_awaited_once_with(1, "grimoire-proc-release")
async def _process_moments(manifest, note=None, *, raises=False):
@@ -184,7 +184,7 @@ async def _process_moments(manifest, note=None, *, raises=False):
loader = AsyncMock(return_value=(note, None) if note is not None else None)
with patch.object(plugin_context, "build_process_manifest", built), \
patch.object(notes_svc, "get_note_for_user", loader):
return await ma.process_moments(1, "scribe-proc-release"), loader
return await ma.process_moments(1, "grimoire-proc-release"), loader
async def test_a_process_slug_resolves_through_the_sync_manifest():
+15 -15
View File
@@ -34,7 +34,7 @@ from tests.helpers import fake_rule, http_sink, need_tools
_REAL_DELIVER = md.deliver_for_act
ROOT = Path(__file__).resolve().parents[1]
HOOK = ROOT / "plugin" / "hooks" / "scribe_moment.sh"
HOOK = ROOT / "plugin" / "hooks" / "grimoire_moment.sh"
def _rule(rid, title="Done means delivered and checked"):
@@ -269,20 +269,20 @@ def test_the_hook_is_registered_on_every_tool():
manifest = json.loads((ROOT / "plugin" / "hooks" / "hooks.json").read_text())
entries = {m.get("matcher"): [h["command"] for h in m["hooks"]]
for m in manifest["hooks"]["PreToolUse"]}
assert any("scribe_moment.sh" in c for c in entries.get("*", []))
assert any("grimoire_moment.sh" in c for c in entries.get("*", []))
def test_the_hook_and_the_routes_agree_on_every_name():
"""Parity across the shell/Python seam: a renamed arg fails silently."""
src = HOOK.read_text()
assert "/api/plugin/moment-tools" in src and "/api/plugin/moment" in src
assert "exclude_rule_ids" in src and "scribe_held_query" in src
assert "scribe_rules_live" in src and "scribe_rules_append" in src
assert "exclude_rule_ids" in src and "grimoire_held_query" in src
assert "grimoire_rules_live" in src and "grimoire_rules_append" in src
# The SHARED ledger, not one of its own.
assert '"${TMPDIR:-/tmp}/scribe-priorart"' in src and ".rules.ids" in src
assert '"${TMPDIR:-/tmp}/grimoire-priorart"' in src and ".rules.ids" in src
for field in (".tools", ".rule_ids", ".context", ".shape_keys"):
assert f"'{field}'" in src
assert "shapes_seen" in src and "scribe_shapes_append" in src
assert "shapes_seen" in src and "grimoire_shapes_append" in src
from grimoire.routes import plugin as routes
@@ -293,19 +293,19 @@ def test_the_hook_and_the_routes_agree_on_every_name():
def test_the_hook_leaves_scribes_own_tools_to_their_responses():
assert "mcp__*scribe*__*) exit 0" in HOOK.read_text()
assert "mcp__*grimoire*__*) exit 0" in HOOK.read_text()
def test_the_hook_never_returns_a_permission_decision():
"""A mount is a recall aid: it informs the act and never holds it."""
code = [ln for ln in HOOK.read_text().splitlines() if not ln.lstrip().startswith("#")]
assert not any("permissionDecision" in ln or "scribe_json_deny" in ln for ln in code)
assert not any("permissionDecision" in ln or "grimoire_json_deny" in ln for ln in code)
def _hook(tmp_path, port, tool="Bash", command="git push origin dev", session="s-moment"):
need_tools("bash", "curl", "awk")
env = {"PATH": os.environ["PATH"], "SCRIBE_URL": f"http://127.0.0.1:{port}",
"SCRIBE_TOKEN": "t", "TMPDIR": str(tmp_path), "HOME": str(tmp_path)}
env = {"PATH": os.environ["PATH"], "GRIMOIRE_URL": f"http://127.0.0.1:{port}",
"GRIMOIRE_TOKEN": "t", "TMPDIR": str(tmp_path), "HOME": str(tmp_path)}
out = subprocess.run(
["bash", str(HOOK)],
input=json.dumps({"session_id": session, "cwd": str(tmp_path), "tool_name": tool,
@@ -341,7 +341,7 @@ def test_a_listed_tool_is_asked_about_and_its_rules_injected(tmp_path):
def test_an_unlisted_tool_never_leaves_the_machine(tmp_path):
with http_sink(by_path=TOOLS) as (port, seen):
assert _hook(tmp_path, port, tool="Read") == ""
assert _hook(tmp_path, port, tool="mcp__plugin_scribe_scribe__update_task") == ""
assert _hook(tmp_path, port, tool="mcp__plugin_grimoire_grimoire__update_task") == ""
assert [e["_path"] for e in seen] == ["/api/plugin/moment-tools"]
@@ -371,16 +371,16 @@ def _attached_names(path: Path) -> dict[str, set[str]]:
return out
def test_every_scribe_tool_the_defaults_name_attaches_its_moment_rules():
def test_every_grimoire_tool_the_defaults_name_attaches_its_moment_rules():
tools_dir = ROOT / "src" / "grimoire" / "mcp" / "tools"
defined: dict[str, dict[str, set[str]]] = {}
scribe_tools: set[str] = set()
grimoire_tools: set[str] = set()
for path in tools_dir.glob("*.py"):
tree = ast.parse(path.read_text())
scribe_tools |= {n.name for n in tree.body if isinstance(n, ast.AsyncFunctionDef)}
grimoire_tools |= {n.name for n in tree.body if isinstance(n, ast.AsyncFunctionDef)}
defined[path.name] = _attached_names(path)
shipped = {a.tool for a in moment_actions.DEFAULT_ACTIONS} & scribe_tools
shipped = {a.tool for a in moment_actions.DEFAULT_ACTIONS} & grimoire_tools
assert {"update_task", "create_rule", "update_milestone"} <= shipped
for tool in sorted(shipped):
named = set().union(*(per_file.get(tool, set()) for per_file in defined.values()))
+2 -2
View File
@@ -60,8 +60,8 @@ def test_every_catalog_moment_is_mountable(name):
@pytest.mark.parametrize("raw,clean", [
(" Work.Finish ", "work.finish"),
("skill.verification", "skill.verification"),
("skill.scribe:writing-plans", "skill.scribe:writing-plans"),
("skill.scribe-proc-release_notes", "skill.scribe-proc-release_notes"),
("skill.grimoire:writing-plans", "skill.grimoire:writing-plans"),
("skill.grimoire-proc-release_notes", "skill.grimoire-proc-release_notes"),
])
def test_a_name_is_normalised_before_it_is_judged(raw, clean):
assert moments.require_moment(raw) == clean
+3 -3
View File
@@ -1,7 +1,7 @@
"""Pattern-library coverage — the extractor that mirrors the hook,
the shape/record matcher, and the end-to-end measurement on real Postgres.
The extractor here and the hook's awk program (scribe_prior_art.sh ARM 1)
The extractor here and the hook's awk program (grimoire_prior_art.sh ARM 1)
must agree on what counts as "a definition" — the metric and the write-path
backstop are two views of the same doctrine. The EXTRACTION_VECTORS below
deliberately reuse the definitions test_write_path_trigger stages for the
@@ -137,9 +137,9 @@ def test_the_hook_extractor_runs_and_agrees_line_for_line(text):
"""
if shutil.which("awk") is None: # pragma: no cover - env guard
pytest.skip("awk not available")
lib = PLUGIN / "hooks" / "scribe_defs.sh"
lib = PLUGIN / "hooks" / "grimoire_defs.sh"
out = subprocess.run(
["bash", "-c", f'. "{lib}"; scribe_defs'],
["bash", "-c", f'. "{lib}"; grimoire_defs'],
input=text, capture_output=True, text=True,
)
assert out.returncode == 0, out.stderr
+5 -5
View File
@@ -124,10 +124,10 @@ def test_every_deriver_exists():
def manifest(**fields) -> str:
base = {
"name": "scribe",
"name": "grimoire",
"description": "d",
"version": "0.1.48",
"mcpServers": {"scribe": {"type": "http", "url": "${user_config.api_endpoint}/mcp"}},
"mcpServers": {"grimoire": {"type": "http", "url": "${user_config.api_endpoint}/mcp"}},
"userConfig": {"api_endpoint": {"type": "string"}},
}
base.update(fields)
@@ -149,7 +149,7 @@ def test_an_identical_manifest_is_not_a_change():
@pytest.mark.parametrize("field,value", [
("userConfig", {"api_endpoint": {"type": "string", "title": "changed"}}),
("mcpServers", {"scribe": {"type": "http", "url": "elsewhere"}}),
("mcpServers", {"grimoire": {"type": "http", "url": "elsewhere"}}),
("description", "a different description"),
("name", "renamed"),
])
@@ -225,11 +225,11 @@ def test_shipped_content_changed_reports_a_hook_edit_as_changed(monkeypatch):
monkeypatch.setattr(
check_plugin, "_git",
lambda *a: (0, "plugin/hooks/scribe_session_context.sh"),
lambda *a: (0, "plugin/hooks/grimoire_session_context.sh"),
)
changed, paths = check_plugin.shipped_content_changed("origin/main")
assert changed is True
assert paths == ["plugin/hooks/scribe_session_context.sh"]
assert paths == ["plugin/hooks/grimoire_session_context.sh"]
def test_a_failed_diff_is_None_and_never_False(monkeypatch):
+9 -9
View File
@@ -47,7 +47,7 @@ def _reset_failures():
def fake_manifest(version: str = "2026.09.01.2252") -> str:
return json.dumps(
{"name": "scribe", "description": "d", "version": version,
{"name": "grimoire", "description": "d", "version": version,
"userConfig": {"api_endpoint": {"type": "string"}}},
indent=2,
)
@@ -125,9 +125,9 @@ def test_the_rewrite_touches_exactly_one_line():
def test_the_rewrite_preserves_indentation_and_key_order():
weird = '{\n\t"name": "scribe",\n\t"version": "0.1.48",\n\t"z": 1\n}\n'
weird = '{\n\t"name": "grimoire",\n\t"version": "0.1.48",\n\t"z": 1\n}\n'
out = rewrite(weird, "2026.09.01.2252")
assert out == '{\n\t"name": "scribe",\n\t"version": "2026.09.01.2252",\n\t"z": 1\n}\n'
assert out == '{\n\t"name": "grimoire",\n\t"version": "2026.09.01.2252",\n\t"z": 1\n}\n'
def test_the_rewrite_refuses_a_manifest_it_cannot_match():
@@ -135,7 +135,7 @@ def test_the_rewrite_refuses_a_manifest_it_cannot_match():
cannot match is one whose shape changed, and quietly reformatting the file
to cope would be a far larger edit than the caller asked for."""
with pytest.raises(ValueError):
rewrite('{"name": "scribe"}', "2026.09.01.2252")
rewrite('{"name": "grimoire"}', "2026.09.01.2252")
def test_the_rewrite_refuses_TWO_version_lines():
@@ -192,17 +192,17 @@ def test_content_changed_and_the_version_did_not_FAILS(monkeypatch):
"""The headline, and the only reason the check exists."""
failures = run_check(
"2026.09.01.2252", "2026.09.01.2252",
["plugin/hooks/scribe_session_context.sh"], monkeypatch,
["plugin/hooks/grimoire_session_context.sh"], monkeypatch,
)
assert len(failures) == 1
assert "still 2026.09.01.2252" in failures[0]
assert "scribe_session_context.sh" in failures[0]
assert "grimoire_session_context.sh" in failures[0]
def test_content_changed_and_the_version_moved_PASSES(monkeypatch):
assert run_check(
"2026.09.01.2252", "2026.08.30.1200",
["plugin/hooks/scribe_session_context.sh"], monkeypatch,
["plugin/hooks/grimoire_session_context.sh"], monkeypatch,
) == []
@@ -283,5 +283,5 @@ def test_the_session_context_hook_still_reads_the_version_field():
"""The hook reads `.version` out of the manifest rather than deriving the
string some other way — if this has to move, the derivation went
somewhere it should not have."""
hook = (check_plugin.HOOKS_DIR / "scribe_session_context.sh").read_text()
assert re.search(r"scribe_json_pick[^\n]*'\.version'", hook)
hook = (check_plugin.HOOKS_DIR / "grimoire_session_context.sh").read_text()
assert re.search(r"grimoire_json_pick[^\n]*'\.version'", hook)
+13 -13
View File
@@ -22,7 +22,7 @@ while proving nothing) or the wording of the reason (prose, and it will be
rewritten). The cases below express scores relative to the constant.
THE ONE BOUNDARY THAT IS A STANDING DECISION, NOT A DESIGN CHOICE. Only the
ACTION arm can hold. `scribe_prior_art.sh` carries a tested property that it
ACTION arm can hold. `grimoire_prior_art.sh` carries a tested property that it
never returns a permissionDecision — a recall aid may not stand in the way of
a write — and a checkpoint does not get to quietly overturn that. The write-path arm computes the same block and returns
it, so the decision can be revisited with evidence; the hook ignores it. The
@@ -46,9 +46,9 @@ from tests.helpers import fake_rule
ROOT = Path(__file__).resolve().parents[1]
HOOKS = ROOT / "plugin" / "hooks"
DEFS = HOOKS / "scribe_defs.sh"
ACTION_HOOK = HOOKS / "scribe_tool_rules.sh"
WRITE_HOOK = HOOKS / "scribe_prior_art.sh"
DEFS = HOOKS / "grimoire_defs.sh"
ACTION_HOOK = HOOKS / "grimoire_tool_rules.sh"
WRITE_HOOK = HOOKS / "grimoire_prior_art.sh"
WHERE = "this Bash call"
TRIGGER = "about to assert in a commit message what CI said"
@@ -187,7 +187,7 @@ def ledger(tmp_path):
def allowed(ledger, rule_id) -> bool:
r = sh(f'scribe_checkpoint_allowed "{ledger}" "{rule_id}" && echo YES || echo NO')
r = sh(f'grimoire_checkpoint_allowed "{ledger}" "{rule_id}" && echo YES || echo NO')
assert r.returncode == 0, r.stderr
return r.stdout.strip().endswith("YES")
@@ -233,7 +233,7 @@ def test_no_ledger_file_refuses_rather_than_holding(tmp_path):
def test_the_deny_envelope_is_valid_json_carrying_the_reason():
r = sh('scribe_json_deny PreToolUse "read \\"rule 9\\" first — then re-submit"')
r = sh('grimoire_json_deny PreToolUse "read \\"rule 9\\" first — then re-submit"')
assert r.returncode == 0, r.stderr
out = json.loads(r.stdout)["hookSpecificOutput"]
assert out["hookEventName"] == "PreToolUse"
@@ -250,7 +250,7 @@ def test_only_the_action_hook_can_hold_an_act():
"""THE GUARD ON THE RECORDED DECISION, and the reason it is here rather
than left to memory.
`scribe_prior_art.sh` runs before every Write and Edit and has never been
`grimoire_prior_art.sh` runs before every Write and Edit and has never been
able to stop one. That is a standing decision — a recall aid may not stand
in the way of the work — and `test_hook_never_returns_a_permission_
decision` in test_write_path_trigger.py holds the other half of it.
@@ -261,12 +261,12 @@ def test_only_the_action_hook_can_hold_an_act():
A feature with a good reason to spread is the kind that spreads without
anyone deciding to, and the decision here is the operator's to revisit.
"""
assert "scribe_json_deny" in ACTION_HOOK.read_text()
assert "grimoire_json_deny" in ACTION_HOOK.read_text()
write_code = [
ln for ln in WRITE_HOOK.read_text().splitlines()
if not ln.lstrip().startswith("#")
]
assert not any("scribe_json_deny" in ln for ln in write_code)
assert not any("grimoire_json_deny" in ln for ln in write_code)
assert not any("permissionDecision" in ln for ln in write_code)
@@ -277,11 +277,11 @@ def test_the_action_hook_caps_every_hold_it_emits():
both guards exist to prevent."""
text = ACTION_HOOK.read_text()
code = [ln for ln in text.splitlines() if not ln.lstrip().startswith("#")]
denies = [i for i, ln in enumerate(code) if "scribe_json_deny" in ln]
denies = [i for i, ln in enumerate(code) if "grimoire_json_deny" in ln]
assert denies, "the action arm no longer emits a hold at all"
for i in denies:
window = "\n".join(code[max(0, i - 6):i])
assert "scribe_checkpoint_allowed" in window, (
assert "grimoire_checkpoint_allowed" in window, (
"a hold is emitted without passing the per-rule and per-session "
"guards first"
)
@@ -296,8 +296,8 @@ def test_the_action_hook_records_what_was_surfaced_whichever_way_it_renders():
ln for ln in ACTION_HOOK.read_text().splitlines()
if not ln.lstrip().startswith("#")
]
append = next(i for i, ln in enumerate(code) if "scribe_rules_append" in ln)
deny = next(i for i, ln in enumerate(code) if "scribe_json_deny" in ln)
append = next(i for i, ln in enumerate(code) if "grimoire_rules_append" in ln)
deny = next(i for i, ln in enumerate(code) if "grimoire_json_deny" in ln)
assert append < deny
+2 -2
View File
@@ -38,7 +38,7 @@ from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
HOOK = ROOT / "plugin" / "hooks" / "scribe_precompact_preserve.sh"
HOOK = ROOT / "plugin" / "hooks" / "grimoire_precompact_preserve.sh"
HOOKS_JSON = ROOT / "plugin" / "hooks" / "hooks.json"
EVENT = {"session_id": "s1", "transcript_path": "/tmp/t.jsonl", "cwd": "/repo",
@@ -50,7 +50,7 @@ def _run(event: dict, tmpdir: str | None = None) -> subprocess.CompletedProcess:
"""Run the hook with its ledger directory ISOLATED.
The hook reads this session's rule ledgers, and they live under
`$TMPDIR/scribe-priorart`. Without an override these tests would read the
`$TMPDIR/grimoire-priorart`. Without an override these tests would read the
machine's real /tmp: on a developer box mid-session that is not empty, and
the output would depend on what some other session happened to leave
behind. None of the assertions below would fail on it today, which is
+6 -6
View File
@@ -3,7 +3,7 @@
Claude Code submits more than typed words through `UserPromptSubmit`. A task
notification, the echo of a slash command and the caveat banner a local command
prints all arrive as user turns, and all reach `.prompt` looking exactly like
something the operator wrote. `scribe_autoinject.sh` believed them.
something the operator wrote. `grimoire_autoinject.sh` believed them.
WHY THIS IS TESTED AT ALL, given that retrieving against a notification almost
never returns anything. The cost that matters is not the wasted embedding, it
@@ -39,8 +39,8 @@ from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
DEFS = ROOT / "plugin" / "hooks" / "scribe_defs.sh"
HOOK = ROOT / "plugin" / "hooks" / "scribe_autoinject.sh"
DEFS = ROOT / "plugin" / "hooks" / "grimoire_defs.sh"
HOOK = ROOT / "plugin" / "hooks" / "grimoire_autoinject.sh"
# Written by the client. None of these is a question anyone asked.
SYNTHETIC = [
@@ -48,7 +48,7 @@ SYNTHETIC = [
"<command-name>/compact</command-name>",
"<command-message>compact</command-message>",
"<command-args>focus on the hooks</command-args>",
"<local-command-stdout>Reconnected to plugin:scribe</local-command-stdout>",
"<local-command-stdout>Reconnected to plugin:grimoire</local-command-stdout>",
"<local-command-stderr>Failed to reconnect</local-command-stderr>",
"<local-command-caveat>Caveat: The messages below were generated…</local-command-caveat>",
" \n<task-notification>\n<task-id>abc</task-id>", # whitespace must not walk the filter
@@ -117,6 +117,6 @@ def test_the_hook_consults_the_filter_before_spending_a_request() -> None:
logged, so it has to sit ahead of the request that would log one.
"""
body = HOOK.read_text()
assert "scribe_skip_prompt" in body, "the hook does not consult the filter"
assert body.index("scribe_skip_prompt") < body.index("curl -fsS"), \
assert "grimoire_skip_prompt" in body, "the hook does not consult the filter"
assert body.index("grimoire_skip_prompt") < body.index("curl -fsS"), \
"the filter runs after the request it exists to prevent"
+2 -2
View File
@@ -21,7 +21,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
SKILL = (
pathlib.Path(__file__).resolve().parents[1]
/ "plugin/skills/using-scribe/SKILL.md"
/ "plugin/skills/using-grimoire/SKILL.md"
)
@@ -31,7 +31,7 @@ def test_the_skill_carries_the_convention():
can govern the prose around it."""
text = " ".join(SKILL.read_text().split())
assert "Name the record, never just its number" in text, (
"the using-scribe skill no longer tells an agent to write the title "
"the using-grimoire skill no longer tells an agent to write the title "
"alongside the id. Nothing else governs how records are cited in "
"prose, commit messages, or task bodies."
)
+5 -5
View File
@@ -30,7 +30,7 @@ from grimoire.services import rule_usage, rulebooks
from tests.helpers import fake_rule, http_sink, need_tools
ROOT = Path(__file__).resolve().parents[1]
HOOK = ROOT / "plugin" / "hooks" / "scribe_reply_check.sh"
HOOK = ROOT / "plugin" / "hooks" / "grimoire_reply_check.sh"
DONE = fake_rule(id=11, title="Definition of done", kind="rule",
when_to_apply="about to tell the operator work is finished")
@@ -269,8 +269,8 @@ def _transcript(tmp_path, reply):
def _run(tmp_path, port, transcript, active=False):
need_tools("bash", "curl", "awk")
env = {"PATH": os.environ["PATH"], "SCRIBE_URL": f"http://127.0.0.1:{port}",
"SCRIBE_TOKEN": "t", "TMPDIR": str(tmp_path), "HOME": str(tmp_path)}
env = {"PATH": os.environ["PATH"], "GRIMOIRE_URL": f"http://127.0.0.1:{port}",
"GRIMOIRE_TOKEN": "t", "TMPDIR": str(tmp_path), "HOME": str(tmp_path)}
out = subprocess.run(
["bash", str(HOOK)],
input=json.dumps({"session_id": "s-reply", "transcript_path": str(transcript),
@@ -304,7 +304,7 @@ def _closing_transcript(tmp_path, *replies):
lines = [
{"type": "user", "message": {"role": "user", "content": "please finish it"}},
{"type": "assistant", "message": {"content": [
{"type": "tool_use", "id": "toolu_1", "name": "mcp__plugin_scribe_scribe__update_task",
{"type": "tool_use", "id": "toolu_1", "name": "mcp__plugin_grimoire_grimoire__update_task",
"input": {"task_id": 41, "status": "done"}}]}},
{"type": "user", "message": {"content": [
{"type": "tool_result", "tool_use_id": "toolu_1", "is_error": False, "content": "{}"}]}},
@@ -370,7 +370,7 @@ def test_an_unreachable_instance_never_holds_a_reply(tmp_path):
assert _run(tmp_path, 9, t) == ""
@pytest.mark.parametrize("name", ["scribe_reply_check.sh"])
@pytest.mark.parametrize("name", ["grimoire_reply_check.sh"])
def test_the_hook_is_registered_on_stop(name):
manifest = json.loads((ROOT / "plugin" / "hooks" / "hooks.json").read_text())
commands = [h["command"] for m in manifest["hooks"]["Stop"] for h in m["hooks"]]
+5 -5
View File
@@ -23,8 +23,8 @@ from grimoire.services import reply_shapes
from tests.helpers import http_sink, need_tools
ROOT = Path(__file__).resolve().parents[1]
HOOK = ROOT / "plugin" / "hooks" / "scribe_autoinject.sh"
SESSION_START = ROOT / "plugin" / "hooks" / "scribe_session_context.sh"
HOOK = ROOT / "plugin" / "hooks" / "grimoire_autoinject.sh"
SESSION_START = ROOT / "plugin" / "hooks" / "grimoire_session_context.sh"
# ── the route ────────────────────────────────────────────────────────────
@@ -72,8 +72,8 @@ async def test_the_route_hands_the_ledger_and_the_rules_just_named_to_the_turn()
def _env(tmp_path, port):
return {"PATH": os.environ["PATH"], "SCRIBE_URL": f"http://127.0.0.1:{port}",
"SCRIBE_TOKEN": "t", "TMPDIR": str(tmp_path), "HOME": str(tmp_path)}
return {"PATH": os.environ["PATH"], "GRIMOIRE_URL": f"http://127.0.0.1:{port}",
"GRIMOIRE_TOKEN": "t", "TMPDIR": str(tmp_path), "HOME": str(tmp_path)}
def _prompt(tmp_path, port, session="s-shape"):
@@ -124,6 +124,6 @@ def test_the_hook_and_the_route_agree_on_the_ledgers_names():
hook = HOOK.read_text()
assert "shapes_seen" in hook and "'.shape_keys'" in hook
assert "scribe_shapes_file" in hook and "scribe_shapes_append" in hook
assert "grimoire_shapes_file" in hook and "grimoire_shapes_append" in hook
route = inspect.getsource(routes.autoinject_retrieve)
assert 'request.args.get("shapes_seen")' in route and '"shape_keys"' in route
+4 -4
View File
@@ -44,10 +44,10 @@ TUNING_TOOLS = ("retrieval_telemetry", "update_rule", "retrieval_surfaces",
def _skill() -> str:
# The route lives in missed-retrieval.md, a reference file of using-scribe;
# The route lives in missed-retrieval.md, a reference file of using-grimoire;
# the skill is SKILL.md and its references read together.
from tests.helpers import skill_text
return skill_text("using-scribe")
return skill_text("using-grimoire")
def _instructions() -> str:
@@ -62,7 +62,7 @@ def test_the_route_names_every_tool_it_depends_on():
text = _skill()
missing = [t for t in TUNING_TOOLS if t not in text]
assert not missing, (
f"using-scribe no longer names {missing}. These tools reach a session "
f"using-grimoire no longer names {missing}. These tools reach a session "
f"through this skill and nowhere else — when they were named on no "
f"surface at all, the tuning loop logged zero events in its lifetime."
)
@@ -140,7 +140,7 @@ def test_the_route_stays_off_the_index():
It carries no pointer to this route, because the index is not a rulebook:
a missed rule is noticed mid-work, not at session start, and the spec asks server instructions for the
workflow across tools, not stance. using-scribe states the route (the tests
workflow across tools, not stance. using-grimoire states the route (the tests
above pin it); the index keeps what_might_apply, the tool that opens it.
Whitespace-flattened before matching: the block is hard-wrapped, so a
+1 -1
View File
@@ -128,7 +128,7 @@ def test_every_unresolved_call_site_is_declared() -> None:
"""A site passing a variable must be named, not silently skipped."""
_, unresolved = call_sites()
# EQUALITY, not prefix. The first spelling of this compared against a
# prefix that could never match — the paths begin `scribe/` — so the check
# prefix that could never match — the paths begin `grimoire/` — so the check
# passed by matching nothing, which is the failure mode a guard is most
# likely to have and least likely to show.
undeclared = sorted({u for u in unresolved if u not in FAN_OUT_SITES})
+10 -10
View File
@@ -17,7 +17,7 @@ touch gets one. The tests read the constant out of the shell and assert the
PROPERTY around it, so any TTL works and only a broken comparison fails.
Runs the real shell, like `test_session_context_ledger` and the after-write
hook's tests, and deliberately with no SCRIBE_URL/SCRIBE_TOKEN: ageing is
hook's tests, and deliberately with no GRIMOIRE_URL/GRIMOIRE_TOKEN: ageing is
local, keyless and networkless, and must work on an instance that is
unreachable or was never configured.
"""
@@ -33,23 +33,23 @@ from pathlib import Path
import pytest
PLUGIN = Path(__file__).resolve().parents[1] / "plugin" / "hooks"
DEFS = PLUGIN / "scribe_defs.sh"
DEFS = PLUGIN / "grimoire_defs.sh"
def _ttl() -> int:
"""The TTL as the shell defines it — never a copy of the number."""
m = re.search(r"^_SCRIBE_RULE_TTL=(\d+)", DEFS.read_text(), re.M)
assert m, "_SCRIBE_RULE_TTL is gone from scribe_defs.sh"
m = re.search(r"^_GRIMOIRE_RULE_TTL=(\d+)", DEFS.read_text(), re.M)
assert m, "_GRIMOIRE_RULE_TTL is gone from grimoire_defs.sh"
return int(m.group(1))
def _live(ledger: Path) -> list[str]:
"""`scribe_rules_live` against a real ledger, as the hooks call it."""
"""`grimoire_rules_live` against a real ledger, as the hooks call it."""
for tool in ("bash", "awk"):
if shutil.which(tool) is None:
pytest.skip(f"hook runtime tool {tool!r} not installed")
proc = subprocess.run(
["bash", "-c", f'. "{DEFS}"; scribe_rules_live "{ledger}"'],
["bash", "-c", f'. "{DEFS}"; grimoire_rules_live "{ledger}"'],
capture_output=True, text=True, timeout=30,
env={"PATH": os.environ["PATH"]}, # no credentials — see the docstring
)
@@ -119,7 +119,7 @@ def test_a_repeatedly_surfaced_rule_appears_once_in_the_exclusion_list(tmp_path)
f"exclusion list, in first-seen order"
)
raw = subprocess.run(
["bash", "-c", f'. "{DEFS}"; scribe_rules_live "{ledger}"'],
["bash", "-c", f'. "{DEFS}"; grimoire_rules_live "{ledger}"'],
capture_output=True, text=True, timeout=30,
env={"PATH": os.environ["PATH"]},
).stdout.strip()
@@ -142,7 +142,7 @@ def test_a_rule_that_ages_out_and_returns_does_not_ping_pong(tmp_path):
_write_ledger(ledger, [("156", ttl + 600)])
subprocess.run(
["bash", "-c", f'. "{DEFS}"; printf \'156\\n\' | scribe_rules_append "{ledger}"'],
["bash", "-c", f'. "{DEFS}"; printf \'156\\n\' | grimoire_rules_append "{ledger}"'],
capture_output=True, text=True, timeout=30,
env={"PATH": os.environ["PATH"]},
)
@@ -198,7 +198,7 @@ def test_both_hooks_read_the_ledger_through_the_shared_helper():
stops calling the helper but still ages correctly some other way is not a
defect, and a rename of the helper is not one either.
"""
for name in ("scribe_prior_art.sh", "scribe_tool_rules.sh"):
for name in ("grimoire_prior_art.sh", "grimoire_tool_rules.sh"):
src = (PLUGIN / name).read_text()
rule_reads = [
ln for ln in src.splitlines()
@@ -210,7 +210,7 @@ def test_both_hooks_read_the_ledger_through_the_shared_helper():
f"ageing, and the two would disagree silently about which rules "
f"this session still holds."
)
assert "scribe_rules_live" in src, (
assert "grimoire_rules_live" in src, (
f"{name} no longer reads the rule ledger through the shared "
f"helper, so the two hooks can drift on the ledger format"
)
+20 -20
View File
@@ -1,12 +1,12 @@
"""The plugin half of the rule↔moment signal, run for real.
`scribe_moment.sh` writes every call to an acts ledger; `scribe_record_opened.sh`
`grimoire_moment.sh` writes every call to an acts ledger; `grimoire_record_opened.sh`
sends the last few minutes of it with the rule a session opened; the server
answers with a proposal line when one is due. These drive both scripts through
bash with a stand-in `curl` on PATH that records what it was sent, so what is
pinned is what the scripts actually do — the window, the body's shape, the
reduction of a large event, the line coming back out — not what their source
looks like. `scribe_session_end.sh` removing the session's files is pinned the
looks like. `grimoire_session_end.sh` removing the session's files is pinned the
same way.
"""
from __future__ import annotations
@@ -49,7 +49,7 @@ def sandbox(tmp_path):
env = {
"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}",
"HOME": str(tmp_path), "TMPDIR": str(tmp_path),
"SCRIBE_URL": "http://scribe.invalid", "SCRIBE_TOKEN": "t",
"GRIMOIRE_URL": "http://grimoire.invalid", "GRIMOIRE_TOKEN": "t",
"FAKE_CURL_LOG": str(tmp_path / "curl.log"),
"FAKE_CURL_BODY": str(tmp_path / "curl.body"),
"CLAUDE_PROJECT_DIR": str(tmp_path),
@@ -67,7 +67,7 @@ def _run(script: str, event: dict, env: dict, **extra) -> subprocess.CompletedPr
def _acts(tmp: Path, sid: str = "s1") -> list[tuple[int, dict]]:
path = tmp / "scribe-moment" / f"{sid}.acts"
path = tmp / "grimoire-moment" / f"{sid}.acts"
rows = []
for line in path.read_text().splitlines():
stamp, _, act = line.partition("\t")
@@ -79,10 +79,10 @@ def test_every_call_is_written_to_the_acts_ledger_scribes_own_included(sandbox):
tmp, env = sandbox
push = {"session_id": "s1", "tool_name": "Bash",
"tool_input": {"command": "git push origin dev"}}
close = {"session_id": "s1", "tool_name": "mcp__plugin_scribe_scribe__update_task",
close = {"session_id": "s1", "tool_name": "mcp__plugin_grimoire_grimoire__update_task",
"tool_input": {"task_id": 4, "status": "done"}}
_run("scribe_moment.sh", push, env, FAKE_CURL_REPLY='{"tools":[]}')
_run("scribe_moment.sh", close, env)
_run("grimoire_moment.sh", push, env, FAKE_CURL_REPLY='{"tools":[]}')
_run("grimoire_moment.sh", close, env)
acts = _acts(tmp)
assert [a for _, a in acts] == [push, close]
assert all(abs(stamp - time.time()) < 60 for stamp, _ in acts)
@@ -90,10 +90,10 @@ def test_every_call_is_written_to_the_acts_ledger_scribes_own_included(sandbox):
def test_the_open_itself_is_not_an_act(sandbox):
tmp, env = sandbox
_run("scribe_moment.sh", {"session_id": "s1", "tool_name": "Bash",
_run("grimoire_moment.sh", {"session_id": "s1", "tool_name": "Bash",
"tool_input": {"command": "ls"}}, env,
FAKE_CURL_REPLY='{"tools":[]}')
_run("scribe_moment.sh", {"session_id": "s1", "tool_name": "mcp__scribe__get_rule",
_run("grimoire_moment.sh", {"session_id": "s1", "tool_name": "mcp__grimoire__get_rule",
"tool_input": {"rule_id": 11}}, env)
assert [a["tool_name"] for _, a in _acts(tmp)] == ["Bash"]
@@ -102,14 +102,14 @@ def test_a_large_event_is_kept_as_its_tool_name(sandbox):
tmp, env = sandbox
big = {"session_id": "s1", "tool_name": "Write",
"tool_input": {"file_path": "x", "content": "y" * 40000}}
_run("scribe_moment.sh", big, env, FAKE_CURL_REPLY='{"tools":[]}')
_run("grimoire_moment.sh", big, env, FAKE_CURL_REPLY='{"tools":[]}')
[(_, act)] = _acts(tmp)
assert act == {"tool_name": "Write", "tool_input": {}}
def test_an_open_sends_the_window_and_prints_the_answer(sandbox):
tmp, env = sandbox
ledger = tmp / "scribe-moment"
ledger = tmp / "grimoire-moment"
ledger.mkdir()
now = int(time.time())
old = {"tool_name": "Bash", "tool_input": {"command": "make old"}}
@@ -118,8 +118,8 @@ def test_an_open_sends_the_window_and_prints_the_answer(sandbox):
f"{now - 1000}\t{json.dumps(old)}\n{now - 5}\t{json.dumps(recent)}\n"
)
line = "> Rule #11 has been opened just after `work.deliver` in 3 distinct sessions"
out = _run("scribe_record_opened.sh",
{"session_id": "s1", "tool_name": "mcp__scribe__get_rule",
out = _run("grimoire_record_opened.sh",
{"session_id": "s1", "tool_name": "mcp__grimoire__get_rule",
"tool_input": {"rule_id": 11}}, env,
FAKE_CURL_REPLY=json.dumps({"moments": ["work.deliver"], "context": line}))
@@ -130,18 +130,18 @@ def test_an_open_sends_the_window_and_prints_the_answer(sandbox):
assert printed["hookEventName"] == "PostToolUse"
assert printed["additionalContext"] == line
# The open is still recorded in its own ledger, as before.
assert (tmp / "scribe-priorart" / "s1.opened.ids").read_text().startswith("11\t")
assert (tmp / "grimoire-priorart" / "s1.opened.ids").read_text().startswith("11\t")
def test_an_open_with_nothing_in_the_window_sends_nothing(sandbox):
tmp, env = sandbox
ledger = tmp / "scribe-moment"
ledger = tmp / "grimoire-moment"
ledger.mkdir()
(ledger / "s1.acts").write_text(
f"{int(time.time()) - 1000}\t{json.dumps({'tool_name': 'Bash', 'tool_input': {}})}\n"
)
out = _run("scribe_record_opened.sh",
{"session_id": "s1", "tool_name": "mcp__scribe__get_rule",
out = _run("grimoire_record_opened.sh",
{"session_id": "s1", "tool_name": "mcp__grimoire__get_rule",
"tool_input": {"rule_id": 11}}, env)
assert out.stdout == ""
assert not (tmp / "curl.log").exists()
@@ -149,17 +149,17 @@ def test_an_open_with_nothing_in_the_window_sends_nothing(sandbox):
def test_session_end_removes_the_sessions_moment_files_and_no_others(sandbox):
tmp, env = sandbox
ledger = tmp / "scribe-moment"
ledger = tmp / "grimoire-moment"
ledger.mkdir()
for name in ("s1.acts", "s1.tools", "s2.acts"):
(ledger / name).write_text("x")
_run("scribe_session_end.sh", {"session_id": "s1", "reason": "clear"}, env)
_run("grimoire_session_end.sh", {"session_id": "s1", "reason": "clear"}, env)
assert sorted(p.name for p in ledger.iterdir()) == ["s2.acts"]
def test_the_hook_and_the_route_agree_on_the_body():
"""A contract across the shell/Python seam: a renamed field fails silently."""
src = (HOOKS / "scribe_record_opened.sh").read_text()
src = (HOOKS / "grimoire_record_opened.sh").read_text()
route = inspect.getsource(plugin_routes.rule_opened)
assert "/api/plugin/rule-opened" in src
for field in ("rule_id", "session_id", "acts"):
+11 -11
View File
@@ -36,7 +36,7 @@ from grimoire.services.plugin_context import _rule_hint_line
ROOT = Path(__file__).resolve().parents[1]
HOOKS = ROOT / "plugin" / "hooks"
RECORDER = HOOKS / "scribe_record_opened.sh"
RECORDER = HOOKS / "grimoire_record_opened.sh"
def _rule(rid=156, kind="rule"):
@@ -98,33 +98,33 @@ def _run_recorder(event: dict, tmp: Path) -> Path:
out = subprocess.run(["bash", str(RECORDER)], input=json.dumps(event),
capture_output=True, text=True, env=env, timeout=30)
assert out.returncode == 0, out.stderr
return tmp / "scribe-priorart" / "s1.opened.ids"
return tmp / "grimoire-priorart" / "s1.opened.ids"
def test_opening_a_rule_is_recorded(tmp_path):
led = _run_recorder(
{"session_id": "s1", "tool_name": "mcp__scribe__get_rule",
{"session_id": "s1", "tool_name": "mcp__grimoire__get_rule",
"tool_input": {"rule_id": 156}}, tmp_path)
assert led.exists()
assert led.read_text().split("\t")[0] == "156"
def test_the_entry_is_stamped_so_it_ages_like_the_naming_ledger(tmp_path):
"""Both ledgers are read by `scribe_rules_live`, which ages on that stamp.
"""Both ledgers are read by `grimoire_rules_live`, which ages on that stamp.
An unstamped entry never expires — bounded, but it would mean an opened
rule stays 'opened' for a session's whole life."""
led = _run_recorder(
{"session_id": "s1", "tool_name": "mcp__scribe__get_rule",
{"session_id": "s1", "tool_name": "mcp__grimoire__get_rule",
"tool_input": {"rule_id": 9}}, tmp_path)
parts = led.read_text().strip().split("\t")
assert len(parts) == 2 and parts[1].isdigit()
@pytest.mark.parametrize("event", [
{"session_id": "s1", "tool_name": "mcp__scribe__get_rule", "tool_input": {}},
{"session_id": "s1", "tool_name": "mcp__scribe__get_rule",
{"session_id": "s1", "tool_name": "mcp__grimoire__get_rule", "tool_input": {}},
{"session_id": "s1", "tool_name": "mcp__grimoire__get_rule",
"tool_input": {"rule_id": "../../etc"}},
{"tool_name": "mcp__scribe__get_rule", "tool_input": {"rule_id": 5}},
{"tool_name": "mcp__grimoire__get_rule", "tool_input": {"rule_id": 5}},
])
def test_an_event_with_nothing_usable_records_nothing_and_still_exits_zero(event, tmp_path):
"""A hook that fails a tool call over bookkeeping is worse than one that
@@ -150,11 +150,11 @@ def test_the_recorder_is_registered_on_the_get_rule_tool():
hooks = json.loads((HOOKS / "hooks.json").read_text())["hooks"]
posts = hooks["PostToolUse"]
mine = [b for b in posts
if any("scribe_record_opened.sh" in h["command"] for h in b["hooks"])]
if any("grimoire_record_opened.sh" in h["command"] for h in b["hooks"])]
assert len(mine) == 1, "the opened-recorder is not registered exactly once"
# An MCP tool's server segment varies with how the plugin was installed, so
# the matcher must not pin one spelling of it.
matcher = mine[0]["matcher"]
assert re.fullmatch(matcher, "mcp__plugin_scribe_scribe__get_rule"), matcher
assert re.fullmatch(matcher, "mcp__scribe__get_rule"), matcher
assert re.fullmatch(matcher, "mcp__plugin_grimoire_grimoire__get_rule"), matcher
assert re.fullmatch(matcher, "mcp__grimoire__get_rule"), matcher
assert not re.fullmatch(matcher, "Bash"), matcher
+13 -13
View File
@@ -690,11 +690,11 @@ def test_the_two_pre_tool_arms_share_one_session_rule_ledger():
not repeat itself. Asserted on the FILENAME both scripts build, because
that is the shared thing; a copy of the path in each is how they drift.
"""
prior = Path("plugin/hooks/scribe_prior_art.sh").read_text()
tool = Path("plugin/hooks/scribe_tool_rules.sh").read_text()
prior = Path("plugin/hooks/grimoire_prior_art.sh").read_text()
tool = Path("plugin/hooks/grimoire_tool_rules.sh").read_text()
for src, name in ((prior, "scribe_prior_art.sh"), (tool, "scribe_tool_rules.sh")):
assert '"${TMPDIR:-/tmp}/scribe-priorart"' in src, f"{name}: state dir moved"
for src, name in ((prior, "grimoire_prior_art.sh"), (tool, "grimoire_tool_rules.sh")):
assert '"${TMPDIR:-/tmp}/grimoire-priorart"' in src, f"{name}: state dir moved"
assert '.rules.ids' in src, f"{name}: rules ledger filename moved"
assert "exclude_rule_ids" in src, f"{name}: does not send the exclusion"
@@ -710,9 +710,9 @@ def test_the_tool_arm_is_registered_on_bash():
m.get("matcher"): [h["command"] for h in m["hooks"]] for m in pre
}
assert "Bash" in entries, "nothing watches Bash — the reflex surface is unguarded"
assert any("scribe_tool_rules.sh" in c for c in entries["Bash"])
assert any("grimoire_tool_rules.sh" in c for c in entries["Bash"])
# The write arm keeps its own matcher; this is an addition, not a move.
assert any("scribe_prior_art.sh" in c for c in entries["Write|Edit"])
assert any("grimoire_prior_art.sh" in c for c in entries["Write|Edit"])
def test_the_hook_and_the_route_agree_on_every_parameter_name():
@@ -725,8 +725,8 @@ def test_the_hook_and_the_route_agree_on_every_parameter_name():
"""
import re
hook = Path("plugin/hooks/scribe_tool_rules.sh").read_text()
defs = Path("plugin/hooks/scribe_defs.sh").read_text()
hook = Path("plugin/hooks/grimoire_tool_rules.sh").read_text()
defs = Path("plugin/hooks/grimoire_defs.sh").read_text()
route = Path("src/grimoire/routes/plugin.py").read_text()
handler = route.split("async def pre_tool_rules")[1].split("\n@plugin_bp")[0]
@@ -736,14 +736,14 @@ def test_the_hook_and_the_route_agree_on_every_parameter_name():
assert sent == {"tool", "command", "exclude_rule_ids", "root", "cwd"}, sent
# The project-scope key is the shared helper's to choose: the hook
# splices in `scribe_scope_query`'s output, which is `repo=` inside a git
# repo and `project_id=` where a `.scribe` marker names the project. Both
# splices in `grimoire_scope_query`'s output, which is `repo=` inside a git
# repo and `project_id=` where a `.grimoire` marker names the project. Both
# halves are one contract with the route, so both are pinned.
assert "scribe_scope_query" in hook, "hook no longer asks for a project scope"
assert "grimoire_scope_query" in hook, "hook no longer asks for a project scope"
# `held_rule_ids` joins them for the same reason: the OPENED ledger
# is read by a shared helper so its ageing and format cannot drift from the
# naming ledger's, which means the key is spelled in defs rather than here.
assert "scribe_held_query" in hook, "hook no longer sends the opened ledger"
assert "grimoire_held_query" in hook, "hook no longer sends the opened ledger"
assert set(re.findall(r"printf '([a-z_]+)=", defs)) == {"repo", "project_id"}
# Spelled with its own leading `&` because it is APPENDED to a query that
# already has a scope key, where the two above are alternatives that open
@@ -754,7 +754,7 @@ def test_the_hook_and_the_route_agree_on_every_parameter_name():
)
# The rulings ledger, shared with the write-path hook —
# spelled once in its helper for the same reason.
assert "scribe_rulings_query" in hook, "hook no longer sends the rulings ledger"
assert "grimoire_rulings_query" in hook, "hook no longer sends the rulings ledger"
assert "printf '&seen_ruling_systems=" in defs, (
"the rulings ledger's query key is no longer spelled in the helper"
)
+2 -2
View File
@@ -40,7 +40,7 @@ def _patched(settings, deliver=None, config=BackupConfig(enabled=True)):
return {"version": 25, "users": []}
async def delivered(payload, config, secret, at, **kw):
return Delivered("scribe-20261015T020000Z.tar", 1234, "/backups")
return Delivered("grimoire-20261015T020000Z.tar", 1234, "/backups")
return [
patch.object(backup_job, "get_admin_setting", settings.get),
@@ -67,7 +67,7 @@ def test_a_good_run_is_kept_with_its_file_and_size():
run = _run(backup_job.run_backup("manual"), _patched(settings))
assert run["status"] == "ok"
assert (run["file"], run["size_bytes"], run["trigger"]) == (
"scribe-20261015T020000Z.tar", 1234, "manual",
"grimoire-20261015T020000Z.tar", 1234, "manual",
)
runs = _run(backup_job.list_runs(), _patched(settings))
assert runs == [run]
+2 -2
View File
@@ -136,10 +136,10 @@ async def test_check_reports_version_and_identity():
def handler(request):
if request.url.path.endswith("/version"):
return _json(200, {"version": "1.23.1"})
return _json(200, {"login": "scribe-bot"})
return _json(200, {"login": "grimoire-bot"})
result = await _forge(handler).check()
assert result == {"ok": True, "version": "1.23.1", "username": "scribe-bot"}
assert result == {"ok": True, "version": "1.23.1", "username": "grimoire-bot"}
# --- the configuration gate (build_adapter) ----------------------------------
+2 -2
View File
@@ -127,7 +127,7 @@ async def test_build_session_context_includes_project_when_scoped():
# none is the ordinary case, not a degraded one.
assert "## Design system" not in out["context"]
# Live state only: how to work with Grimoire is the
# using-scribe skill's to say. A restated reflex here is a copy that drifts.
# using-grimoire skill's to say. A restated reflex here is a copy that drifts.
assert 'content_type="rule"' not in out["context"]
assert "Reflex:" not in out["context"]
@@ -229,7 +229,7 @@ async def test_build_session_context_unbound_repo_emits_bind_hint():
@pytest.mark.asyncio
async def test_a_project_id_that_does_not_resolve_is_reported_not_dropped():
"""A `.scribe` marker names a project directly, so a caller arrives
"""A `.grimoire` marker names a project directly, so a caller arrives
holding a pointer it BELIEVES in. If the id is for
another instance, or names a deleted project, the session has to be told —
it cannot infer it from an absence.
+17 -17
View File
@@ -25,11 +25,11 @@ def test_compose_tags_lowercases_language_and_dedups():
def test_compose_body_includes_fields_and_fence():
body = s.compose_body(
code="return 1", language="python", signature="f() -> int",
when_to_use="always", repo="scribe", path="a.py", symbol="f",
when_to_use="always", repo="grimoire", path="a.py", symbol="f",
)
assert "**When to use:** always" in body
assert "**Signature:** `f() -> int`" in body
assert "`scribe` · `a.py` · `f`" in body
assert "`grimoire` · `a.py` · `f`" in body
assert "```python\nreturn 1\n```" in body
assert body.rstrip().endswith("```")
@@ -43,7 +43,7 @@ def test_parse_round_trips_a_composed_snippet():
title = "useDebouncedRef"
body = s.compose_body(
code="const x = 1", language="ts", signature="useDebouncedRef(v, ms)",
when_to_use="debounce a reactive ref", repo="scribe",
when_to_use="debounce a reactive ref", repo="grimoire",
path="frontend/src/composables/x.ts", symbol="useDebouncedRef",
)
got = s.parse_snippet_fields(title, body, ["ts", "snippet"])
@@ -52,7 +52,7 @@ def test_parse_round_trips_a_composed_snippet():
assert got["signature"] == "useDebouncedRef(v, ms)"
assert got["language"] == "ts"
assert got["code"] == "const x = 1"
assert got["repo"] == "scribe"
assert got["repo"] == "grimoire"
assert got["path"] == "frontend/src/composables/x.ts"
assert got["symbol"] == "useDebouncedRef"
@@ -94,33 +94,33 @@ def test_compose_body_multi_location_renders_bullet_list():
body = s.compose_body(
code="x = 1",
locations=[
{"repo": "scribe", "path": "a.py", "symbol": "f"},
{"repo": "grimoire", "path": "a.py", "symbol": "f"},
{"repo": "web", "path": "b.ts", "symbol": "g"},
],
)
assert "**Locations:**" in body
assert "- `scribe` · `a.py` · `f`" in body
assert "- `grimoire` · `a.py` · `f`" in body
assert "- `web` · `b.ts` · `g`" in body
assert "**Location:**" not in body.replace("**Locations:**", "")
def test_compose_body_single_location_via_list_uses_singular_label():
body = s.compose_body(
code="x = 1", locations=[{"repo": "scribe", "path": "a.py", "symbol": "f"}],
code="x = 1", locations=[{"repo": "grimoire", "path": "a.py", "symbol": "f"}],
)
assert "**Location:** `scribe` · `a.py` · `f`" in body
assert "**Location:** `grimoire` · `a.py` · `f`" in body
assert "**Locations:**" not in body
def test_normalize_locations_drops_empty_and_dedups():
got = s._normalize_locations([
{"repo": "scribe", "path": "a.py", "symbol": "f"},
{"repo": "grimoire", "path": "a.py", "symbol": "f"},
{"repo": "", "path": "", "symbol": ""}, # dropped (empty)
{"repo": "scribe", "path": "a.py", "symbol": "f"}, # dropped (dup)
{"repo": "grimoire", "path": "a.py", "symbol": "f"}, # dropped (dup)
{"repo": "web", "path": "", "symbol": ""},
])
assert got == [
{"repo": "scribe", "path": "a.py", "symbol": "f"},
{"repo": "grimoire", "path": "a.py", "symbol": "f"},
{"repo": "web", "path": "", "symbol": ""},
]
@@ -129,25 +129,25 @@ def test_parse_round_trips_multi_location():
body = s.compose_body(
code="const x = 1", language="ts",
locations=[
{"repo": "scribe", "path": "a.ts", "symbol": "f"},
{"repo": "grimoire", "path": "a.ts", "symbol": "f"},
{"repo": "web", "path": "b.ts", "symbol": "g"},
],
)
got = s.parse_snippet_fields("n — u", body, ["ts", "snippet"])
assert got["locations"] == [
{"repo": "scribe", "path": "a.ts", "symbol": "f"},
{"repo": "grimoire", "path": "a.ts", "symbol": "f"},
{"repo": "web", "path": "b.ts", "symbol": "g"},
]
# repo/path/symbol mirror the first location for back-compat.
assert (got["repo"], got["path"], got["symbol"]) == ("scribe", "a.ts", "f")
assert (got["repo"], got["path"], got["symbol"]) == ("grimoire", "a.ts", "f")
def test_parse_legacy_single_location_line_still_works():
# A body written by the pre-multi-location serializer.
body = "**Location:** `scribe` · `a.py` · `f`\n\n```py\nx = 1\n```\n"
body = "**Location:** `grimoire` · `a.py` · `f`\n\n```py\nx = 1\n```\n"
got = s.parse_snippet_fields("n — u", body, None)
assert got["locations"] == [{"repo": "scribe", "path": "a.py", "symbol": "f"}]
assert (got["repo"], got["path"], got["symbol"]) == ("scribe", "a.py", "f")
assert got["locations"] == [{"repo": "grimoire", "path": "a.py", "symbol": "f"}]
assert (got["repo"], got["path"], got["symbol"]) == ("grimoire", "a.py", "f")
def test_merge_snippet_fields_unions_locations_and_tags():
+3 -3
View File
@@ -16,16 +16,16 @@ from pathlib import Path
from tests.helpers import need_tools
HOOK = Path(__file__).resolve().parents[1] / "plugin" / "hooks" / "scribe_session_context.sh"
HOOK = Path(__file__).resolve().parents[1] / "plugin" / "hooks" / "grimoire_session_context.sh"
def run_hook(tmp_path: Path, url: str, marker_project: int | None = None) -> str:
need_tools("bash", "curl", "awk")
if marker_project is not None:
(tmp_path / ".scribe").write_text(json.dumps({
(tmp_path / ".grimoire").write_text(json.dumps({
"instance": url, "project_id": marker_project, "project": "P",
}))
env = {**os.environ, "SCRIBE_URL": url, "SCRIBE_TOKEN": "t",
env = {**os.environ, "GRIMOIRE_URL": url, "GRIMOIRE_TOKEN": "t",
"CLAUDE_PROJECT_DIR": str(tmp_path)}
r = subprocess.run(["bash", str(HOOK)], input=b'{"source":"startup"}',
capture_output=True, env=env, timeout=60)
+4 -4
View File
@@ -26,7 +26,7 @@ statement, so a blanket delete (all False) and a no-op (all True) both fail,
and neither can be made to pass by editing one case.
Runs the real shell against a temp TMPDIR, like the after-write hook's tests.
Deliberately with no SCRIBE_URL/SCRIBE_TOKEN in the environment: clearing the
Deliberately with no GRIMOIRE_URL/GRIMOIRE_TOKEN in the environment: clearing the
ledger is local, keyless and networkless, and must still happen on an instance
that is unreachable or unconfigured.
"""
@@ -41,7 +41,7 @@ from pathlib import Path
import pytest
PLUGIN = Path(__file__).resolve().parents[1] / "plugin"
HOOK = PLUGIN / "hooks" / "scribe_session_context.sh"
HOOK = PLUGIN / "hooks" / "grimoire_session_context.sh"
def _env(tmp_path):
@@ -54,13 +54,13 @@ def _env(tmp_path):
for tool in ("bash",):
if shutil.which(tool) is None:
pytest.skip(f"hook runtime tool {tool!r} not installed")
# No SCRIBE_URL / SCRIBE_TOKEN on purpose — see the module docstring.
# No GRIMOIRE_URL / GRIMOIRE_TOKEN on purpose — see the module docstring.
return {"PATH": os.environ["PATH"], "TMPDIR": str(tmp_path),
"HOME": str(tmp_path)}
def _ledger(tmp_path, sid: str, name: str = "rules.ids") -> Path:
d = tmp_path / "scribe-priorart"
d = tmp_path / "grimoire-priorart"
d.mkdir(exist_ok=True)
f = d / f"{sid}.{name}"
f.write_text("156\n168\n")
+17 -17
View File
@@ -46,16 +46,16 @@ from pathlib import Path
import pytest
HOOKS = Path(__file__).resolve().parents[1] / "plugin" / "hooks"
SESSION_START = HOOKS / "scribe_session_context.sh"
DEFS = HOOKS / "scribe_defs.sh"
SESSION_START = HOOKS / "grimoire_session_context.sh"
DEFS = HOOKS / "grimoire_defs.sh"
# Every ledger the hooks write today, and where. Named here so a failure READS
# as "this one survived", but never used to build the hook's own delete list —
# the convention tests below are what keep this roster honest as it grows.
LEDGERS = {
"scribe-priorart": (".ids", ".rules.ids", ".opened.ids", ".sync.ids",
"grimoire-priorart": (".ids", ".rules.ids", ".opened.ids", ".sync.ids",
".derive.ids", ".shapes.ids", ".reportcheck.ids"),
"scribe-autoinject": (".ids",),
"grimoire-autoinject": (".ids",),
}
@@ -66,15 +66,15 @@ def _swept_dirs() -> set[str]:
agree with itself forever, which is precisely the failure that let the
auto-inject ledger sit in a directory nothing cleared.
"""
line = re.search(r'SCRIBE_LEDGER_DIRS="([^"]*)"', DEFS.read_text())
assert line, "SCRIBE_LEDGER_DIRS is gone; the clear has no roster"
line = re.search(r'GRIMOIRE_LEDGER_DIRS="([^"]*)"', DEFS.read_text())
assert line, "GRIMOIRE_LEDGER_DIRS is gone; the clear has no roster"
return set(line.group(1).split())
def _run_session_start(source: str, tmp: Path, extra: list[str] | None = None) -> Path:
"""Run the SessionStart hook for real, with the ledger directories filled.
`extra` names further files to plant in `scribe-priorart` — used to put
`extra` names further files to plant in `grimoire-priorart` — used to put
an evidence twin in front of the sweep.
"""
for tool in ("bash",):
@@ -87,9 +87,9 @@ def _run_session_start(source: str, tmp: Path, extra: list[str] | None = None) -
for suffix in suffixes:
(state / f"s1{suffix}").write_text("42\t1789600000\n")
# Not a ledger: an outage marker that must outlive the clear.
(tmp / "scribe-priorart" / "s1.unreached").write_text("1\n")
(tmp / "grimoire-priorart" / "s1.unreached").write_text("1\n")
for name in extra or ():
(tmp / "scribe-priorart" / name).write_text("42\t1789600000\n")
(tmp / "grimoire-priorart" / name).write_text("42\t1789600000\n")
env = {"PATH": os.environ["PATH"], "HOME": str(tmp), "TMPDIR": str(tmp)}
out = subprocess.run(
@@ -139,7 +139,7 @@ def test_the_outage_marker_is_not_swept_with_them(tmp_path):
the second with the first.
"""
root = _run_session_start("compact", tmp_path)
assert (root / "scribe-priorart" / "s1.unreached").exists()
assert (root / "grimoire-priorart" / "s1.unreached").exists()
# ── the two assumptions the sweep rests on ─────────────────────────────────
@@ -162,9 +162,9 @@ def _session_paths(text: str):
def test_every_directory_holding_a_ledger_is_one_the_clear_visits():
"""The failure that shipped once already, in the same step that fixed it.
The first cut swept `scribe-priorart` alone. Every ledger NAMED in the
hooks was there, so it read as complete — and `scribe_autoinject.sh` keeps
its note ledger in `scribe-autoinject`, which meant the arm that fires most
The first cut swept `grimoire-priorart` alone. Every ledger NAMED in the
hooks was there, so it read as complete — and `grimoire_autoinject.sh` keeps
its note ledger in `grimoire-autoinject`, which meant the arm that fires most
was the only one still carrying the bug. Nothing said so: the clear ran,
found nothing to remove, and exited 0.
"""
@@ -183,7 +183,7 @@ def test_every_directory_holding_a_ledger_is_one_the_clear_visits():
assert not offenders, (
"these ledgers live in directories the compaction clear never visits, "
f"so they outlive the context they describe: {offenders}. Add the "
"directory to SCRIBE_LEDGER_DIRS in scribe_defs.sh."
"directory to GRIMOIRE_LEDGER_DIRS in grimoire_defs.sh."
)
@@ -223,7 +223,7 @@ def test_the_clear_is_derived_and_not_a_list_of_names():
"""
sh = SESSION_START.read_text()
block = sh.split('case "$source" in')[1].split("esac")[0]
assert "scribe_clear_session_ledgers" in block
assert "grimoire_clear_session_ledgers" in block
for suffix in {s for suffixes in LEDGERS.values() for s in suffixes}:
assert suffix not in block, (
f"the clear names {suffix} again — a list, not a convention"
@@ -233,7 +233,7 @@ def test_the_clear_is_derived_and_not_a_list_of_names():
def test_the_evidence_twin_is_not_swept_with_them(tmp_path):
"""The second thing that survives, and for the `.unreached` reason.
`scribe_rules_append` writes each ledger twice: `<sid>.<kind>.ids`, which
`grimoire_rules_append` writes each ledger twice: `<sid>.<kind>.ids`, which
says what this context HOLDS and must be forgotten here, and
`<sid>.<kind>.keep.ids`, which says what HAPPENED and no compaction makes
untrue. The session-end readout is built on the second, and it runs AT the
@@ -249,7 +249,7 @@ def test_the_evidence_twin_is_not_swept_with_them(tmp_path):
either mistake.
"""
root = _run_session_start("compact", tmp_path, extra=["s1.rules.keep.ids"])
assert (root / "scribe-priorart" / "s1.rules.keep.ids").exists(), (
assert (root / "grimoire-priorart" / "s1.rules.keep.ids").exists(), (
"the evidence twin was swept with the exclusion ledgers — the readout "
"at this seam now reports a session it cannot see"
)
+24 -24
View File
@@ -42,9 +42,9 @@ from tests.helpers import need_tools
ROOT = Path(__file__).resolve().parents[1]
HOOKS = ROOT / "plugin" / "hooks"
DEFS = HOOKS / "scribe_defs.sh"
PRECOMPACT = HOOKS / "scribe_precompact_preserve.sh"
RECORDER = HOOKS / "scribe_record_outcome.sh"
DEFS = HOOKS / "grimoire_defs.sh"
PRECOMPACT = HOOKS / "grimoire_precompact_preserve.sh"
RECORDER = HOOKS / "grimoire_record_outcome.sh"
HOOKS_JSON = HOOKS / "hooks.json"
@@ -61,34 +61,34 @@ def sh(script: str) -> str:
def ledgers(tmp_path, *, named=(), opened=(), acted=(), held=()) -> Path:
"""The four ledgers, written THROUGH THE REAL WRITERS.
Not hand-rolled files: `scribe_rules_append` is what creates the evidence
Not hand-rolled files: `grimoire_rules_append` is what creates the evidence
twin these read from, so a fixture that wrote the bytes itself would keep
passing if the twin stopped being written.
"""
d = tmp_path / "scribe-priorart"
d = tmp_path / "grimoire-priorart"
d.mkdir(parents=True, exist_ok=True)
script = []
for name, ids in (("rules", named), ("opened", opened), ("acted", acted)):
if ids:
body = "".join(f"{i}\n" for i in ids)
script.append(
f"printf '%s' '{body}' | scribe_rules_append \"{d}/s.{name}.ids\""
f"printf '%s' '{body}' | grimoire_rules_append \"{d}/s.{name}.ids\""
)
for i in held:
script.append(f'scribe_checkpoint_allowed "{d}/s.checkpoint.ids" {i} >/dev/null')
script.append(f'grimoire_checkpoint_allowed "{d}/s.checkpoint.ids" {i} >/dev/null')
if script:
sh("\n".join(script))
return d
def readout(d: Path) -> str:
return sh(f'scribe_slippage_lines "{d}" "s"')
return sh(f'grimoire_slippage_lines "{d}" "s"')
# ── The arithmetic ────────────────────────────────────────────────────────
def minus(a: str, b: str) -> str:
return sh(f'scribe_ids_minus "{a}" "{b}"').strip()
return sh(f'grimoire_ids_minus "{a}" "{b}"').strip()
def test_the_difference_keeps_order_and_drops_members():
@@ -159,7 +159,7 @@ def test_a_session_that_resolved_everything_makes_no_accusation(tmp_path):
def test_a_session_no_rule_touched_says_nothing_at_all(tmp_path):
"""A fresh install must not be told something is
wrong when the truth is that nothing has happened yet."""
d = tmp_path / "scribe-priorart"
d = tmp_path / "grimoire-priorart"
d.mkdir(parents=True)
assert readout(d).strip() == ""
@@ -216,13 +216,13 @@ def test_declaring_an_outcome_is_recorded(tmp_path):
tmp_path,
)
assert r.returncode == 0
assert (tmp_path / "scribe-priorart" / "s.acted.ids").read_text().startswith("156\t")
assert (tmp_path / "grimoire-priorart" / "s.acted.ids").read_text().startswith("156\t")
def test_the_entry_is_stamped_like_its_siblings(tmp_path):
"""One reader ages all three ledgers, so all three carry a timestamp."""
run_recorder({"session_id": "s", "tool_input": {"rule_id": 9}}, tmp_path)
line = (tmp_path / "scribe-priorart" / "s.acted.ids").read_text().strip()
line = (tmp_path / "grimoire-priorart" / "s.acted.ids").read_text().strip()
assert "\t" in line and line.split("\t")[1].isdigit()
@@ -235,7 +235,7 @@ def test_an_unusable_event_records_nothing_and_still_exits_zero(event, tmp_path)
hook error."""
r = run_recorder(event, tmp_path)
assert r.returncode == 0
assert not (tmp_path / "scribe-priorart" / "s.acted.ids").exists()
assert not (tmp_path / "grimoire-priorart" / "s.acted.ids").exists()
def test_the_recorder_is_registered_on_the_rule_outcome_tool():
@@ -245,7 +245,7 @@ def test_the_recorder_is_registered_on_the_rule_outcome_tool():
entries = cfg["hooks"]["PostToolUse"]
assert any(
"rule_outcome" in e.get("matcher", "")
and any("scribe_record_outcome.sh" in h["command"] for h in e["hooks"])
and any("grimoire_record_outcome.sh" in h["command"] for h in e["hooks"])
for e in entries
)
@@ -268,7 +268,7 @@ def test_the_readout_is_unchanged_by_the_sweep(tmp_path):
reported all of it."""
d = ledgers(tmp_path, named=[9, 34, 156], opened=[9, 156], acted=[156], held=[156])
before = readout(d)
sh(f'TMPDIR="{tmp_path}" scribe_clear_session_ledgers s')
sh(f'TMPDIR="{tmp_path}" grimoire_clear_session_ledgers s')
assert readout(d) == before
assert "READ WITH NO OUTCOME RECORDED: 9" in readout(d)
@@ -279,7 +279,7 @@ def test_the_sweep_still_clears_what_the_context_no_longer_holds(tmp_path):
the strength of the old ledger would be silent about a rule the context
has lost."""
d = ledgers(tmp_path, named=[9], opened=[9])
sh(f'TMPDIR="{tmp_path}" scribe_clear_session_ledgers s')
sh(f'TMPDIR="{tmp_path}" grimoire_clear_session_ledgers s')
assert not (d / "s.rules.ids").exists()
assert not (d / "s.opened.ids").exists()
assert (d / "s.opened.keep.ids").exists()
@@ -288,7 +288,7 @@ def test_the_sweep_still_clears_what_the_context_no_longer_holds(tmp_path):
def test_the_evidence_twin_is_not_aged_out(tmp_path):
"""An exclusion ledger ages by TTL, and should: a rule named two hours ago
is not in this context. A rule OPENED two hours ago was still opened."""
d = tmp_path / "scribe-priorart"
d = tmp_path / "grimoire-priorart"
d.mkdir(parents=True, exist_ok=True)
stale = int(time.time()) - 99999
(d / "s.opened.keep.ids").write_text(f"9\t{stale}\n")
@@ -300,11 +300,11 @@ def test_every_ledger_written_through_the_appender_gets_a_twin(tmp_path):
"""Derived in one place rather than listed at the call sites — a list is
what broke this before, and the next ledger somebody adds should be born
on the right side without anyone remembering to say so."""
d = tmp_path / "scribe-priorart"
d = tmp_path / "grimoire-priorart"
d.mkdir(parents=True, exist_ok=True)
sh(f"""printf '7\n' | scribe_rules_append "{d}/s.brandnew.ids" """)
sh(f"""printf '7\n' | grimoire_rules_append "{d}/s.brandnew.ids" """)
assert (d / "s.brandnew.keep.ids").read_text().startswith("7\t")
sh(f'TMPDIR="{tmp_path}" scribe_clear_session_ledgers s')
sh(f'TMPDIR="{tmp_path}" grimoire_clear_session_ledgers s')
assert (d / "s.brandnew.keep.ids").exists()
@@ -312,15 +312,15 @@ def test_a_checkpoint_budget_is_restored_by_a_compaction(tmp_path):
"""The cap counts the SWEPT file on purpose. After a compaction this
context has not read the rule, so the budget to stop an act on it is
honestly fresh — while the record that a stop already happened stays."""
d = tmp_path / "scribe-priorart"
d = tmp_path / "grimoire-priorart"
d.mkdir(parents=True, exist_ok=True)
f = d / "s.checkpoint.ids"
sh(f'scribe_checkpoint_allowed "{f}" 156 >/dev/null')
sh(f'grimoire_checkpoint_allowed "{f}" 156 >/dev/null')
r = subprocess.run(
["bash", "-c", f'. "{DEFS}"\nscribe_checkpoint_allowed "{f}" 156'],
capture_output=True, text=True, timeout=30,
)
assert r.returncode != 0, "a rule does not get to stop the same session twice"
sh(f'TMPDIR="{tmp_path}" scribe_clear_session_ledgers s')
sh(f'scribe_checkpoint_allowed "{f}" 156 >/dev/null')
sh(f'TMPDIR="{tmp_path}" grimoire_clear_session_ledgers s')
sh(f'grimoire_checkpoint_allowed "{f}" 156 >/dev/null')
assert (d / "s.checkpoint.keep.ids").read_text().count("156") == 2
+4 -4
View File
@@ -21,8 +21,8 @@ import pytest
from tests.helpers import http_sink
HOOKS = Path(__file__).resolve().parents[1] / "plugin" / "hooks"
CHECK = HOOKS / "scribe_shape_check.sh"
PRIOR_ART = HOOKS / "scribe_prior_art.sh"
CHECK = HOOKS / "grimoire_shape_check.sh"
PRIOR_ART = HOOKS / "grimoire_prior_art.sh"
REASON = "SERVER REASON: judge what you wrote"
@@ -30,12 +30,12 @@ def _env(tmp_path, url="http://127.0.0.1:9"):
for tool in ("curl", "bash", "git"):
if shutil.which(tool) is None:
pytest.skip(f"hook runtime tool {tool!r} not installed")
return {"PATH": os.environ["PATH"], "SCRIBE_URL": url, "SCRIBE_TOKEN": "t",
return {"PATH": os.environ["PATH"], "GRIMOIRE_URL": url, "GRIMOIRE_TOKEN": "t",
"TMPDIR": str(tmp_path), "HOME": str(tmp_path)}
def _ledger(tmp_path, session="s1"):
return tmp_path / "scribe-priorart" / f"{session}.written.ids"
return tmp_path / "grimoire-priorart" / f"{session}.written.ids"
def _write_ledger(tmp_path, lines, session="s1"):
+1 -1
View File
@@ -8,7 +8,7 @@ keep the SKILL.md body under 500 lines, split into reference files as it
nears that, and link each reference file ONE level deep from SKILL.md —
Claude may only preview a file reached through another reference file.
using-scribe grows because every new practice lands there. What matters on
using-grimoire grows because every new practice lands there. What matters on
most turns stays in SKILL.md; what matters at one moment (placing a rule, a
missed retrieval, starting a project) lives in files SKILL.md names with the
moment to open them. These tests keep that shape from quietly undoing
+7 -7
View File
@@ -125,12 +125,12 @@ def test_a_dead_claim_restarts_rather_than_resumes():
def test_the_binder_hook_watches_the_two_writes_that_stamp_a_claim():
hooks = json.loads((ROOT / "plugin/hooks/hooks.json").read_text())["hooks"]
blocks = [b for b in hooks["PostToolUse"]
if any("scribe_claim_session.sh" in h["command"] for h in b["hooks"])]
if any("grimoire_claim_session.sh" in h["command"] for h in b["hooks"])]
assert len(blocks) == 1
matcher = re.compile(blocks[0]["matcher"])
assert matcher.fullmatch("mcp__plugin_scribe_scribe__update_task")
assert matcher.fullmatch("mcp__scribe__add_task_log")
assert not matcher.fullmatch("mcp__scribe__get_task")
assert matcher.fullmatch("mcp__plugin_grimoire_grimoire__update_task")
assert matcher.fullmatch("mcp__grimoire__add_task_log")
assert not matcher.fullmatch("mcp__grimoire__get_task")
# --- 5. binding a session (integration) --------------------------------------
@@ -236,7 +236,7 @@ def test_a_dead_claim_on_finished_work_is_not_news():
def test_the_session_start_hook_sends_the_source_and_the_session():
"""The reader branches on what the hook sends; a hook that stopped sending
either would leave every session on the no-claims path, silently."""
text = (ROOT / "plugin/hooks/scribe_session_context.sh").read_text()
text = (ROOT / "plugin/hooks/grimoire_session_context.sh").read_text()
assert re.search(r'q="source=\$\(printf', text)
assert "session_id=$(printf" in text
@@ -265,8 +265,8 @@ def test_session_end_releases_claims_but_not_on_clear():
SessionStart(source=clear) pushes the claimed work straight back."""
hooks = json.loads((ROOT / "plugin/hooks/hooks.json").read_text())["hooks"]
commands = [h["command"] for b in hooks.get("SessionEnd", []) for h in b["hooks"]]
assert any("scribe_session_end.sh" in c for c in commands)
text = (ROOT / "plugin/hooks/scribe_session_end.sh").read_text()
assert any("grimoire_session_end.sh" in c for c in commands)
text = (ROOT / "plugin/hooks/grimoire_session_end.sh").read_text()
assert '[ "$reason" = "clear" ] && exit 0' in text
assert "/api/plugin/release-session" in text
+2 -2
View File
@@ -95,9 +95,9 @@ def test_the_skill_carries_the_test_a_writer_can_actually_apply():
# Reference files included: the full statement sits in writing-records.md
# and SKILL.md keeps the pointer.
text = " ".join(skill_text("using-scribe").split())
text = " ".join(skill_text("using-grimoire").split())
assert "could this note become false without anyone editing it" in text.lower(), (
"the using-scribe skill no longer carries the one-question test. That "
"the using-grimoire skill no longer carries the one-question test. That "
"question is what makes the distinction applicable rather than merely "
"true."
)
+1 -1
View File
@@ -238,5 +238,5 @@ def test_the_instruction_surfaces_point_at_it():
assert "what_might_apply" in server._INSTRUCTIONS
skill = (pathlib.Path(__file__).resolve().parents[1]
/ "plugin" / "skills" / "using-scribe" / "SKILL.md").read_text()
/ "plugin" / "skills" / "using-grimoire" / "SKILL.md").read_text()
assert "what_might_apply" in skill
+32 -32
View File
@@ -40,7 +40,7 @@ def _stored_settings(stored):
PLUGIN = Path(__file__).resolve().parents[1] / "plugin"
HOOK = PLUGIN / "hooks" / "scribe_prior_art.sh"
HOOK = PLUGIN / "hooks" / "grimoire_prior_art.sh"
def _snippet_item(nid, title, user_id=1):
@@ -978,13 +978,13 @@ def test_route_reads_every_arg_the_hook_sends():
for arg in ("path=", "code=", "exclude_ids=", "exclude_sync_ids=", "shapes="):
assert arg in hook, f"hook never sends {arg}"
# The project-scope key is not spelled in the hook: it is whichever of `repo=` / `project_id=` scribe_scope_query picks, so the hook
# The project-scope key is not spelled in the hook: it is whichever of `repo=` / `project_id=` grimoire_scope_query picks, so the hook
# splices in its output and the helper is the other half of the contract.
assert "scribe_scope_query" in hook, "hook no longer asks for a project scope"
assert "grimoire_scope_query" in hook, "hook no longer asks for a project scope"
# Same arrangement for the OPENED ledger: spelled once in the
# helper so its ageing and format cannot drift from the naming ledger's.
assert "scribe_held_query" in hook, "hook no longer sends the opened ledger"
defs = (HOOK.parent / "scribe_defs.sh").read_text()
assert "grimoire_held_query" in hook, "hook no longer sends the opened ledger"
defs = (HOOK.parent / "grimoire_defs.sh").read_text()
assert set(re.findall(r"printf '([a-z_]+)=", defs)) == {"repo", "project_id"}
# Its own leading `&` — it is appended to a query that already carries a
# scope key, rather than being one of the alternatives that opens one.
@@ -992,7 +992,7 @@ def test_route_reads_every_arg_the_hook_sends():
"the opened ledger's query key is no longer spelled in the helper"
)
# The rulings ledger, shared with the tool-rules hook.
assert "scribe_rulings_query" in hook, "hook no longer sends the rulings ledger"
assert "grimoire_rulings_query" in hook, "hook no longer sends the rulings ledger"
assert "printf '&seen_ruling_systems=" in defs
@@ -1017,7 +1017,7 @@ def test_hook_is_registered_for_write_and_edit():
entries = cfg["hooks"]["PreToolUse"]
assert any(
"Write" in e.get("matcher", "") and "Edit" in e.get("matcher", "")
and any("scribe_prior_art.sh" in h["command"] for h in e["hooks"])
and any("grimoire_prior_art.sh" in h["command"] for h in e["hooks"])
for e in entries
)
@@ -1063,7 +1063,7 @@ def test_hook_exits_silently_when_unconfigured():
"tool_input": {"file_path": "/tmp/x.py", "content": "def f(): ..."},
}),
capture_output=True, text=True,
env={"PATH": "/usr/bin:/bin", "SCRIBE_URL": "", "SCRIBE_TOKEN": ""},
env={"PATH": "/usr/bin:/bin", "GRIMOIRE_URL": "", "GRIMOIRE_TOKEN": ""},
)
assert out.returncode == 0
assert out.stdout.strip() == ""
@@ -1072,16 +1072,16 @@ def test_hook_exits_silently_when_unconfigured():
def test_hook_skips_prose_and_data_files():
"""No round-trip for a markdown edit — the server would return nothing
anyway. The list lives in the shared library and the hook asks it."""
lib = (PLUGIN / "hooks" / "scribe_defs.sh").read_text()
skip = re.search(r"scribe_skip_path\(\) \{\n case \"\$1\" in\n(.*?)esac", lib, re.S)
assert skip, "expected an extension skip list in scribe_defs.sh"
lib = (PLUGIN / "hooks" / "grimoire_defs.sh").read_text()
skip = re.search(r"grimoire_skip_path\(\) \{\n case \"\$1\" in\n(.*?)esac", lib, re.S)
assert skip, "expected an extension skip list in grimoire_defs.sh"
for ext in ("*.md", "*.json", "*.lock", "*.png"):
assert ext in skip.group(1)
# Config formats are deliberately NOT skipped — a workflow file is reusable.
assert "*.yml" not in skip.group(1)
src = HOOK.read_text()
assert 'scribe_skip_path "$file_path" && exit 0' in src
assert '/scribe_defs.sh"' in src # sourced, not copied
assert 'grimoire_skip_path "$file_path" && exit 0' in src
assert '/grimoire_defs.sh"' in src # sourced, not copied
def test_hook_keeps_sync_and_reuse_dedup_apart():
@@ -1093,9 +1093,9 @@ def test_hook_keeps_sync_and_reuse_dedup_apart():
assert ".sync.ids" in src # its own state file
assert "exclude_sync_ids=" in src # its own query channel
# The reuse file must NOT swallow sync ids — the write-back subtracts them.
# Spelled with the shell JSON readers in scribe_defs.sh.
assert "scribe_json_list_minus \"$body_flat\" '.note_ids' '.sync_note_ids'" in src
assert "scribe_json_list \"$body_flat\" '.sync_note_ids'" in src
# Spelled with the shell JSON readers in grimoire_defs.sh.
assert "grimoire_json_list_minus \"$body_flat\" '.note_ids' '.sync_note_ids'" in src
assert "grimoire_json_list \"$body_flat\" '.sync_note_ids'" in src
def _hook_runtime_env():
@@ -1115,7 +1115,7 @@ def _hook_runtime_env():
if shutil.which(tool) is None:
pytest.skip(f"hook runtime tool {tool!r} not installed")
return {"PATH": os.environ["PATH"],
"SCRIBE_URL": "http://127.0.0.1:9", "SCRIBE_TOKEN": "t"}
"GRIMOIRE_URL": "http://127.0.0.1:9", "GRIMOIRE_TOKEN": "t"}
def _dup_repo(tmp_path, env):
@@ -1141,7 +1141,7 @@ def test_hook_nudges_recording_when_copies_exist_but_nothing_is_recorded(tmp_pat
no record of it, the same context block must ask for create_snippet — the
one moment the recording nudge is earned rather than noise."""
with http_sink(b'{"context":"","note_ids":[],"sync_note_ids":[]}') as (port, seen):
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{port}")
env = dict(_hook_runtime_env(), GRIMOIRE_URL=f"http://127.0.0.1:{port}")
repo = _dup_repo(tmp_path, env)
out = subprocess.run(["bash", str(HOOK)], input=_write_event(repo),
capture_output=True, text=True, env=env)
@@ -1157,15 +1157,15 @@ def test_hook_nudges_recording_when_copies_exist_but_nothing_is_recorded(tmp_pat
assert "did not answer" not in ctx
def test_hook_says_when_scribe_did_not_answer_once_per_outage(tmp_path):
def test_hook_says_when_grimoire_did_not_answer_once_per_outage(tmp_path):
"""A configured instance that does not answer (refused connection)
is SAID — the write went unchecked — instead of the hook failing open in
silence; the record nudge's "nothing recorded" claim is withheld. Once per
outage: a second miss is quiet, an answer clears the marker, and the next
miss speaks again. The marker is shared with the after-write hook."""
env = _hook_runtime_env() # SCRIBE_URL → a refused port
env = _hook_runtime_env() # GRIMOIRE_URL → a refused port
repo = _dup_repo(tmp_path, env)
marker = tmp_path / "scribe-priorart" / "s-out.unreached"
marker = tmp_path / "grimoire-priorart" / "s-out.unreached"
env["TMPDIR"] = str(tmp_path)
out = subprocess.run(["bash", str(HOOK)], input=_write_event(repo, "s-out"),
capture_output=True, text=True, env=env)
@@ -1183,7 +1183,7 @@ def test_hook_says_when_scribe_did_not_answer_once_per_outage(tmp_path):
assert "already defined" in ctx and "did not answer" not in ctx
# An answer clears the marker …
with http_sink(b'{"context":"","note_ids":[],"sync_note_ids":[]}') as (port, _seen):
up = dict(env, SCRIBE_URL=f"http://127.0.0.1:{port}")
up = dict(env, GRIMOIRE_URL=f"http://127.0.0.1:{port}")
subprocess.run(["bash", str(HOOK)], input=_write_event(repo, "s-out"),
capture_output=True, text=True, env=up)
assert not marker.exists()
@@ -1244,7 +1244,7 @@ def test_local_arm_finds_duplicates_in_every_language_family(
ask for the record (the instance ANSWERS "nothing recorded" — an
unanswered call withholds the nudge, so a sink stands in for it)."""
with http_sink(b'{"context":"","note_ids":[],"sync_note_ids":[]}') as (port, _seen):
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{port}")
env = dict(_hook_runtime_env(), GRIMOIRE_URL=f"http://127.0.0.1:{port}")
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
@@ -1443,20 +1443,20 @@ def test_hook_names_the_shapes_being_written():
that changes a body, not a signature — the definition enclosing the edit,
found by walking the target file upward from the edited lines."""
src = HOOK.read_text()
lib = (PLUGIN / "hooks" / "scribe_defs.sh").read_text()
assert "scribe_defs()" in lib # one extractor, shared
assert "scribe_defs()" not in src # ...not a second copy here
lib = (PLUGIN / "hooks" / "grimoire_defs.sh").read_text()
assert "grimoire_defs()" in lib # one extractor, shared
assert "grimoire_defs()" not in src # ...not a second copy here
assert ".tool_input.old_string" in src # the Edit's anchor
assert '"$file_path" | scribe_defs | tail -1' in src # nearest definition above
assert '"$file_path" | grimoire_defs | tail -1' in src # nearest definition above
# The ledger feed sends NAMES, never bodies, and stays on the one GET.
assert src.count("/api/plugin/prior-art?") == 1
def _run_hook_against_sink(tmp_path, payload):
"""Run the hook with SCRIBE_URL pointed at a throwaway local listener and
"""Run the hook with GRIMOIRE_URL pointed at a throwaway local listener and
return the query the hook sent (tests.helpers.http_sink)."""
with http_sink() as (port, seen):
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{port}")
env = dict(_hook_runtime_env(), GRIMOIRE_URL=f"http://127.0.0.1:{port}")
out = subprocess.run(
["bash", str(HOOK)], input=json.dumps(payload),
capture_output=True, text=True, env=env,
@@ -1602,7 +1602,7 @@ def test_the_hook_keeps_a_derive_channel_and_sends_it_back(tmp_path):
reply = (b'{"context":"> family","note_ids":[],"sync_note_ids":[],'
b'"derive_keys":["dup:483a","canon:2855"]}')
with http_sink(reply) as (port, seen):
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{port}",
env = dict(_hook_runtime_env(), GRIMOIRE_URL=f"http://127.0.0.1:{port}",
TMPDIR=str(tmp_path))
payload = {"session_id": "s-derive-1", "cwd": str(tmp_path), "tool_name": "Write",
"tool_input": {"file_path": str(tmp_path / "a.css"),
@@ -1613,7 +1613,7 @@ def test_the_hook_keeps_a_derive_channel_and_sends_it_back(tmp_path):
assert out.returncode == 0, out.stderr
assert "exclude_derive" not in seen[0]
assert seen[1]["exclude_derive"] == ["dup:483a,canon:2855"]
state = tmp_path / "scribe-priorart" / "s-derive-1.derive.ids"
state = tmp_path / "grimoire-priorart" / "s-derive-1.derive.ids"
assert state.read_text().split() == ["dup:483a", "canon:2855", "dup:483a", "canon:2855"]
@@ -1671,7 +1671,7 @@ def test_hook_keeps_the_rule_channel_apart_from_the_other_three():
src = HOOK.read_text()
assert ".rules.ids" in src # its own state file
assert "exclude_rule_ids=" in src # its own query channel
assert "scribe_json_list \"$body_flat\" '.rule_ids'" in src # its own write-back
assert "grimoire_json_list \"$body_flat\" '.rule_ids'" in src # its own write-back
# And it rides the same request as the rest, not a second round trip.
assert "${rule_exclude_q}" in src