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

Step 3 of the Scribe -> Grimoire rename.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 10:52:55 -04:00
co-authored by Claude Opus 5.5
parent 0f5ef0be63
commit c5eeec471f
87 changed files with 1027 additions and 989 deletions
+32 -32
View File
@@ -40,7 +40,7 @@ def _stored_settings(stored):
PLUGIN = Path(__file__).resolve().parents[1] / "plugin"
HOOK = PLUGIN / "hooks" / "scribe_prior_art.sh"
HOOK = PLUGIN / "hooks" / "grimoire_prior_art.sh"
def _snippet_item(nid, title, user_id=1):
@@ -978,13 +978,13 @@ def test_route_reads_every_arg_the_hook_sends():
for arg in ("path=", "code=", "exclude_ids=", "exclude_sync_ids=", "shapes="):
assert arg in hook, f"hook never sends {arg}"
# The project-scope key is not spelled in the hook: it is whichever of `repo=` / `project_id=` scribe_scope_query picks, so the hook
# The project-scope key is not spelled in the hook: it is whichever of `repo=` / `project_id=` grimoire_scope_query picks, so the hook
# splices in its output and the helper is the other half of the contract.
assert "scribe_scope_query" in hook, "hook no longer asks for a project scope"
assert "grimoire_scope_query" in hook, "hook no longer asks for a project scope"
# Same arrangement for the OPENED ledger: spelled once in the
# helper so its ageing and format cannot drift from the naming ledger's.
assert "scribe_held_query" in hook, "hook no longer sends the opened ledger"
defs = (HOOK.parent / "scribe_defs.sh").read_text()
assert "grimoire_held_query" in hook, "hook no longer sends the opened ledger"
defs = (HOOK.parent / "grimoire_defs.sh").read_text()
assert set(re.findall(r"printf '([a-z_]+)=", defs)) == {"repo", "project_id"}
# Its own leading `&` — it is appended to a query that already carries a
# scope key, rather than being one of the alternatives that opens one.
@@ -992,7 +992,7 @@ def test_route_reads_every_arg_the_hook_sends():
"the opened ledger's query key is no longer spelled in the helper"
)
# The rulings ledger, shared with the tool-rules hook.
assert "scribe_rulings_query" in hook, "hook no longer sends the rulings ledger"
assert "grimoire_rulings_query" in hook, "hook no longer sends the rulings ledger"
assert "printf '&seen_ruling_systems=" in defs
@@ -1017,7 +1017,7 @@ def test_hook_is_registered_for_write_and_edit():
entries = cfg["hooks"]["PreToolUse"]
assert any(
"Write" in e.get("matcher", "") and "Edit" in e.get("matcher", "")
and any("scribe_prior_art.sh" in h["command"] for h in e["hooks"])
and any("grimoire_prior_art.sh" in h["command"] for h in e["hooks"])
for e in entries
)
@@ -1063,7 +1063,7 @@ def test_hook_exits_silently_when_unconfigured():
"tool_input": {"file_path": "/tmp/x.py", "content": "def f(): ..."},
}),
capture_output=True, text=True,
env={"PATH": "/usr/bin:/bin", "SCRIBE_URL": "", "SCRIBE_TOKEN": ""},
env={"PATH": "/usr/bin:/bin", "GRIMOIRE_URL": "", "GRIMOIRE_TOKEN": ""},
)
assert out.returncode == 0
assert out.stdout.strip() == ""
@@ -1072,16 +1072,16 @@ def test_hook_exits_silently_when_unconfigured():
def test_hook_skips_prose_and_data_files():
"""No round-trip for a markdown edit — the server would return nothing
anyway. The list lives in the shared library and the hook asks it."""
lib = (PLUGIN / "hooks" / "scribe_defs.sh").read_text()
skip = re.search(r"scribe_skip_path\(\) \{\n case \"\$1\" in\n(.*?)esac", lib, re.S)
assert skip, "expected an extension skip list in scribe_defs.sh"
lib = (PLUGIN / "hooks" / "grimoire_defs.sh").read_text()
skip = re.search(r"grimoire_skip_path\(\) \{\n case \"\$1\" in\n(.*?)esac", lib, re.S)
assert skip, "expected an extension skip list in grimoire_defs.sh"
for ext in ("*.md", "*.json", "*.lock", "*.png"):
assert ext in skip.group(1)
# Config formats are deliberately NOT skipped — a workflow file is reusable.
assert "*.yml" not in skip.group(1)
src = HOOK.read_text()
assert 'scribe_skip_path "$file_path" && exit 0' in src
assert '/scribe_defs.sh"' in src # sourced, not copied
assert 'grimoire_skip_path "$file_path" && exit 0' in src
assert '/grimoire_defs.sh"' in src # sourced, not copied
def test_hook_keeps_sync_and_reuse_dedup_apart():
@@ -1093,9 +1093,9 @@ def test_hook_keeps_sync_and_reuse_dedup_apart():
assert ".sync.ids" in src # its own state file
assert "exclude_sync_ids=" in src # its own query channel
# The reuse file must NOT swallow sync ids — the write-back subtracts them.
# Spelled with the shell JSON readers in scribe_defs.sh.
assert "scribe_json_list_minus \"$body_flat\" '.note_ids' '.sync_note_ids'" in src
assert "scribe_json_list \"$body_flat\" '.sync_note_ids'" in src
# Spelled with the shell JSON readers in grimoire_defs.sh.
assert "grimoire_json_list_minus \"$body_flat\" '.note_ids' '.sync_note_ids'" in src
assert "grimoire_json_list \"$body_flat\" '.sync_note_ids'" in src
def _hook_runtime_env():
@@ -1115,7 +1115,7 @@ def _hook_runtime_env():
if shutil.which(tool) is None:
pytest.skip(f"hook runtime tool {tool!r} not installed")
return {"PATH": os.environ["PATH"],
"SCRIBE_URL": "http://127.0.0.1:9", "SCRIBE_TOKEN": "t"}
"GRIMOIRE_URL": "http://127.0.0.1:9", "GRIMOIRE_TOKEN": "t"}
def _dup_repo(tmp_path, env):
@@ -1141,7 +1141,7 @@ def test_hook_nudges_recording_when_copies_exist_but_nothing_is_recorded(tmp_pat
no record of it, the same context block must ask for create_snippet — the
one moment the recording nudge is earned rather than noise."""
with http_sink(b'{"context":"","note_ids":[],"sync_note_ids":[]}') as (port, seen):
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{port}")
env = dict(_hook_runtime_env(), GRIMOIRE_URL=f"http://127.0.0.1:{port}")
repo = _dup_repo(tmp_path, env)
out = subprocess.run(["bash", str(HOOK)], input=_write_event(repo),
capture_output=True, text=True, env=env)
@@ -1157,15 +1157,15 @@ def test_hook_nudges_recording_when_copies_exist_but_nothing_is_recorded(tmp_pat
assert "did not answer" not in ctx
def test_hook_says_when_scribe_did_not_answer_once_per_outage(tmp_path):
def test_hook_says_when_grimoire_did_not_answer_once_per_outage(tmp_path):
"""A configured instance that does not answer (refused connection)
is SAID — the write went unchecked — instead of the hook failing open in
silence; the record nudge's "nothing recorded" claim is withheld. Once per
outage: a second miss is quiet, an answer clears the marker, and the next
miss speaks again. The marker is shared with the after-write hook."""
env = _hook_runtime_env() # SCRIBE_URL → a refused port
env = _hook_runtime_env() # GRIMOIRE_URL → a refused port
repo = _dup_repo(tmp_path, env)
marker = tmp_path / "scribe-priorart" / "s-out.unreached"
marker = tmp_path / "grimoire-priorart" / "s-out.unreached"
env["TMPDIR"] = str(tmp_path)
out = subprocess.run(["bash", str(HOOK)], input=_write_event(repo, "s-out"),
capture_output=True, text=True, env=env)
@@ -1183,7 +1183,7 @@ def test_hook_says_when_scribe_did_not_answer_once_per_outage(tmp_path):
assert "already defined" in ctx and "did not answer" not in ctx
# An answer clears the marker …
with http_sink(b'{"context":"","note_ids":[],"sync_note_ids":[]}') as (port, _seen):
up = dict(env, SCRIBE_URL=f"http://127.0.0.1:{port}")
up = dict(env, GRIMOIRE_URL=f"http://127.0.0.1:{port}")
subprocess.run(["bash", str(HOOK)], input=_write_event(repo, "s-out"),
capture_output=True, text=True, env=up)
assert not marker.exists()
@@ -1244,7 +1244,7 @@ def test_local_arm_finds_duplicates_in_every_language_family(
ask for the record (the instance ANSWERS "nothing recorded" — an
unanswered call withholds the nudge, so a sink stands in for it)."""
with http_sink(b'{"context":"","note_ids":[],"sync_note_ids":[]}') as (port, _seen):
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{port}")
env = dict(_hook_runtime_env(), GRIMOIRE_URL=f"http://127.0.0.1:{port}")
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
@@ -1443,20 +1443,20 @@ def test_hook_names_the_shapes_being_written():
that changes a body, not a signature — the definition enclosing the edit,
found by walking the target file upward from the edited lines."""
src = HOOK.read_text()
lib = (PLUGIN / "hooks" / "scribe_defs.sh").read_text()
assert "scribe_defs()" in lib # one extractor, shared
assert "scribe_defs()" not in src # ...not a second copy here
lib = (PLUGIN / "hooks" / "grimoire_defs.sh").read_text()
assert "grimoire_defs()" in lib # one extractor, shared
assert "grimoire_defs()" not in src # ...not a second copy here
assert ".tool_input.old_string" in src # the Edit's anchor
assert '"$file_path" | scribe_defs | tail -1' in src # nearest definition above
assert '"$file_path" | grimoire_defs | tail -1' in src # nearest definition above
# The ledger feed sends NAMES, never bodies, and stays on the one GET.
assert src.count("/api/plugin/prior-art?") == 1
def _run_hook_against_sink(tmp_path, payload):
"""Run the hook with SCRIBE_URL pointed at a throwaway local listener and
"""Run the hook with GRIMOIRE_URL pointed at a throwaway local listener and
return the query the hook sent (tests.helpers.http_sink)."""
with http_sink() as (port, seen):
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{port}")
env = dict(_hook_runtime_env(), GRIMOIRE_URL=f"http://127.0.0.1:{port}")
out = subprocess.run(
["bash", str(HOOK)], input=json.dumps(payload),
capture_output=True, text=True, env=env,
@@ -1602,7 +1602,7 @@ def test_the_hook_keeps_a_derive_channel_and_sends_it_back(tmp_path):
reply = (b'{"context":"> family","note_ids":[],"sync_note_ids":[],'
b'"derive_keys":["dup:483a","canon:2855"]}')
with http_sink(reply) as (port, seen):
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{port}",
env = dict(_hook_runtime_env(), GRIMOIRE_URL=f"http://127.0.0.1:{port}",
TMPDIR=str(tmp_path))
payload = {"session_id": "s-derive-1", "cwd": str(tmp_path), "tool_name": "Write",
"tool_input": {"file_path": str(tmp_path / "a.css"),
@@ -1613,7 +1613,7 @@ def test_the_hook_keeps_a_derive_channel_and_sends_it_back(tmp_path):
assert out.returncode == 0, out.stderr
assert "exclude_derive" not in seen[0]
assert seen[1]["exclude_derive"] == ["dup:483a,canon:2855"]
state = tmp_path / "scribe-priorart" / "s-derive-1.derive.ids"
state = tmp_path / "grimoire-priorart" / "s-derive-1.derive.ids"
assert state.read_text().split() == ["dup:483a", "canon:2855", "dup:483a", "canon:2855"]
@@ -1671,7 +1671,7 @@ def test_hook_keeps_the_rule_channel_apart_from_the_other_three():
src = HOOK.read_text()
assert ".rules.ids" in src # its own state file
assert "exclude_rule_ids=" in src # its own query channel
assert "scribe_json_list \"$body_flat\" '.rule_ids'" in src # its own write-back
assert "grimoire_json_list \"$body_flat\" '.rule_ids'" in src # its own write-back
# And it rides the same request as the rest, not a second round trip.
assert "${rule_exclude_q}" in src