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
+27 -27
View File
@@ -4,7 +4,7 @@ WHY NOT jq, AND WHY THIS NEEDS TESTING AT ALL. jq is absent by default on
macOS, on the Debian/Ubuntu slim images, on Alpine and in most CI containers,
and a hook that exits quietly without it gives the operator no session context,
no rules, no prior art and no process sync, with not one word saying why. The
parser is `plugin/hooks/scribe_json.awk`, written in POSIX awk, and a parser is exactly
parser is `plugin/hooks/grimoire_json.awk`, written in POSIX awk, and a parser is exactly
the kind of thing that works on the six documents you tried it on.
So the centre of this file is a DIFFERENTIAL against Python's `json`: the same
@@ -30,13 +30,13 @@ import pytest
from tests.helpers import need_tools
HOOKS = Path(__file__).resolve().parents[1] / "plugin" / "hooks"
DEFS = HOOKS / "scribe_defs.sh"
PARSER = HOOKS / "scribe_json.awk"
TURN = HOOKS / "scribe_turn.awk"
DEFS = HOOKS / "grimoire_defs.sh"
PARSER = HOOKS / "grimoire_json.awk"
TURN = HOOKS / "grimoire_turn.awk"
def sh(script: str, stdin: str = "") -> str:
"""Run a snippet with scribe_defs.sh sourced, under the hooks' own flags.
"""Run a snippet with grimoire_defs.sh sourced, under the hooks' own flags.
BYTES IN, BYTES OUT, decoded here — NOT `text=True`. Text mode turns on
universal newlines, which rewrites a carriage return in the output to a
@@ -65,7 +65,7 @@ def flat(doc: str, mode: str = "whole") -> list[tuple[str, str, str]]:
# The differential: this parser against Python's.
def reference_flatten(value, path="", out=None):
"""What scribe_json.awk is specified to emit, computed with `json`.
"""What grimoire_json.awk is specified to emit, computed with `json`.
A string's VALUE is its RAW escaped body — the parser deliberately does not
decode, because a newline inside a value would break the line format the
@@ -150,7 +150,7 @@ def test_a_value_survives_the_round_trip_the_hooks_actually_make(value):
character entry matters most: it is longer than the parser's token window,
so it takes the wide-read fallback rather than the fast path."""
doc = json.dumps({"tool_input": {"content": value}}, ensure_ascii=False)
got = sh('flat=$(scribe_json_flat); scribe_json_pick "$flat" \'.tool_input.content\'',
got = sh('flat=$(grimoire_json_flat); grimoire_json_pick "$flat" \'.tool_input.content\'',
stdin=doc)
assert got.rstrip("\n") == value.rstrip("\n")
@@ -170,7 +170,7 @@ def test_an_escaped_character_decodes_to_utf8_in_any_locale(value, locale):
if locale:
env["LC_ALL"] = locale
body = json.dumps(value)[1:-1]
r = subprocess.run(["bash", "-c", f'. "{DEFS}"; scribe_json_unescape'],
r = subprocess.run(["bash", "-c", f'. "{DEFS}"; grimoire_json_unescape'],
input=body.encode(), capture_output=True, env=env)
assert r.returncode == 0, r.stderr.decode()
assert r.stdout == (value + "\n").encode()
@@ -190,14 +190,14 @@ def test_pick_list_and_len_read_what_the_server_sends():
"sync_note_ids": [22], "derive_keys": ["k1", "k2"],
"rule_ids": [], "project": {"id": 7}})
out = sh(r"""
flat=$(scribe_json_flat)
echo "ctx=$(scribe_json_pick "$flat" '.context')"
echo "pid=$(scribe_json_pick "$flat" '.project.id')"
echo "notes=$(scribe_json_list "$flat" '.note_ids' | tr '\n' ',')"
echo "keys=$(scribe_json_list "$flat" '.derive_keys' | tr '\n' ',')"
echo "n=$(scribe_json_len "$flat" '.note_ids')"
echo "rules_n=$(scribe_json_len "$flat" '.rule_ids')"
echo "minus=$(scribe_json_list_minus "$flat" '.note_ids' '.sync_note_ids' | tr '\n' ',')"
flat=$(grimoire_json_flat)
echo "ctx=$(grimoire_json_pick "$flat" '.context')"
echo "pid=$(grimoire_json_pick "$flat" '.project.id')"
echo "notes=$(grimoire_json_list "$flat" '.note_ids' | tr '\n' ',')"
echo "keys=$(grimoire_json_list "$flat" '.derive_keys' | tr '\n' ',')"
echo "n=$(grimoire_json_len "$flat" '.note_ids')"
echo "rules_n=$(grimoire_json_len "$flat" '.rule_ids')"
echo "minus=$(grimoire_json_list_minus "$flat" '.note_ids' '.sync_note_ids' | tr '\n' ',')"
""", stdin=body)
got = dict(line.split("=", 1) for line in out.splitlines())
assert got == {"ctx": "some markdown", "pid": "7", "notes": "11,22,33,",
@@ -209,9 +209,9 @@ def test_an_absent_field_and_a_null_field_both_read_as_empty():
test `[ -n "$x" ]`. A field the server left null and one it never sent mean
the same thing to all of them."""
out = sh(r"""
flat=$(scribe_json_flat)
printf '[%s][%s][%s]\n' "$(scribe_json_pick "$flat" '.nothing')" \
"$(scribe_json_pick "$flat" '.nulled')" "$(scribe_json_pick "$flat" '.real')"
flat=$(grimoire_json_flat)
printf '[%s][%s][%s]\n' "$(grimoire_json_pick "$flat" '.nothing')" \
"$(grimoire_json_pick "$flat" '.nulled')" "$(grimoire_json_pick "$flat" '.real')"
""", stdin=json.dumps({"nulled": None, "real": "here"}))
assert out.strip() == "[][][here]"
@@ -220,9 +220,9 @@ def test_an_absent_array_length_is_empty_not_zero():
"""NOT the same answer as 0, and the distinction is load-bearing: the
record nudge fires when the server said "zero notes" and must not fire
when the server said nothing at all."""
out = sh(r"""flat=$(scribe_json_flat)
printf '[%s][%s]\n' "$(scribe_json_len "$flat" '.note_ids')" \
"$(scribe_json_len "$flat" '.missing')" """,
out = sh(r"""flat=$(grimoire_json_flat)
printf '[%s][%s]\n' "$(grimoire_json_len "$flat" '.note_ids')" \
"$(grimoire_json_len "$flat" '.missing')" """,
stdin=json.dumps({"note_ids": []}))
assert out.strip() == "[0][]"
@@ -252,7 +252,7 @@ def test_the_hook_envelope_is_valid_json_carrying_the_exact_context(value):
"""The one JSON document these hooks WRITE. Five hooks emitted it through
`jq -n --arg c`; getting the escaping wrong corrupts every injected context
that contains a Windows path, a regex or a quoted word."""
out = sh('scribe_json_out PreToolUse "$(cat)"', stdin=value)
out = sh('grimoire_json_out PreToolUse "$(cat)"', stdin=value)
parsed = json.loads(out)
assert parsed["hookSpecificOutput"]["hookEventName"] == "PreToolUse"
assert parsed["hookSpecificOutput"]["additionalContext"] == value.rstrip("\n")
@@ -265,15 +265,15 @@ def test_urlenc_percent_encodes_exactly_the_unreserved_set(value):
an accented letter as one and mawk as two, and an encoder built on substr()
would produce a different URL depending on which awk is installed.
Percent-encoding is defined on BYTES."""
got = sh("scribe_urlenc", stdin=value)
got = sh("grimoire_urlenc", stdin=value)
assert got == quote(value, safe="")
def test_urlenc_and_the_envelope_can_fail():
"""A guard that cannot fail is decoration. If the escaper were a
no-op, this pair would be the assertion that noticed."""
assert sh("scribe_urlenc", stdin="a b") != "a b"
raw = sh('scribe_json_out PreToolUse "$(cat)"', stdin='he said "hi"')
assert sh("grimoire_urlenc", stdin="a b") != "a b"
raw = sh('grimoire_json_out PreToolUse "$(cat)"', stdin='he said "hi"')
assert '\\"hi\\"' in raw, raw
@@ -360,7 +360,7 @@ def test_the_reply_is_the_text_after_the_last_action_and_keeps_its_newlines():
facts = _turn([_prompt(), _text("thinking out loud"), _close("t1", 41),
_text("line one\nline two"), _text("line three")])
assert facts["reply"] == "line one\\nline two\\nline three"
decoded = sh("scribe_json_unescape", stdin=facts["reply"])
decoded = sh("grimoire_json_unescape", stdin=facts["reply"])
assert decoded == "line one\nline two\nline three\n"
assert "thinking out loud" not in decoded