rename(547): the server is grimoire — package, image, DB defaults, backups
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s

Step 2 of the Scribe -> Grimoire rename.

- src/scribe -> src/grimoire, and every import, patch target, module path,
  hypercorn entry point and `python -m grimoire.restore` with it; pyproject,
  uv.lock and the frontend package are named grimoire.
- Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service
  runs as grimoire / grimoire_test.
- Compose files default the DB user, database and (prod) volume to grimoire,
  each overridable from .env, so an install created as Scribe keeps its data
  by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME.
- Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar.
  Archives named scribe-* with the scribe-backup-tar manifest still restore
  and are still counted at the destination (folder and S3), with tests.
- Exports, the MCP auth realm, the ASGI scope key and the persistent logger
  carry the new name.

Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1",
because every stored secret was derived under it.

Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is
step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 10:50:45 -04:00
co-authored by Claude Opus 5.5
parent e99f1169f7
commit 0f5ef0be63
447 changed files with 3611 additions and 3558 deletions
+53 -53
View File
@@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.services.dedup import (
from grimoire.services.dedup import (
PLAN_MATCH_DEFAULT_THRESHOLD,
DuplicateMatch,
duplicate_response,
@@ -20,7 +20,7 @@ from tests.helpers import fake_note, make_mock_session, session_returning
@pytest.mark.asyncio
async def test_title_exact_match_returns_title_duplicate():
note = fake_note(id=10, title="Setup CI")
with patch("scribe.services.dedup.async_session",
with patch("grimoire.services.dedup.async_session",
return_value=session_returning(note)):
# whitespace/case differences are normalized away
dup = await find_duplicate_note(7, " setup ci ", project_id=2, is_task=True)
@@ -33,9 +33,9 @@ async def test_title_exact_match_returns_title_duplicate():
@pytest.mark.asyncio
async def test_short_body_skips_semantic_check():
sem = AsyncMock()
with patch("scribe.services.dedup.async_session",
with patch("grimoire.services.dedup.async_session",
return_value=session_returning(None)), \
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
patch("grimoire.services.dedup.embeddings_svc.semantic_search_notes", sem):
dup = await find_duplicate_note(7, "Unique", body="too short", project_id=2)
assert dup is None
sem.assert_not_called() # body under _MIN_BODY_FOR_SEMANTIC
@@ -46,9 +46,9 @@ async def test_semantic_match_when_body_substantial():
# A note blocks only in the copy band.
hit = fake_note(id=20, title="Existing", note_type="note")
sem = AsyncMock(return_value=[(0.99, hit)])
with patch("scribe.services.dedup.async_session",
with patch("grimoire.services.dedup.async_session",
return_value=session_returning(None)), \
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
patch("grimoire.services.dedup.embeddings_svc.semantic_search_notes", sem):
dup = await find_duplicate_note(
7, "Title", body="x" * 250, project_id=2, is_task=False, note_type="note",
)
@@ -63,14 +63,14 @@ async def test_a_near_note_is_surfaced_not_blocked():
"""Sibling notes — the next dev-log, another part of one design — measure
0.90–0.98, so a match there is shown for the session to judge
instead of refusing the write."""
from scribe.services.dedup import _NOTE_OVERLAP_FLOOR
from grimoire.services.dedup import _NOTE_OVERLAP_FLOOR
hit = fake_note(id=20, title="Dev-log day 3", note_type="note")
sem = AsyncMock(return_value=[(0.93, hit)])
overlaps: list = []
with patch("scribe.services.dedup.async_session",
with patch("grimoire.services.dedup.async_session",
return_value=session_returning(None)), \
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
patch("grimoire.services.dedup.embeddings_svc.semantic_search_notes", sem):
dup = await find_duplicate_note(
7, "Dev-log day 4", body="x" * 250, project_id=2, is_task=False,
note_type="note", overlaps=overlaps,
@@ -89,9 +89,9 @@ async def test_a_record_matching_in_two_chunks_is_one_overlap():
hit = fake_note(id=31, title="Design part one", note_type="note")
sem = AsyncMock(return_value=[(0.9, hit)])
overlaps: list = []
with patch("scribe.services.dedup.async_session",
with patch("grimoire.services.dedup.async_session",
return_value=session_returning(None)), \
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
patch("grimoire.services.dedup.embeddings_svc.semantic_search_notes", sem):
await find_duplicate_note(
7, "Design part two", body=body, project_id=2, note_type="note",
overlaps=overlaps,
@@ -103,18 +103,18 @@ async def test_a_record_matching_in_two_chunks_is_one_overlap():
async def test_without_an_overlaps_list_the_gate_asks_at_the_copy_band():
"""A caller that only wants the block (create_process) does not pay for
a wider search it will not read."""
from scribe.services.dedup import _NOTE_COPY_THRESHOLD
from grimoire.services.dedup import _NOTE_COPY_THRESHOLD
sem = AsyncMock(return_value=[])
with patch("scribe.services.dedup.async_session",
with patch("grimoire.services.dedup.async_session",
return_value=session_returning(None)), \
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
patch("grimoire.services.dedup.embeddings_svc.semantic_search_notes", sem):
await find_duplicate_note(7, "T", body="x" * 250, note_type="note")
assert sem.await_args.kwargs["threshold"] == _NOTE_COPY_THRESHOLD
def test_the_overlap_reply_leaves_the_judgement_to_the_reader():
from scribe.services.dedup import NoteOverlap, note_overlap_response
from grimoire.services.dedup import NoteOverlap, note_overlap_response
assert note_overlap_response([], "note") == {}
out = note_overlap_response([NoteOverlap(9, "Dev-log day 3", 0.93)], "task")
@@ -124,7 +124,7 @@ def test_the_overlap_reply_leaves_the_judgement_to_the_reader():
def test_a_batch_overlap_names_its_record():
from scribe.services.dedup import NoteOverlap, batch_overlap_response
from grimoire.services.dedup import NoteOverlap, batch_overlap_response
assert batch_overlap_response({}) == {}
out = batch_overlap_response({2: [NoteOverlap(9, "X", 0.9)]})
@@ -141,16 +141,16 @@ async def test_gate_catches_a_duplicate_hiding_in_a_later_chunk():
"a real paragraph of content for the chunker to keep. ") * 4
body = "\n\n".join(f"## Topic {i}\n\n{para} (t{i})" for i in range(8))
from scribe.services.embeddings import chunk_document
from grimoire.services.embeddings import chunk_document
n_chunks = len(chunk_document("Title", body))
assert n_chunks > 1, "test body must actually chunk"
hit = fake_note(id=30, title="The existing decision", note_type="note")
# Every chunk misses except the LAST one the gate will ask about.
sem = AsyncMock(side_effect=[[] for _ in range(n_chunks - 1)] + [[(0.99, hit)]])
with patch("scribe.services.dedup.async_session",
with patch("grimoire.services.dedup.async_session",
return_value=session_returning(None)), \
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
patch("grimoire.services.dedup.embeddings_svc.semantic_search_notes", sem):
dup = await find_duplicate_note(
7, "Title", body=body, project_id=2, is_task=False, note_type="note",
)
@@ -162,9 +162,9 @@ async def test_gate_catches_a_duplicate_hiding_in_a_later_chunk():
async def test_semantic_match_of_other_note_type_is_ignored():
other = fake_note(id=21, title="X", note_type="process")
sem = AsyncMock(return_value=[(0.97, other)])
with patch("scribe.services.dedup.async_session",
with patch("grimoire.services.dedup.async_session",
return_value=session_returning(None)), \
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
patch("grimoire.services.dedup.embeddings_svc.semantic_search_notes", sem):
dup = await find_duplicate_note(7, "Title", body="x" * 250, note_type="note")
assert dup is None # type mismatch must not block
@@ -172,7 +172,7 @@ async def test_semantic_match_of_other_note_type_is_ignored():
@pytest.mark.asyncio
async def test_rule_title_match_in_topic():
rule = fake_note(id=47, title="Honor the multi-user sharing ACL")
with patch("scribe.services.dedup.async_session",
with patch("grimoire.services.dedup.async_session",
return_value=session_returning(rule)):
dup = await find_duplicate_rule(
"honor the multi-user sharing acl", topic_id=7,
@@ -186,7 +186,7 @@ async def test_rule_title_match_in_topic():
async def test_rule_requires_a_scope():
# No topic_id and no project_id → nothing to scope to → no match, no query.
sess = AsyncMock()
with patch("scribe.services.dedup.async_session", return_value=sess):
with patch("grimoire.services.dedup.async_session", return_value=sess):
dup = await find_duplicate_rule("anything")
assert dup is None
sess.__aenter__.assert_not_called()
@@ -234,9 +234,9 @@ async def test_same_location_is_a_duplicate_however_it_is_described():
repo·path·symbol was created because the prose around it differed."""
existing = fake_note(id=30, title=".btn-primary — a page's main action", note_type="snippet")
sem = AsyncMock()
with patch("scribe.services.dedup.async_session",
with patch("grimoire.services.dedup.async_session",
return_value=_session_sequence([None, existing])), \
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
patch("grimoire.services.dedup.embeddings_svc.semantic_search_notes", sem):
dup = await find_duplicate_note(
7, "primaryButton — something else entirely", body="x" * 400,
project_id=2, is_task=False, note_type="snippet",
@@ -254,9 +254,9 @@ async def test_same_location_is_a_duplicate_however_it_is_described():
@pytest.mark.asyncio
async def test_identical_code_is_a_duplicate_at_a_different_location():
existing = fake_note(id=31, title="group_pairs", note_type="snippet")
with patch("scribe.services.dedup.async_session",
with patch("grimoire.services.dedup.async_session",
return_value=_session_sequence([None, None, existing])), \
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes",
patch("grimoire.services.dedup.embeddings_svc.semantic_search_notes",
AsyncMock(return_value=[])):
dup = await find_duplicate_note(
7, "unionFind", body="x" * 400, project_id=2, is_task=False,
@@ -274,8 +274,8 @@ async def test_a_location_without_a_symbol_is_not_an_identity():
the button recipes form."""
session = _session_sequence([None])
sem = AsyncMock(return_value=[])
with patch("scribe.services.dedup.async_session", return_value=session), \
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
with patch("grimoire.services.dedup.async_session", return_value=session), \
patch("grimoire.services.dedup.embeddings_svc.semantic_search_notes", sem):
dup = await find_duplicate_note(
7, "Some recipe", body="x" * 400, project_id=2, is_task=False,
note_type="snippet", code="",
@@ -292,14 +292,14 @@ async def test_a_location_without_a_symbol_is_not_an_identity():
async def test_snippets_use_the_raised_semantic_threshold():
"""Variants of one component legitimately reach 0.92. The semantic arm has
to sit above that band or it refuses the corpus it exists to protect."""
from scribe.services.dedup import (
from grimoire.services.dedup import (
_SEMANTIC_THRESHOLD,
_SNIPPET_SEMANTIC_THRESHOLD,
)
sem = AsyncMock(return_value=[])
with patch("scribe.services.dedup.async_session",
with patch("grimoire.services.dedup.async_session",
return_value=_session_sequence([None, None, None])), \
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
patch("grimoire.services.dedup.embeddings_svc.semantic_search_notes", sem):
await find_duplicate_note(
7, "A recipe", body="x" * 400, project_id=2, is_task=False,
note_type="snippet", code="x",
@@ -317,7 +317,7 @@ async def test_snippets_use_the_raised_semantic_threshold():
def test_sibling_variants_are_not_reported_as_merge_candidates():
"""The measured false POSITIVE: eight button recipes, every direct pair over
the floor, proposed as ONE merge set."""
from scribe.services.dedup import _drop_sibling_pairs
from grimoire.services.dedup import _drop_sibling_pairs
records = {
1: {"locations": [{"repo": "S", "path": "c.css", "symbol": ".btn-primary"}],
@@ -332,7 +332,7 @@ def test_identical_code_still_reports_even_with_different_symbols():
"""The filter keys on "the author named these apart", but a shared code
fingerprint overrides that — the same code under two names IS the
copy-paste the report exists to surface."""
from scribe.services.dedup import _drop_sibling_pairs
from grimoire.services.dedup import _drop_sibling_pairs
records = {
1: {"locations": [{"repo": "S", "path": "a.py", "symbol": "debounce"}],
@@ -347,7 +347,7 @@ def test_unnamed_snippets_still_report():
"""A snippet with no recorded symbol made no identity claim, so the filter
must not protect it — re-recording without a location is a common way to
duplicate."""
from scribe.services.dedup import _drop_sibling_pairs
from grimoire.services.dedup import _drop_sibling_pairs
records = {1: {"code_sha": "aaa"}, 2: {"code_sha": "bbb"}}
assert _drop_sibling_pairs([(1, 2, 0.9)], records) == [(1, 2, 0.9)]
@@ -379,7 +379,7 @@ async def test_report_refuses_an_unknown_kind():
"""A typo'd kind must fail loudly, not scan snippets by default — the
caller asked a question about a kind that doesn't exist, and answering a
different question instead is how wrong conclusions get confident."""
from scribe.services.dedup import find_duplicate_records
from grimoire.services.dedup import find_duplicate_records
with pytest.raises(ValueError, match="kind must be one of"):
await find_duplicate_records(7, kind="rule")
@@ -388,8 +388,8 @@ async def test_report_refuses_an_unknown_kind():
def test_kind_clauses_split_notes_from_tasks_on_status():
"""Tasks are notes with a status, not a note_type of their own. A report
that mixed them would propose folding a to-do into a write-up."""
from scribe.models.note import Note
from scribe.services.dedup import _kind_clauses
from grimoire.models.note import Note
from grimoire.services.dedup import _kind_clauses
note_sql = " AND ".join(str(c) for c in _kind_clauses("note", Note))
task_sql = " AND ".join(str(c) for c in _kind_clauses("task", Note))
@@ -405,7 +405,7 @@ def test_every_kind_has_a_suggestion_and_none_proposes_merging_notes():
records are, and 'merge' is only ever the answer for snippets — folding two
notes destroys what each said, which is why consolidated_at was dropped
rather than built."""
from scribe.services.dedup import _KIND_SUGGESTION, _REPORT_KINDS
from grimoire.services.dedup import _KIND_SUGGESTION, _REPORT_KINDS
for kind in _REPORT_KINDS:
assert _KIND_SUGGESTION.get(kind), f"no suggestion for {kind}"
@@ -425,9 +425,9 @@ def test_every_kind_has_a_suggestion_and_none_proposes_merging_notes():
async def test_plan_title_match_short_circuits_the_semantic_arm():
ms = MagicMock(id=415, title="Plan gate")
sem = AsyncMock()
with patch("scribe.services.dedup.can_read_project", AsyncMock(return_value=True)), \
patch("scribe.services.dedup.async_session", return_value=session_returning(ms)), \
patch("scribe.services.dedup.embeddings_svc.semantic_search_milestones", sem):
with patch("grimoire.services.dedup.can_read_project", AsyncMock(return_value=True)), \
patch("grimoire.services.dedup.async_session", return_value=session_returning(ms)), \
patch("grimoire.services.dedup.embeddings_svc.semantic_search_milestones", sem):
dup = await find_matching_plan(7, 2, " plan GATE", "x" * 300)
assert (dup.id, dup.reason, dup.similarity) == (415, "title", 1.0)
sem.assert_not_awaited()
@@ -437,10 +437,10 @@ async def test_plan_title_match_short_circuits_the_semantic_arm():
async def test_plan_semantic_arm_asks_for_active_plans_in_the_project_at_the_setting():
ms = MagicMock(id=9, title="Metadata")
sem = AsyncMock(return_value=[(0.912345, ms)])
with patch("scribe.services.dedup.can_read_project", AsyncMock(return_value=True)), \
patch("scribe.services.dedup.async_session", return_value=session_returning(None)), \
patch("scribe.services.dedup.embeddings_svc.semantic_search_milestones", sem), \
patch("scribe.services.settings.get_setting", AsyncMock(return_value="0.8")):
with patch("grimoire.services.dedup.can_read_project", AsyncMock(return_value=True)), \
patch("grimoire.services.dedup.async_session", return_value=session_returning(None)), \
patch("grimoire.services.dedup.embeddings_svc.semantic_search_milestones", sem), \
patch("grimoire.services.settings.get_setting", AsyncMock(return_value="0.8")):
dup = await find_matching_plan(7, 2, "Book metadata", "x" * 300)
assert (dup.id, dup.reason, dup.similarity) == (9, "semantic", 0.912)
kw = sem.call_args.kwargs
@@ -450,10 +450,10 @@ async def test_plan_semantic_arm_asks_for_active_plans_in_the_project_at_the_set
@pytest.mark.asyncio
async def test_plan_gate_fails_open():
boom = MagicMock(side_effect=RuntimeError("db down"))
with patch("scribe.services.dedup.can_read_project", AsyncMock(return_value=True)), \
patch("scribe.services.dedup.async_session", boom):
with patch("grimoire.services.dedup.can_read_project", AsyncMock(return_value=True)), \
patch("grimoire.services.dedup.async_session", boom):
assert await find_matching_plan(7, 2, "Anything", "x" * 300) is None
with patch("scribe.services.dedup.can_read_project", AsyncMock(side_effect=RuntimeError)):
with patch("grimoire.services.dedup.can_read_project", AsyncMock(side_effect=RuntimeError)):
assert await find_matching_plan(7, 2, "Anything", "x" * 300) is None
assert await find_matching_plan(7, 0, "No project") is None
@@ -462,17 +462,17 @@ async def test_plan_gate_fails_open():
async def test_plan_gate_says_nothing_about_a_project_the_caller_cannot_read():
ms = MagicMock(id=415, title="Their plan")
session = MagicMock(return_value=session_returning(ms))
with patch("scribe.services.dedup.can_read_project", AsyncMock(return_value=False)), \
patch("scribe.services.dedup.async_session", session):
with patch("grimoire.services.dedup.can_read_project", AsyncMock(return_value=False)), \
patch("grimoire.services.dedup.async_session", session):
assert await find_matching_plan(8, 2, "Their plan") is None
session.assert_not_called()
@pytest.mark.asyncio
async def test_a_bad_threshold_setting_falls_back_to_the_default():
with patch("scribe.services.settings.get_setting", AsyncMock(return_value="lots")):
with patch("grimoire.services.settings.get_setting", AsyncMock(return_value="lots")):
assert await get_plan_match_threshold(7) == PLAN_MATCH_DEFAULT_THRESHOLD
with patch("scribe.services.settings.get_setting", AsyncMock(return_value="7")):
with patch("grimoire.services.settings.get_setting", AsyncMock(return_value="7")):
assert await get_plan_match_threshold(7) == 1.0