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
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:
@@ -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")
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
+5
-5
@@ -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
@@ -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
-1
@@ -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
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, ...]] = {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ---------------------------------------------
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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) == ""
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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"]]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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) ----------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user