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