rename(547): what people read says Grimoire
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 36s

Step 1 of the Scribe -> Grimoire rename: every capitalised "Scribe" in prose
becomes "Grimoire" ("Fabled Scribe" -> "Fabled Grimoire") across the UI,
the PWA manifest and page title, the MCP instructions and tool docstrings,
hook and skill text, docs and README, with the tests that assert on that
prose moving with it. Old migrations keep their wording.

Identifiers are untouched here: the package, plugin name, MCP server key,
hook file names, env vars, the .scribe marker and FabledScribe repo/image
names move in steps 2 and 3, along with prose that quotes them.

The sweep matched "Scribe" only as a whole word, so describe/subscribe and
friends are untouched. The longer name took the MCP instructions one
character over their 1,600 budget; two phrases are shortened to fit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 10:46:37 -04:00
co-authored by Claude Opus 5.5
parent 06be537a18
commit e99f1169f7
142 changed files with 460 additions and 460 deletions
+1 -1
View File
@@ -107,7 +107,7 @@ def _no_reply_shape_log():
"""Stub the reply-shape delivery row.
Autouse for _no_system_labels' reason: every turn's retrieval, every
moment request and every Scribe tool that reaches a shaped moment writes
moment request and every Grimoire tool that reaches a shaped moment writes
one AppLog row, and those paths are tested without a database.
tests/test_reply_shapes.py binds the real function at import time,
before this patch runs.
+2 -2
View File
@@ -346,7 +346,7 @@ def http_sink(reply: bytes = b'{"context":"","note_ids":[]}',
"""A throwaway local HTTP listener for hook end-to-end tests: yields
``(port, seen)`` where ``seen`` collects every GET's parsed query string
(one dict per request, in order). Lets the shell be tested end to end —
the extraction, the encoding, the URL — without a Scribe instance.
the extraction, the encoding, the URL — without a Grimoire instance.
Pass ``reply`` for the body the hook should see.
@@ -482,7 +482,7 @@ async def rule_row(rule_id: int):
return await s.get(Rule, rule_id)
# Software-only vocabulary. Scribe's product text speaks for any work a person
# Software-only vocabulary. Grimoire's product text speaks for any work a person
# drives through an agent — configuration, infrastructure, documents — so a
# catalog or a shipped shape that reads as software-only is wrong on most of
# the installs it ships to. The actions particular to one kind of work belong
+1 -1
View File
@@ -122,7 +122,7 @@ def test_after_write_local_arm_works_without_a_server_and_says_the_server_did_no
out = _run(repo, env, session="s-local")
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
assert "`slug` is already defined in 1 other file(s): c.py" in ctx
assert "Scribe did not answer the prior-art check for `d.py` within 8s" in ctx
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"
+1 -1
View File
@@ -27,7 +27,7 @@ the cause. No signature changed.
WHAT IS PINNED: which edits speak and which stay silent, and that both gates
are load-bearing. NOT pinned: the wording, which is prose.
A FIXTURE REPO, NEVER THIS ONE. Asserting against Scribe's own files would
A FIXTURE REPO, NEVER THIS ONE. Asserting against Grimoire's own files would
make the test a description of today's tree, failing the next time someone
renames something.
"""
+1 -1
View File
@@ -2,7 +2,7 @@
WHY THIS EXISTS
Scribe's record kinds are reached for interchangeably — a note written where
Grimoire's record kinds are reached for interchangeably — a note written where
a task was owed, a rule written where a snippet belonged — and the moment of
choice is the only moment a correction is cheap. The tool docstring IS the
agent-facing contract (product guidance lives there and nowhere else), so a docstring that only documents parameters answers "how do I call
+3 -3
View File
@@ -35,14 +35,14 @@ from tests.helpers import fake_lesson, fake_note, fake_snippet
OLD_MIRROR = {
"name": "debounce",
"language": "javascript",
"locations": [{"repo": "Scribe", "path": "old/place.js", "symbol": "debounce"}],
"locations": [{"repo": "Grimoire", "path": "old/place.js", "symbol": "debounce"}],
"verification": {"status": "ok", "code_sha": "abc", "checked_at": "2026-01-01"},
"provenance": {"commit_sha": "deadbeef"},
}
MOVED_BODY = (
"**Locations:**\n"
"- `Scribe` · `new/place.ts` · `debounce`\n\n"
"- `Grimoire` · `new/place.ts` · `debounce`\n\n"
"```typescript\nexport const debounce = 1;\n```\n"
)
@@ -52,7 +52,7 @@ async def test_a_body_write_moves_the_mirror_with_it():
note = fake_snippet(data=dict(OLD_MIRROR), project_id=None)
await _update(note, body=MOVED_BODY)
assert note.data["locations"] == [
{"repo": "Scribe", "path": "new/place.ts", "symbol": "debounce"}
{"repo": "Grimoire", "path": "new/place.ts", "symbol": "debounce"}
], "the mirror still describes where the snippet used to live"
assert note.data["language"] == "typescript"
+3 -3
View File
@@ -2,7 +2,7 @@
WHY THIS EXISTS
Scribe's guidance to agents reaches a session through five surfaces: the MCP
Grimoire's guidance to agents reaches a session through five surfaces: the MCP
`_INSTRUCTIONS`, the plugin's static session context, the live session context
the server builds, the skills, and tool docstrings. A practice stated in full
on several of them drifts apart, copy by copy.
@@ -320,11 +320,11 @@ TOPICS: tuple[Topic, ...] = (
"belongs to another user", index=("shared:true",)),
Topic("stored processes are followed as written", "docstrings", ("get_process",), "follow the returned body"),
Topic("a project is never guessed", "docstrings", ("never guessing a project",), "never guessing a project"),
Topic("an unbound repo gets a bind hint", "live", ("bind_repo",), "isn't mapped to a scribe project"),
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("say so when scribe's tools are unavailable", "static", ("tools are unavailable",),
Topic("say so when grimoire's tools are unavailable", "static", ("tools are unavailable",),
"rather than silently falling back to local notes"),
)
+14 -14
View File
@@ -57,13 +57,13 @@ async def seeded():
data=compose_data(name=name, language="python", locations=locations),
)
nested = _snippet("nested", [loc(repo="Scribe", path="frontend/src/lib/x.ts", symbol="helper")])
sibling = _snippet("sibling", [loc(repo="Scribe", path="frontend/srcmap.ts", symbol="other")])
# Two locations, deliberately crossed: repo Scribe at src/a.py and repo
# Portal at src/b.py. repo=Scribe + path=src/b.py must NOT match it.
nested = _snippet("nested", [loc(repo="Grimoire", path="frontend/src/lib/x.ts", symbol="helper")])
sibling = _snippet("sibling", [loc(repo="Grimoire", path="frontend/srcmap.ts", symbol="other")])
# Two locations, deliberately crossed: repo Grimoire at src/a.py and repo
# Portal at src/b.py. repo=Grimoire + path=src/b.py must NOT match it.
multi = _snippet(
"multi",
[loc(repo="Scribe", path="src/a.py", symbol="alpha"), loc(repo="Portal", path="src/b.py", symbol="beta")],
[loc(repo="Grimoire", path="src/a.py", symbol="alpha"), loc(repo="Portal", path="src/b.py", symbol="beta")],
)
# No structured location at all — must never satisfy a location filter,
# and must not error the query either.
@@ -101,7 +101,7 @@ async def _ids_for(user_id: int, **parts) -> set[int]:
@pytest.mark.asyncio
async def test_repo_filter_finds_every_snippet_recorded_in_it(seeded):
user_id, ids = seeded
found = await _ids_for(user_id, repo="Scribe")
found = await _ids_for(user_id, repo="Grimoire")
assert found == {ids["nested"], ids["sibling"], ids["multi"]}
assert ids["placeless"] not in found
@@ -124,9 +124,9 @@ async def test_path_prefix_reaches_into_subdirectories(seeded):
@pytest.mark.asyncio
async def test_parts_must_match_within_one_location(seeded):
user_id, ids = seeded
assert await _ids_for(user_id, repo="Scribe", path="src/a.py") == {ids["multi"]}
assert await _ids_for(user_id, repo="Grimoire", path="src/a.py") == {ids["multi"]}
# Same snippet, but repo and path belong to two different entries.
assert await _ids_for(user_id, repo="Scribe", path="src/b.py") == set()
assert await _ids_for(user_id, repo="Grimoire", path="src/b.py") == set()
assert await _ids_for(user_id, repo="Portal", path="src/b.py") == {ids["multi"]}
@@ -155,7 +155,7 @@ async def test_keyword_arm_applies_the_location_filter(seeded):
"scribe.services.embeddings.semantic_search_notes",
AsyncMock(return_value=[]),
):
assert await _ids_for(user_id, q="nested", repo="Scribe") == {ids["nested"]}
assert await _ids_for(user_id, q="nested", repo="Grimoire") == {ids["nested"]}
# Matches the query but not the place.
assert await _ids_for(user_id, q="nested", repo="Portal") == set()
@@ -190,7 +190,7 @@ async def test_semantic_arm_applies_the_location_filter(seeded):
assert await _ids_for(user_id, q="zzzznomatch", path="frontend/src") == {
ids["nested"],
}
assert await _ids_for(user_id, q="zzzznomatch", repo="Scribe", path="src/b.py") == set()
assert await _ids_for(user_id, q="zzzznomatch", repo="Grimoire", path="src/b.py") == set()
@pytest.mark.asyncio
@@ -212,13 +212,13 @@ async def test_sql_and_python_dialects_agree_on_the_same_rows(seeded):
by_id = {n.id: n.data for n in rows}
cases = [
{"repo": "Scribe"},
{"repo": "Grimoire"},
{"path": "frontend/src"},
{"path": "frontend/src/lib/x.ts"},
{"repo": "Scribe", "path": "src/a.py"},
{"repo": "Scribe", "path": "src/b.py"},
{"repo": "Grimoire", "path": "src/a.py"},
{"repo": "Grimoire", "path": "src/b.py"},
{"symbol": "beta"},
{"repo": "Scribe", "path": "frontend", "symbol": "helper"},
{"repo": "Grimoire", "path": "frontend", "symbol": "helper"},
]
for case in cases:
sql_ids = await _ids_for(user_id, **case)
+1 -1
View File
@@ -62,7 +62,7 @@ async def test_unknown_tool_keeps_the_upstream_error():
#
# SDK 2.x passes on only a ToolError's text; anything else is wrapped as
# "Error executing tool X" and its message is kept on the server. ValueError is
# how every Scribe tool refuses, so without this every refusal arrives bare.
# how every Grimoire tool refuses, so without this every refusal arrives bare.
def _raising_server() -> StrictArgsMCPServer:
+2 -2
View File
@@ -43,7 +43,7 @@ async def test_creating_with_a_parent_passes_it_through():
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.create_design_system = AsyncMock(return_value=_fake_design_system())
from scribe.mcp.tools.design_systems import create_design_system
await create_design_system(title="Scribe", parent_id=4)
await create_design_system(title="Grimoire", parent_id=4)
assert svc.create_design_system.await_args.kwargs["parent_id"] == 4
@@ -53,7 +53,7 @@ async def test_create_raises_when_the_parent_is_not_writable():
svc.create_design_system = AsyncMock(return_value=None)
from scribe.mcp.tools.design_systems import create_design_system
with pytest.raises(ValueError):
await create_design_system(title="Scribe", parent_id=4)
await create_design_system(title="Grimoire", parent_id=4)
# --- the three-state parent -------------------------------------------------
+2 -2
View File
@@ -8,7 +8,7 @@ tests/test_integration_rule_moments.py. These pin what is decided above it:
fresh ones are recorded, each with its moment;
- every door fails open;
- the plugin is told which tools can reach anything, and nothing more;
- every Scribe tool the shipped mappings name carries the attach, and the
- every Grimoire tool the shipped mappings name carries the attach, and the
hook and the route agree on every name between them.
"""
from __future__ import annotations
@@ -352,7 +352,7 @@ def test_an_install_with_nothing_mounted_sends_one_request_per_window(tmp_path):
assert len(seen) == 1
# ── the MCP door: every shipped Scribe action carries the attach ────────
# ── the MCP door: every shipped Grimoire action carries the attach ────────
def _attached_names(path: Path) -> dict[str, set[str]]:
+1 -1
View File
@@ -1,6 +1,6 @@
"""One definition of what SHIPS in the plugin, and the drift guards on it.
WHAT THIS IS ABOUT. Scribe publishes two artifacts. `plugin/` is not in the Docker image — installs fetch it from this
WHAT THIS IS ABOUT. Grimoire publishes two artifacts. `plugin/` is not in the Docker image — installs fetch it from this
repo through `.claude-plugin/marketplace.json`, so **a push IS the release**,
with no build step in between. That makes "which files reach an install?" a
question with real consequences, and it can be answered wrong two ways:
+2 -2
View File
@@ -3,7 +3,7 @@
The mechanism this pins was read out of the installed Claude Code build, not
out of the documentation, which describes a different one. Three facts decide
whether the hook works at all, and all three are properties of what the shell
writes rather than of anything Scribe runs:
writes rather than of anything Grimoire runs:
* **Exit 0.** The handler keeps a hook's stdout only when it `succeeded`,
which is `status === 0`. A non-zero exit sends the same bytes down the
@@ -111,7 +111,7 @@ def test_the_instructions_name_what_has_to_survive():
in the words a summarizer can act on."""
said = _run(EVENT).stdout.lower()
assert "id" in said and "title" in said
for anchor in ("in progress", "scribe", "not yet recorded"):
for anchor in ("in progress", "grimoire", "not yet recorded"):
assert anchor in said, f"the instruction no longer mentions {anchor!r}"
+1 -1
View File
@@ -6,7 +6,7 @@ WHY TWO DOORS AT ALL
propose, offer three answers, and wait. That is right for a rule — the person
a rule binds should have agreed to be bound.
A preference inverts it: it is a rule Scribe can and should update during
A preference inverts it: it is a rule Grimoire can and should update during
use. A preference that asks every time never drifts, and drifting is the whole feature. Reaching one through
`create_rule(kind=..., when_to_apply="when the moment this fixture stands in for arises")` would mean reading it through the gate's prose, and
the caller would hesitate over exactly the act this kind exists to make
+2 -2
View File
@@ -5,12 +5,12 @@ not. `#3244` reads as complete to the writer and as homework to the reader,
who has to look it up to know what their own conversation is about. Nobody
knows what a note, task or milestone is by its id number.
PRODUCT, NOT A RULE. Every Scribe user hits this, so the fix is in
PRODUCT, NOT A RULE. Every Grimoire user hits this, so the fix is in
the surfaces the product ships — the skill that shapes how an agent writes,
and the tool responses that hand a record back. A per-instance rule would fix
it for one operator and leave the behaviour wrong for everyone else.
Scribe's duplicate gate already had the right shape — `id 412: "debounce
Grimoire's duplicate gate already had the right shape — `id 412: "debounce
helper"` — which is why these assert the CONVENTION reaches the other
surfaces rather than inventing a new one.
"""
+1 -1
View File
@@ -59,7 +59,7 @@ def test_placement_comes_from_the_record():
def test_the_shipped_shapes_assume_no_particular_domain():
"""Scribe is domain-neutral: a home-infrastructure or writing project reads
"""Grimoire is domain-neutral: a home-infrastructure or writing project reads
these too. Software-specific evidence belongs in an operator's own
preferences, never in the product default."""
text = _text()
+1 -1
View File
@@ -40,7 +40,7 @@ _SECRETS = [
("vendor-prefixed token in a shell assignment",
"TOK=flt_AAAABBBBCCCCDDDDEEEEFFFF\npython3 - <<'PY'",
"flt_AAAABBBBCCCCDDDDEEEEFFFF"),
("a Scribe fmcp_ key in an auth header",
("a Grimoire fmcp_ key in an auth header",
"curl -H 'Authorization: Bearer fmcp_ZZZZYYYYXXXXWWWWVVVV' https://x",
"fmcp_ZZZZYYYYXXXXWWWWVVVV"),
("a forge token in an export",
+1 -1
View File
@@ -175,7 +175,7 @@ async def test_injected_menu_labels_the_record_kind():
# Still title-first: the marker is metadata, not an excuse to carry bodies.
assert "body" not in out["context"]
# The header can't claim they're all notes when the markers say otherwise.
assert "Scribe records" in lines[0]
assert "Grimoire records" in lines[0]
def test_list_shaped_services_use_the_shared_visibility_clause():
+2 -2
View File
@@ -11,7 +11,7 @@ than a reimplementation of it. What they pin:
* the marker is found from a subdirectory, the way git finds its root;
* a bare integer works, because that is what a person writes by hand;
* the marker BEATS a git remote, since someone put the file there on purpose;
* an `instance` naming a different Scribe is refused — the case the field
* an `instance` naming a different Grimoire is refused — the case the field
exists for. A project id means nothing on its own: id 2 is a different
project on every instance, so a marker that travels would otherwise scope
the session to the wrong project, confidently and without a word.
@@ -73,7 +73,7 @@ def test_the_marker_is_found_from_a_subdirectory(tmp_path):
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 Scribe is."""
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"
+1 -1
View File
@@ -115,7 +115,7 @@ def test_a_repo_binding_carries_the_branch_its_ledger_follows():
"""Without `ref` a restored binding silently falls back to the
default branch and the shape ledger starts accounting for a different
tree — a wrong answer that looks like a working one."""
b = SimpleNamespace(user_id=7, project_id=2, repo_key="Scribe", ref="dev")
b = SimpleNamespace(user_id=7, project_id=2, repo_key="Grimoire", ref="dev")
[row] = backup._repo_binding_rows([b])
assert row["ref"] == "dev"
+2 -2
View File
@@ -126,7 +126,7 @@ def test_checking_a_folder_leaves_nothing_behind(tmp_path):
(tmp_path / "scribe-20261014T020000Z.tar").write_bytes(b"x")
config = BackupConfig(folder_path=str(tmp_path))
said = asyncio.run(check_destination(config, None))
assert "holds 1 Scribe backup" in said
assert "holds 1 Grimoire backup" in said
assert [p.name for p in tmp_path.iterdir()] == ["scribe-20261014T020000Z.tar"]
@@ -176,4 +176,4 @@ def test_checking_s3_only_lists():
said = asyncio.run(check_destination(S3, "secret", transport=httpx.MockTransport(handler)))
assert methods == ["GET"]
assert "holds 1 Scribe backup" in said
assert "holds 1 Grimoire backup" in said
+3 -3
View File
@@ -241,7 +241,7 @@ async def test_same_location_is_a_duplicate_however_it_is_described():
7, "primaryButton — something else entirely", body="x" * 400,
project_id=2, is_task=False, note_type="snippet",
code=".btn-primary { color: red; }",
locations=[{"repo": "Scribe", "path": "a/b.css", "symbol": ".btn-primary"}],
locations=[{"repo": "Grimoire", "path": "a/b.css", "symbol": ".btn-primary"}],
)
assert dup is not None
assert dup.reason == "location"
@@ -261,7 +261,7 @@ async def test_identical_code_is_a_duplicate_at_a_different_location():
dup = await find_duplicate_note(
7, "unionFind", body="x" * 400, project_id=2, is_task=False,
note_type="snippet", code="def f():\n return 1",
locations=[{"repo": "Scribe", "path": "z.py", "symbol": "f"}],
locations=[{"repo": "Grimoire", "path": "z.py", "symbol": "f"}],
)
assert dup is not None
assert dup.reason == "code"
@@ -279,7 +279,7 @@ async def test_a_location_without_a_symbol_is_not_an_identity():
dup = await find_duplicate_note(
7, "Some recipe", body="x" * 400, project_id=2, is_task=False,
note_type="snippet", code="",
locations=[{"repo": "Scribe", "path": "a/b.css", "symbol": ""}],
locations=[{"repo": "Grimoire", "path": "a/b.css", "symbol": ""}],
)
assert dup is None
# Exactly one query — the title check. With no symbol and no code there is
+1 -1
View File
@@ -21,7 +21,7 @@ async def test_create_with_a_parent_is_denied_without_write_on_that_parent():
with patch("scribe.services.design_systems.access") as acc:
acc.can_write_design_system = AsyncMock(return_value=False)
from scribe.services.design_systems import create_design_system
result = await create_design_system(user_id=1, title="Scribe", parent_id=7)
result = await create_design_system(user_id=1, title="Grimoire", parent_id=7)
assert result is None
+3 -3
View File
@@ -126,7 +126,7 @@ async def test_build_session_context_includes_project_when_scoped():
# No design system on the project -> no design block at all. An install with
# none is the ordinary case, not a degraded one.
assert "## Design system" not in out["context"]
# Live state only: how to work with Scribe is the
# 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.
assert 'content_type="rule"' not in out["context"]
assert "Reflex:" not in out["context"]
@@ -249,7 +249,7 @@ async def test_a_project_id_that_does_not_resolve_is_reported_not_dropped():
assert "list_projects" in ctx
# Not mistaken for the repo case, which has a different remedy.
assert "## Repository not yet bound" not in ctx
assert "No Scribe project is bound" not in ctx
assert "No Grimoire project is bound" not in ctx
@pytest.mark.asyncio
@@ -380,7 +380,7 @@ async def _autoinject(main_hits, reuse_hits, cfg=None, lesson_hits=None):
@pytest.mark.asyncio
async def test_a_snippet_takes_the_last_slot_when_none_won_on_score():
"""The measured failure: a prompt asking for a helper returned three project
records ABOUT building the retrieval system, and zero snippets. Scribe's own
records ABOUT building the retrieval system, and zero snippets. Grimoire's own
records are about software work, so they share vocabulary with any coding
prompt while answering none of them."""
main = [(0.66, fake_note(id=1, title="Step 3: title-first auto-inject", user_id=1)),
+1 -1
View File
@@ -42,7 +42,7 @@ async def test_a_number_just_above_the_highest_id_is_a_guess():
async def test_existing_ids_and_far_numbers_are_not_guesses():
"""Below the max is a real record; far above it is not a Scribe id at all
"""Below the max is a real record; far above it is not a Grimoire id at all
(a PR, a forge issue) — neither may be refused."""
far = 100 + GUESS_WINDOW + 1
with patch.object(record_refs, "_max_note_id", AsyncMock(return_value=100)):
+3 -3
View File
@@ -4,7 +4,7 @@ A record another user owns is that person's suggestion, not the operator's
settled practice — and the two are indistinguishable unless every surface that
hands one over says whose it is. These tests hold that line at the labelling
helpers and at the process→skill manifest, which is the most consequential
passive surface Scribe has (each entry becomes an auto-surfacing skill file).
passive surface Grimoire has (each entry becomes an auto-surfacing skill file).
"""
from unittest.mock import AsyncMock, MagicMock, patch
@@ -94,11 +94,11 @@ async def test_shared_process_skill_stub_never_claims_to_be_the_operators():
own, shared = out["processes"]
assert "operator's saved Scribe process" in own["description"]
assert "operator's saved Grimoire process" in own["description"]
assert "shared" not in own
assert shared["shared"] is True
assert shared["owner"] == "alex"
assert "operator's saved Scribe process" not in shared["description"]
assert "operator's saved Grimoire process" not in shared["description"]
assert "alex" in shared["description"]
assert "go-ahead" in shared["description"]
+1 -1
View File
@@ -1,6 +1,6 @@
"""Tests for the drift check — does a snippet still match its source?
The check itself runs agent-side (Scribe has no checkout), so what's testable
The check itself runs agent-side (Grimoire has no checkout), so what's testable
here is everything around it: the verdict's shape, the rule that a verdict
expires when the code it blessed is edited away, and the two dialects of the
filter predicate — SQL for the browse/keyword arms, Python for the semantic
+25 -25
View File
@@ -31,9 +31,9 @@ def _data(*locations: dict) -> dict:
def test_location_parts_keeps_only_what_was_asked_for():
assert location_parts() == {}
assert location_parts(repo="Scribe") == {"repo": "Scribe"}
assert location_parts(repo=" Scribe ", path=" src/x.py ") == {
"repo": "Scribe", "path": "src/x.py",
assert location_parts(repo="Grimoire") == {"repo": "Grimoire"}
assert location_parts(repo=" Grimoire ", path=" src/x.py ") == {
"repo": "Grimoire", "path": "src/x.py",
}
@@ -54,16 +54,16 @@ def test_no_parts_matches_everything():
def test_matches_exact_repo_path_and_symbol():
data = _data({"repo": "Scribe", "path": "src/scribe/x.py", "symbol": "helper"})
assert location_matches(data, {"repo": "Scribe"})
assert location_matches(data, {"repo": "scribe"}) # repo names: case never distinguishes
data = _data({"repo": "Grimoire", "path": "src/scribe/x.py", "symbol": "helper"})
assert location_matches(data, {"repo": "Grimoire"})
assert location_matches(data, {"repo": "grimoire"}) # repo names: case never distinguishes
assert location_matches(data, {"path": "src/scribe/x.py"})
assert location_matches(data, {"symbol": "helper"})
assert location_matches(data, {"repo": "Scribe", "symbol": "helper"})
assert location_matches(data, {"repo": "Grimoire", "symbol": "helper"})
def test_path_matches_as_a_directory_prefix():
data = _data({"repo": "Scribe", "path": "frontend/src/lib/x.ts", "symbol": ""})
data = _data({"repo": "Grimoire", "path": "frontend/src/lib/x.ts", "symbol": ""})
assert location_matches(data, {"path": "frontend/src"})
assert location_matches(data, {"path": "frontend/src/"})
assert location_matches(data, {"path": "frontend/src/lib/x.ts"})
@@ -71,39 +71,39 @@ def test_path_matches_as_a_directory_prefix():
def test_path_prefix_stops_at_a_directory_boundary():
"""`frontend/src` must not match `frontend/srcmap.ts` — a sibling, not a child."""
data = _data({"repo": "Scribe", "path": "frontend/srcmap.ts", "symbol": ""})
data = _data({"repo": "Grimoire", "path": "frontend/srcmap.ts", "symbol": ""})
assert not location_matches(data, {"path": "frontend/src"})
def test_parts_must_all_match_the_same_location():
"""Repo A in one entry and path B in another is not "recorded at A/B"."""
data = _data(
{"repo": "Scribe", "path": "src/a.py", "symbol": ""},
{"repo": "Grimoire", "path": "src/a.py", "symbol": ""},
{"repo": "Portal", "path": "src/b.py", "symbol": ""},
)
assert location_matches(data, {"repo": "Scribe", "path": "src/a.py"})
assert not location_matches(data, {"repo": "Scribe", "path": "src/b.py"})
assert location_matches(data, {"repo": "Grimoire", "path": "src/a.py"})
assert not location_matches(data, {"repo": "Grimoire", "path": "src/b.py"})
def test_missing_or_empty_data_never_matches():
assert not location_matches(None, {"repo": "Scribe"})
assert not location_matches({}, {"repo": "Scribe"})
assert not location_matches({"locations": []}, {"repo": "Scribe"})
assert not location_matches({"name": "x"}, {"repo": "Scribe"})
assert not location_matches(None, {"repo": "Grimoire"})
assert not location_matches({}, {"repo": "Grimoire"})
assert not location_matches({"locations": []}, {"repo": "Grimoire"})
assert not location_matches({"name": "x"}, {"repo": "Grimoire"})
def test_blank_recorded_part_does_not_match_a_requested_one():
data = _data({"repo": "Scribe", "path": "", "symbol": ""})
assert not location_matches(data, {"repo": "Scribe", "symbol": "helper"})
data = _data({"repo": "Grimoire", "path": "", "symbol": ""})
assert not location_matches(data, {"repo": "Grimoire", "symbol": "helper"})
# --- location_jsonpath --------------------------------------------------------
def test_jsonpath_filters_within_one_locations_entry():
expr = location_jsonpath({"repo": "Scribe", "symbol": "helper"})
expr = location_jsonpath({"repo": "Grimoire", "symbol": "helper"})
assert expr.startswith("$.locations[*] ? (")
# repo: anchored, case-insensitive — mirrors location_matches.
assert '@.repo like_regex "^Scribe$" flag "i"' in expr
assert '@.repo like_regex "^Grimoire$" flag "i"' in expr
assert '@.symbol == "helper"' in expr
assert " && " in expr
@@ -144,7 +144,7 @@ def test_location_clause_renders_a_jsonpath_containment():
from scribe.services.knowledge import _location_clause
sql = str(_location_clause({"repo": "Scribe"}).compile(dialect=postgresql.dialect()))
sql = str(_location_clause({"repo": "Grimoire"}).compile(dialect=postgresql.dialect()))
assert "@?" in sql
assert "data" in sql
@@ -178,9 +178,9 @@ async def test_mcp_tool_passes_locations_through_to_the_service():
patch("scribe.services.access.label_shared_items",
new=AsyncMock(return_value=[])):
from scribe.mcp.tools.snippets import list_snippets
await list_snippets(repo="Scribe", path="src/scribe", symbol="helper")
await list_snippets(repo="Grimoire", path="src/scribe", symbol="helper")
kwargs = m.await_args.kwargs
assert kwargs["repo"] == "Scribe"
assert kwargs["repo"] == "Grimoire"
assert kwargs["path"] == "src/scribe"
assert kwargs["symbol"] == "helper"
@@ -201,8 +201,8 @@ async def test_service_builds_the_filter_from_the_parts_given():
with patch("scribe.services.knowledge.query_knowledge",
new=AsyncMock(return_value=([], 0))) as m:
from scribe.services.snippets import list_snippets
await list_snippets(1, q="debounce", repo="Scribe", path=" src/ ")
await list_snippets(1, q="debounce", repo="Grimoire", path=" src/ ")
kwargs = m.await_args.kwargs
assert kwargs["locations"] == {"repo": "Scribe", "path": "src/"}
assert kwargs["locations"] == {"repo": "Grimoire", "path": "src/"}
# Search and place compose — the location filter must not drop the query.
assert kwargs["q"] == "debounce"
+2 -2
View File
@@ -104,7 +104,7 @@ async def test_provenance_lives_and_dies_with_the_code_end_to_end(user_id):
note = await svc.create_snippet(
user_id, name="prov_helper", code="def prov_helper():\n return 1\n",
language="python", repo="Scribe", path="src/x.py", symbol="prov_helper",
language="python", repo="Grimoire", path="src/x.py", symbol="prov_helper",
commit_sha=SHA_A,
)
fields, view = await _fresh(user_id, note.id)
@@ -140,7 +140,7 @@ async def test_verification_stamps_the_commit_and_ok_refreshes_provenance(user_i
note = await svc.create_snippet(
user_id, name="prov_verify", code="def prov_verify():\n return 1\n",
language="python", repo="Scribe", path="src/y.py", symbol="prov_verify",
language="python", repo="Grimoire", path="src/y.py", symbol="prov_verify",
commit_sha=SHA_A,
)
+2 -2
View File
@@ -55,7 +55,7 @@ def test_every_score_the_hook_can_write_reads_back(score) -> None:
def test_the_real_reasons_from_the_poisoned_ledgers_parse() -> None:
"""Verbatim from Portal and Scribe rows, so the parser is pinned to text
"""Verbatim from Portal and Grimoire rows, so the parser is pinned to text
that actually exists in the database rather than to text it generates."""
assert stamp_score("hook: pulled #3283; payload resembles it (0.69)") == 0.69
assert stamp_score("hook: pulled #3283; payload resembles it (0.77)") == 0.77
@@ -93,7 +93,7 @@ def test_a_stamp_at_or_above_the_floor_is_not_listed() -> None:
@pytest.mark.parametrize("by", ["agent", "audit", "import", "mechanical"])
def test_a_judgment_is_never_weak_however_it_would_have_scored(by) -> None:
"""The measured case: 302 of Scribe's 334 rows under one canon were
"""The measured case: 302 of Grimoire's 334 rows under one canon were
`audit`-classified with no score at all. Listing those as weak would
invite an agent to withdraw the only judgments in the ledger that were
ever actually made by one."""
+1 -1
View File
@@ -1,6 +1,6 @@
"""task_document — the document a TASK is embedded as, work logs included.
A work log is the richest record Scribe holds of *why* something is the way it
A work log is the richest record Grimoire holds of *why* something is the way it
is: prose written during the work, saying what was tried and ruled out. A log
that is readable but not embedded is not findable, so "has anyone tried this
approach?" — the question a log answers — cannot reach it.
+1 -1
View File
@@ -10,7 +10,7 @@ almost nothing else does".
So the guidance is not decoration on this feature, it IS the feature's
precondition, and product guidance lives in the tool docstrings and the skill
rather than in a Scribe rule. That makes it exactly the kind of prose a later
rather than in a Grimoire rule. That makes it exactly the kind of prose a later
docstring tidy-up deletes without noticing what it was for.
WHAT THIS PINS, AND WHAT IT DOES NOT
+4 -4
View File
@@ -1055,7 +1055,7 @@ def test_hook_stays_a_get_so_a_read_scoped_key_works():
def test_hook_exits_silently_when_unconfigured():
"""An install with no Scribe URL/token must produce no output at all."""
"""An install with no Grimoire URL/token must produce no output at all."""
out = subprocess.run(
["bash", str(HOOK)],
input=json.dumps({
@@ -1137,7 +1137,7 @@ def _write_event(repo, session="s-nudge"):
def test_hook_nudges_recording_when_copies_exist_but_nothing_is_recorded(tmp_path):
"""The local arm proves duplication; when Scribe ANSWERS that it has
"""The local arm proves duplication; when Grimoire ANSWERS that it has
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):
@@ -1172,7 +1172,7 @@ def test_hook_says_when_scribe_did_not_answer_once_per_outage(tmp_path):
assert out.returncode == 0
ctx = json.loads(out.stdout)["hookSpecificOutput"]["additionalContext"]
assert "already defined" in ctx
assert "Scribe did not answer the prior-art check for `b.py` within 5s" in ctx
assert "Grimoire did not answer the prior-art check for `b.py` within 5s" in ctx
assert "UNCHECKED" in ctx and "list_shapes" in ctx
assert "None of those existing copies is recorded" not in ctx
assert marker.is_file()
@@ -1198,7 +1198,7 @@ def test_hook_says_when_scribe_did_not_answer_once_per_outage(tmp_path):
out = subprocess.run(["bash", str(HOOK)], input=_write_event(repo, "s-out-2"),
capture_output=True, text=True, env=env)
ctx = json.loads(out.stdout)["hookSpecificOutput"]["additionalContext"]
assert ctx.startswith("> Scribe did not answer")
assert ctx.startswith("> Grimoire did not answer")
def test_hook_stays_quiet_about_recording_when_nothing_is_duplicated(tmp_path):