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