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
+29 -29
View File
@@ -45,7 +45,7 @@ def _bind_user():
``pytestmark = pytest.mark.usefixtures("_bind_user")`` and builds its fakes
with user_id=7 so ownership checks see the caller as the owner.
"""
from scribe.mcp._context import _user_id_ctx
from grimoire.mcp._context import _user_id_ctx
token = _user_id_ctx.set(7)
yield
@@ -63,7 +63,7 @@ async def _dispose_engine():
pool cleanly. The import is deferred so merely collecting a module that
mixes unit and integration tests never builds an engine.
"""
from scribe.models import engine
from grimoire.models import engine
yield
await engine.dispose()
@@ -82,8 +82,8 @@ def _no_embedding():
# Milestones embed too; a plan created in a test would
# otherwise detach the same model-loading task.
with patch("scribe.services.notes.embed_note", MagicMock()), \
patch("scribe.services.milestones.embed_milestone", MagicMock()):
with patch("grimoire.services.notes.embed_note", MagicMock()), \
patch("grimoire.services.milestones.embed_milestone", MagicMock()):
yield
@@ -97,7 +97,7 @@ def _no_supersession():
lie about what it does. The label's own behaviour is covered in
tests/test_supersession_ranking.py.
"""
with patch("scribe.services.plugin_context.superseded_ids",
with patch("grimoire.services.plugin_context.superseded_ids",
AsyncMock(return_value=set())):
yield
@@ -112,7 +112,7 @@ def _no_reply_shape_log():
tests/test_reply_shapes.py binds the real function at import time,
before this patch runs.
"""
with patch("scribe.services.reply_shapes.record_delivery", AsyncMock()):
with patch("grimoire.services.reply_shapes.record_delivery", AsyncMock()):
yield
@@ -125,7 +125,7 @@ def _no_system_labels():
to "no labels", the state of any untagged record. Tests of the label
itself patch it with a value.
"""
with patch("scribe.services.plugin_context.system_names_for",
with patch("grimoire.services.plugin_context.system_names_for",
AsyncMock(return_value={})):
yield
@@ -140,7 +140,7 @@ def _no_rulings_arm():
tests/test_system_rulings.py binds the real function at import time,
before this patch runs.
"""
with patch("scribe.services.system_rulings.rulings_for_paths",
with patch("grimoire.services.system_rulings.rulings_for_paths",
AsyncMock(return_value={"lines": [], "system_ids": []})):
yield
@@ -154,12 +154,12 @@ def _no_system_usage_readout():
the zero shape for whatever ids were asked. tests/test_system_usage_readout.py
binds the real function at import time, before this patch runs.
"""
from scribe.services.system_usage import empty_system_usage
from grimoire.services.system_usage import empty_system_usage
async def _zeros(system_ids):
return {int(s): empty_system_usage() for s in system_ids}
with patch("scribe.services.system_usage.usage_for_systems",
with patch("grimoire.services.system_usage.usage_for_systems",
AsyncMock(side_effect=_zeros)):
yield
@@ -181,11 +181,11 @@ def _no_task_log_arm():
tests/test_integration_task_work_log.py. A test that wants the arm live
re-patches it, same as the rules arm.
"""
with patch("scribe.services.task_logs.logs_for_task",
with patch("grimoire.services.task_logs.logs_for_task",
AsyncMock(return_value=[])), \
patch("scribe.services.task_logs.count_logs_for_task",
patch("grimoire.services.task_logs.count_logs_for_task",
AsyncMock(return_value=0)), \
patch("scribe.services.task_logs.log_counts_for_tasks",
patch("grimoire.services.task_logs.log_counts_for_tasks",
AsyncMock(return_value={})):
yield
@@ -218,13 +218,13 @@ def _no_lesson_rule_links(request):
if request.node.get_closest_marker("integration"):
yield
return
with patch("scribe.services.lesson_rules.attach_lesson_rules", AsyncMock()), \
patch("scribe.services.lesson_rules.attach_rule_lessons", AsyncMock()), \
patch("scribe.services.lesson_rules.rule_candidates", AsyncMock(return_value=[])), \
patch("scribe.services.lesson_rules.co_surfaced", AsyncMock(return_value="")), \
patch("scribe.services.plugin_context._rules_via_lessons",
with patch("grimoire.services.lesson_rules.attach_lesson_rules", AsyncMock()), \
patch("grimoire.services.lesson_rules.attach_rule_lessons", AsyncMock()), \
patch("grimoire.services.lesson_rules.rule_candidates", AsyncMock(return_value=[])), \
patch("grimoire.services.lesson_rules.co_surfaced", AsyncMock(return_value="")), \
patch("grimoire.services.plugin_context._rules_via_lessons",
AsyncMock(return_value=([], []))), \
patch("scribe.services.lesson_rules.convergence_for", AsyncMock(return_value=None)):
patch("grimoire.services.lesson_rules.convergence_for", AsyncMock(return_value=None)):
yield
@@ -245,10 +245,10 @@ def _no_family_triggers(request):
if request.node.get_closest_marker("integration"):
yield
return
with patch("scribe.services.family.citation_trigger", AsyncMock(return_value=None)), \
patch("scribe.services.family.repeat_trigger", AsyncMock(return_value=None)), \
patch("scribe.services.family.milestone_trigger", AsyncMock(return_value=None)), \
patch("scribe.services.family.milestone_is_open", AsyncMock(return_value=False)):
with patch("grimoire.services.family.citation_trigger", AsyncMock(return_value=None)), \
patch("grimoire.services.family.repeat_trigger", AsyncMock(return_value=None)), \
patch("grimoire.services.family.milestone_trigger", AsyncMock(return_value=None)), \
patch("grimoire.services.family.milestone_is_open", AsyncMock(return_value=False)):
yield
@@ -266,9 +266,9 @@ def _no_moment_delivery(request):
if request.node.get_closest_marker("integration"):
yield
return
from scribe.services.retrieval_pipeline import RuleResult
from grimoire.services.retrieval_pipeline import RuleResult
with patch("scribe.services.moment_delivery.deliver_for_act",
with patch("grimoire.services.moment_delivery.deliver_for_act",
AsyncMock(return_value=([], RuleResult()))):
yield
@@ -289,7 +289,7 @@ def _no_rule_arm():
channel in tests/test_write_path_trigger.py. A test that wants the arm
live can re-patch it.
"""
with patch("scribe.services.plugin_context.semantic_search_rules",
with patch("grimoire.services.plugin_context.semantic_search_rules",
AsyncMock(return_value=[])):
yield
@@ -306,7 +306,7 @@ def _no_rule_overlap():
tests/test_rule_overlap_gate.py, which binds the real function at import
time — before this patch runs — and stubs the search beneath it instead.
"""
with patch("scribe.services.dedup.find_overlapping_rules",
with patch("grimoire.services.dedup.find_overlapping_rules",
AsyncMock(return_value=[])):
yield
@@ -323,10 +323,10 @@ def _default_gate_bars():
read itself is tested in tests/test_gate_settings.py, which binds the real
function at import time, before this patch runs.
"""
from scribe.services.dedup import GATE_DEFAULT_THRESHOLDS
from grimoire.services.dedup import GATE_DEFAULT_THRESHOLDS
async def _default(user_id, key, lo):
return GATE_DEFAULT_THRESHOLDS[key]
with patch("scribe.services.dedup._gate_setting", AsyncMock(side_effect=_default)):
with patch("grimoire.services.dedup._gate_setting", AsyncMock(side_effect=_default)):
yield
+11 -11
View File
@@ -26,12 +26,12 @@ async def drive_update_note(note, **kwargs):
result = MagicMock()
result.scalars.return_value.first.return_value = note
session.execute = _AsyncMock(return_value=result)
with patch("scribe.services.notes.async_session") as cls, \
patch("scribe.services.notes.embed_note", MagicMock()), \
patch("scribe.services.notes._maybe_reactivate_project", _AsyncMock()), \
patch("scribe.services.note_versions.create_version", _AsyncMock()):
with patch("grimoire.services.notes.async_session") as cls, \
patch("grimoire.services.notes.embed_note", MagicMock()), \
patch("grimoire.services.notes._maybe_reactivate_project", _AsyncMock()), \
patch("grimoire.services.note_versions.create_version", _AsyncMock()):
cls.return_value = session
from scribe.services.notes import update_note
from grimoire.services.notes import update_note
return await update_note(user_id=7, note_id=note.id, **kwargs)
@@ -111,7 +111,7 @@ async def ensure_user(session, username: str, role: str = "user"):
"""
from sqlalchemy import select
from scribe.models.user import User
from grimoire.models.user import User
existing = (
await session.execute(select(User).where(User.username == username))
@@ -309,7 +309,7 @@ def plain_rule_detail():
"""
async def _detail(_uid, rule, _system_ids=None, _moments=None):
return rule.to_dict()
return patch("scribe.mcp.tools.rulebooks.rulebooks_svc.rule_detail", _detail)
return patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.rule_detail", _detail)
class FakeMCP:
@@ -420,8 +420,8 @@ def writepath_cfg(**over):
real_one_does` is what makes the next addition fail loudly here rather than
silently no-op an arm, which is the whole claim this docstring makes.
"""
from scribe.services.plugin_context import _CHECKPOINT_DEFAULT
from scribe.services.retrieval_surfaces import SURFACES
from grimoire.services.plugin_context import _CHECKPOINT_DEFAULT
from grimoire.services.retrieval_surfaces import SURFACES
cfg = {
"enabled": True,
@@ -475,8 +475,8 @@ async def rule_row(rule_id: int):
database and must not pay for one — the same reason `plugin_config` defers
its service imports.
"""
from scribe.models import async_session
from scribe.models.rulebook import Rule
from grimoire.models import async_session
from grimoire.models.rulebook import Rule
async with async_session() as s:
return await s.get(Rule, rule_id)
+10 -10
View File
@@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.services.api_keys import (
from grimoire.services.api_keys import (
hash_token,
_key_prefix,
generate_key,
@@ -45,7 +45,7 @@ async def test_create_api_key_returns_full_key():
mock_key_obj.id = 1
mock_key_obj.to_dict.return_value = {"id": 1, "name": "test", "scope": "read", "key_prefix": "fmcp_xxx"}
with patch("scribe.services.api_keys.async_session") as mock_session_ctx:
with patch("grimoire.services.api_keys.async_session") as mock_session_ctx:
mock_session = make_mock_session()
mock_session.add = MagicMock()
mock_session.commit = AsyncMock()
@@ -56,7 +56,7 @@ async def test_create_api_key_returns_full_key():
mock_session_ctx.return_value = mock_session
# Patch the select result
with patch("scribe.services.api_keys.ApiKey") as mock_model:
with patch("grimoire.services.api_keys.ApiKey") as mock_model:
mock_model.return_value = mock_key_obj
full_key, key_dict = await create_api_key(user_id=1, name="test", scope="read")
@@ -65,7 +65,7 @@ async def test_create_api_key_returns_full_key():
@pytest.mark.asyncio
async def test_lookup_key_returns_none_for_unknown():
with patch("scribe.services.api_keys.async_session") as mock_session_ctx:
with patch("grimoire.services.api_keys.async_session") as mock_session_ctx:
mock_session = make_mock_session()
mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = None
@@ -109,15 +109,15 @@ async def test_read_only_key_blocked_on_post():
from quart import jsonify
return jsonify({"ok": True})
from scribe.auth import _check_auth
from grimoire.auth import _check_auth
wrapped = _check_auth(dummy)
async with app.test_request_context(
"/test", method="POST",
headers={"Authorization": "Bearer fmcp_readonly"},
):
with patch("scribe.auth.lookup_key", AsyncMock(return_value=fake_key)), \
patch("scribe.auth.get_user_by_id", AsyncMock(return_value=fake_user)):
with patch("grimoire.auth.lookup_key", AsyncMock(return_value=fake_key)), \
patch("grimoire.auth.get_user_by_id", AsyncMock(return_value=fake_user)):
resp = await wrapped()
assert resp[1] == 403
@@ -146,15 +146,15 @@ async def test_bearer_token_valid_write_key_passes():
from quart import jsonify
return jsonify({"ok": True})
from scribe.auth import _check_auth
from grimoire.auth import _check_auth
wrapped = _check_auth(dummy)
async with app.test_request_context(
"/test", method="POST",
headers={"Authorization": "Bearer fmcp_writekey"},
):
with patch("scribe.auth.lookup_key", AsyncMock(return_value=fake_key)), \
patch("scribe.auth.get_user_by_id", AsyncMock(return_value=fake_user)):
with patch("grimoire.auth.lookup_key", AsyncMock(return_value=fake_key)), \
patch("grimoire.auth.get_user_by_id", AsyncMock(return_value=fake_user)):
resp = await wrapped()
# Should not be a tuple (no error response)
+2 -2
View File
@@ -16,7 +16,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.services.plugin_context import (
from grimoire.services.plugin_context import (
_AUTOINJECT_CONTEXT_MAX,
_AUTOINJECT_CONTEXT_PROMPT_MAX,
_autoinject_query,
@@ -144,7 +144,7 @@ def test_a_title_only_match_hands_over_the_trigger_it_matched_on():
async def _menu(hits, seen, chunks, systems=None):
from scribe.services import plugin_context as pc
from grimoire.services import plugin_context as pc
calls: list[int] = []
+1 -1
View File
@@ -15,7 +15,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.services import embeddings as emb
from grimoire.services import embeddings as emb
def _ctx(session):
+5 -5
View File
@@ -28,9 +28,9 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.services import embeddings as emb
from scribe.services import retrieval_tuning as rt
from scribe.services.retrieval_surfaces import SURFACES, get_surface
from grimoire.services import embeddings as emb
from grimoire.services import retrieval_tuning as rt
from grimoire.services.retrieval_surfaces import SURFACES, get_surface
from tests.helpers import make_mock_session, tool_doc
# Names for the thing that talks, as opposed to the thing that embeds. Any of
@@ -72,7 +72,7 @@ def test_no_chat_model_identifier_reaches_the_calibration_path():
"""
import inspect
from scribe.services import retrieval_migration as rm
from grimoire.services import retrieval_migration as rm
for label, obj in (
("calibration_stamp", emb.calibration_stamp),
@@ -183,7 +183,7 @@ def test_the_tool_says_nothing_auto_retunes():
Without this, the obvious next move on seeing `stale: true` is to move the
dial — which is tuning from a statistic, which can point the wrong way.
"""
doc = tool_doc("scribe.mcp.tools.retrieval_tuning", "retrieval_surfaces")
doc = tool_doc("grimoire.mcp.tools.retrieval_tuning", "retrieval_surfaces")
assert "calibration" in doc
assert "NOTHING IS RETUNED AUTOMATICALLY" in doc
assert "unstamped" in doc
+8 -8
View File
@@ -10,7 +10,7 @@ its topical anchor.
"""
import re
from scribe.services.embeddings import (
from grimoire.services.embeddings import (
_CHUNK_CHAR_BUDGET,
_MIN_CHUNK_CONTENT,
chunk_document,
@@ -228,7 +228,7 @@ async def test_search_collapses_chunk_rows_to_best_chunk_per_note():
long record fills the top-k with copies of itself."""
from unittest.mock import AsyncMock, MagicMock, patch
from scribe.services import embeddings as emb
from grimoire.services import embeddings as emb
note_a, note_b = MagicMock(id=1), MagicMock(id=2)
# Rows are (Note, distance, chunk_index, chunk_text) — the chunk columns
@@ -289,7 +289,7 @@ def _session_ctx(log_rows=()):
async def test_upsert_stores_one_versioned_row_per_chunk():
from unittest.mock import AsyncMock, patch
from scribe.services import embeddings as emb
from grimoire.services import embeddings as emb
body = "\n\n".join(
f"## Section {i}\n\n{_long_section(f'sec-{i}')}" for i in range(6)
@@ -324,7 +324,7 @@ async def test_a_tasks_work_logs_reach_the_rows_it_is_stored_as():
import datetime
from unittest.mock import AsyncMock, patch
from scribe.services import embeddings as emb
from grimoire.services import embeddings as emb
session, ctx = _session_ctx(
log_rows=[(datetime.datetime(2026, 9, 20), "ruled out the cache theory")]
@@ -348,7 +348,7 @@ async def test_upsert_of_an_emptied_record_clears_rows_instead_of_embedding():
longer says."""
from unittest.mock import patch
from scribe.services import embeddings as emb
from grimoire.services import embeddings as emb
session, ctx = _session_ctx()
with (
@@ -369,7 +369,7 @@ async def test_backfill_reembeds_notes_with_a_stale_chunker_version():
table wipe. Only rows AT the current version count as done."""
from unittest.mock import AsyncMock, MagicMock, patch
from scribe.services import embeddings as emb
from grimoire.services import embeddings as emb
def _ids(*values):
r = MagicMock()
@@ -404,10 +404,10 @@ def test_a_row_is_current_only_in_the_live_models_space():
"""The column is a width, not an identity, so a same-width model
swap writes a second geometry with no error. The backfill's "current"
predicate has to name BOTH halves of the stamp, on every embedding table."""
from scribe.models.embedding import (
from grimoire.models.embedding import (
MilestoneEmbedding, NoteEmbedding, RuleEmbedding, SystemEmbedding,
)
from scribe.services import embeddings as emb
from grimoire.services import embeddings as emb
for table in (NoteEmbedding, RuleEmbedding, MilestoneEmbedding, SystemEmbedding):
clause = str(emb.is_current_stamp(table))
+7 -7
View File
@@ -30,8 +30,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy import true
from scribe.services import milestones as ms
from scribe.services import placement as pl
from grimoire.services import milestones as ms
from grimoire.services import placement as pl
from tests.helpers import fake_note, make_mock_session
@@ -243,7 +243,7 @@ async def test_the_listing_and_placement_agree_on_what_open_means():
def test_a_task_line_in_the_menu_says_where_the_work_stands():
"""The other half: an id and a title with no status is what got cited."""
from scribe.services.plugin_context import _record_kind
from grimoire.services.plugin_context import _record_kind
assert _record_kind(fake_note(is_task=True, status="done")) == "task (done)"
assert _record_kind(fake_note(is_task=True, status="todo")) == "task (todo)"
@@ -255,7 +255,7 @@ def test_a_task_line_in_the_menu_says_where_the_work_stands():
def test_a_record_with_no_status_gains_no_parenthesis():
"""Only tasks. A note or a snippet has no status to be wrong about, and a
marker that appeared on every line would stop being read."""
from scribe.services.plugin_context import _record_kind
from grimoire.services.plugin_context import _record_kind
assert _record_kind(fake_note(note_type="snippet")) == "snippet"
assert _record_kind(fake_note(note_type="process")) == "process"
@@ -272,9 +272,9 @@ def test_the_listing_tools_say_what_next_step_is_and_how_to_use_it():
from tests.helpers import tool_doc
for module, name in (
("scribe.mcp.tools.milestones", "list_milestones"),
("scribe.mcp.tools.projects", "enter_project"),
("scribe.mcp.tools.projects", "get_project"),
("grimoire.mcp.tools.milestones", "list_milestones"),
("grimoire.mcp.tools.projects", "enter_project"),
("grimoire.mcp.tools.projects", "get_project"),
):
doc = tool_doc(module, name).lower()
assert "next_step" in doc, f"{name} does not mention next_step"
+8 -8
View File
@@ -33,14 +33,14 @@ from tests.helpers import tool_doc as _doc
# The create surfaces and where they live. `start_planning` is here because
# it is a create in everything but name — it is how a plan comes into being.
_SURFACES = [
("scribe.mcp.tools.notes", "create_note"),
("scribe.mcp.tools.tasks", "create_task"),
("scribe.mcp.tools.tasks", "create_records"),
("scribe.mcp.tools.tasks", "start_planning"),
("scribe.mcp.tools.snippets", "create_snippet"),
("scribe.mcp.tools.processes", "create_process"),
("scribe.mcp.tools.rulebooks", "create_rule"),
("scribe.mcp.tools.rulebooks", "create_project_rule"),
("grimoire.mcp.tools.notes", "create_note"),
("grimoire.mcp.tools.tasks", "create_task"),
("grimoire.mcp.tools.tasks", "create_records"),
("grimoire.mcp.tools.tasks", "start_planning"),
("grimoire.mcp.tools.snippets", "create_snippet"),
("grimoire.mcp.tools.processes", "create_process"),
("grimoire.mcp.tools.rulebooks", "create_rule"),
("grimoire.mcp.tools.rulebooks", "create_project_rule"),
]
# What a caller could have wanted instead. A surface naming two of these has
+1 -1
View File
@@ -3,7 +3,7 @@
import asyncpg
import pytest
from scribe.services.db_ready import asyncpg_dsn, wait_for_database
from grimoire.services.db_ready import asyncpg_dsn, wait_for_database
URL = "postgresql+asyncpg://scribe:p%40ss@db:5432/scribe"
+16 -16
View File
@@ -116,9 +116,9 @@ async def test_a_title_change_reaches_the_mirror_too():
def test_every_kind_with_a_derived_mirror_is_registered():
"""The load-bearing one. A single `if` naming one kind leaves the next
kind uncovered."""
from scribe.services.lessons import LESSON_NOTE_TYPE
from scribe.services.notes import _mirror_recomposers
from scribe.services.snippets import SNIPPET_NOTE_TYPE
from grimoire.services.lessons import LESSON_NOTE_TYPE
from grimoire.services.notes import _mirror_recomposers
from grimoire.services.snippets import SNIPPET_NOTE_TYPE
table = _mirror_recomposers()
assert SNIPPET_NOTE_TYPE in table, "snippets lost their mirror recomposer"
@@ -132,7 +132,7 @@ def test_every_kind_with_a_derived_mirror_is_registered():
def test_the_dispatch_is_a_lookup_not_a_named_kind():
"""Asserted on structure. A lookup extends in one line in one
place; naming a kind inline is the shape that left lessons uncovered."""
from scribe.services import notes as notes_module
from grimoire.services import notes as notes_module
src = inspect.getsource(notes_module.update_note)
assert "_mirror_recomposers()" in src
@@ -144,7 +144,7 @@ def test_the_dispatch_is_a_lookup_not_a_named_kind():
def test_each_recomposer_takes_the_note_and_nothing_else():
"""A registry entry with the wrong signature fails inside a generic PATCH,
which is the one moment nobody is watching."""
from scribe.services.notes import _mirror_recomposers
from grimoire.services.notes import _mirror_recomposers
for kind, fn in _mirror_recomposers().items():
assert callable(fn), f"{kind} maps to something not callable"
@@ -158,13 +158,13 @@ NEW_TRIGGER = "a CI run has sat in_progress far longer than its suite takes"
def _lesson_body(trigger, insight="Read the job log.", sources=None):
from scribe.services.lessons import compose_body
from grimoire.services.lessons import compose_body
return compose_body(insight, trigger, sources)
@pytest.mark.asyncio
async def test_a_body_write_moves_a_lessons_trigger_with_it():
from scribe.services.lessons import TRIGGER_KEY
from grimoire.services.lessons import TRIGGER_KEY
what = "Read the job log before waiting longer"
note = fake_lesson(
@@ -186,8 +186,8 @@ async def test_a_subject_containing_an_em_dash_still_splits():
separator: a subject may legitimately contain one. The title here is an
UN-MIGRATED one, still carrying its trigger, because that
is the row the inverse still has to read correctly."""
from scribe.services.embeddings import trigger_title
from scribe.services.lessons import TRIGGER_KEY
from grimoire.services.embeddings import trigger_title
from grimoire.services.lessons import TRIGGER_KEY
what = "A wait with no deadline — the shape, not the symptom"
trigger = "you are about to await something crossing a process boundary"
@@ -205,7 +205,7 @@ async def test_dropping_the_provenance_line_drops_it_from_the_mirror():
the failure this recompose exists to prevent, not a courtesy — the
opposite call from a snippet's `verification`, which is carried because it
was never in the body to delete."""
from scribe.services.lessons import SOURCES_KEY
from grimoire.services.lessons import SOURCES_KEY
note = fake_lesson(
title="Something learned",
@@ -220,7 +220,7 @@ async def test_dropping_the_provenance_line_drops_it_from_the_mirror():
async def test_an_explicit_data_wins_for_a_lesson_too():
"""`update_lesson` composes the mirror from the merged field set and passes
it here; that caller knows things a re-read of the body cannot recover."""
from scribe.services.lessons import TRIGGER_KEY
from grimoire.services.lessons import TRIGGER_KEY
note = fake_lesson(data={TRIGGER_KEY: "old"}, project_id=None)
authoritative = {TRIGGER_KEY: "from the service", "what": "x"}
@@ -232,7 +232,7 @@ async def test_an_explicit_data_wins_for_a_lesson_too():
async def test_a_lesson_title_change_reaches_the_mirror():
"""A lesson's subject lives in its title, so a title edit is a trigger for
recomposition exactly as it is for a snippet's name."""
from scribe.services.lessons import TRIGGER_KEY
from grimoire.services.lessons import TRIGGER_KEY
trigger = "two absolute siblings overlap"
note = fake_lesson(
@@ -261,7 +261,7 @@ async def test_a_lesson_title_change_reaches_the_mirror():
def test_untrigger_title_inverts_trigger_title(subject, trigger):
"""The join's inverse lives beside it rather than in whichever caller
wanted it."""
from scribe.services.embeddings import trigger_title, untrigger_title
from grimoire.services.embeddings import trigger_title, untrigger_title
title = trigger_title(subject, trigger)
assert untrigger_title(title, trigger) == (subject or trigger).strip()
@@ -270,7 +270,7 @@ def test_untrigger_title_inverts_trigger_title(subject, trigger):
def test_untrigger_title_degrades_to_the_whole_title():
"""A record written before the join existed still answers with something a
human recognises rather than with ""."""
from scribe.services.embeddings import untrigger_title
from grimoire.services.embeddings import untrigger_title
assert untrigger_title("a plain old title", "") == "a plain old title"
assert untrigger_title("a plain old title", "a trigger it lacks") == (
@@ -282,7 +282,7 @@ def test_the_trigger_separator_is_spelled_in_exactly_one_place():
"""Two literals that must match are one edit away from not matching."""
import pathlib
root = pathlib.Path(__file__).resolve().parents[1] / "src" / "scribe"
root = pathlib.Path(__file__).resolve().parents[1] / "src" / "grimoire"
offenders = [
str(p.relative_to(root)) for p in root.rglob("*.py")
if '" \u2014 "' in p.read_text() and p.name != "embeddings.py"
@@ -295,7 +295,7 @@ def test_the_trigger_separator_is_spelled_in_exactly_one_place():
def test_the_mirror_guards_can_fail():
"""Shown turning red once."""
from scribe.services.lessons import TRIGGER_KEY, recompose_data
from grimoire.services.lessons import TRIGGER_KEY, recompose_data
bare = fake_lesson(title="just a title", body="no composed lines", data=None)
assert TRIGGER_KEY not in recompose_data(bare)
+1 -1
View File
@@ -6,7 +6,7 @@ own import-free module — see services/design_cascade.py.
"""
from types import SimpleNamespace
from scribe.services.design_cascade import ancestry, resolve_tokens, would_cycle
from grimoire.services.design_cascade import ancestry, resolve_tokens, would_cycle
from tests.helpers import design_token_stub
+1 -1
View File
@@ -14,7 +14,7 @@ import re
import pytest
from scribe.services import design_starter_roles as roles
from grimoire.services import design_starter_roles as roles
def test_no_role_carries_a_VALUE():
+1 -1
View File
@@ -7,7 +7,7 @@ declaration.
"""
from types import SimpleNamespace
from scribe.services.design_stylesheet import (
from grimoire.services.design_stylesheet import (
check_code_against_tokens,
derivation_report,
duplicate_values,
+5 -5
View File
@@ -25,8 +25,8 @@ import inspect
import textwrap
from unittest.mock import AsyncMock, patch
from scribe.services import shape_ledger
from scribe.services.shape_ledger import _semantic_canon
from grimoire.services import shape_ledger
from grimoire.services.shape_ledger import _semantic_canon
# Comfortably over WRITEPATH_MIN_CODE_CHARS (48 non-whitespace characters), so
# these tests exercise the comparison rather than the substance guard. A real
@@ -53,7 +53,7 @@ def _hits(*hits: tuple[float, int]) -> AsyncMock:
def _patch(mock: AsyncMock):
return patch("scribe.services.embeddings.semantic_search_notes", mock)
return patch("grimoire.services.embeddings.semantic_search_notes", mock)
# ── the arm proposes on a hit ────────────────────────────────────────────
@@ -95,7 +95,7 @@ async def test_the_arm_proposes_at_the_write_path_floor() -> None:
"""It asks the write-path hint's question of the same documents, so it
uses that surface's floor — not a stricter private one that yields no
proposals for the judge to weigh."""
from scribe.services.plugin_context import WRITEPATH_DEFAULT_THRESHOLD
from grimoire.services.plugin_context import WRITEPATH_DEFAULT_THRESHOLD
mock = _hits()
with _patch(mock):
@@ -140,7 +140,7 @@ def test_the_capped_pass_reads_new_shapes_first() -> None:
highest ids — was flagged before the arm could propose for it."""
from datetime import datetime, timedelta, timezone
from scribe.services.shape_ledger import _semantic_priority
from grimoire.services.shape_ledger import _semantic_priority
t = datetime(2026, 9, 22, tzinfo=timezone.utc)
rows = [
+2 -2
View File
@@ -20,9 +20,9 @@ from __future__ import annotations
import ast
import pathlib
from scribe.services.embeddings import embedding_text
from grimoire.services.embeddings import embedding_text
SERVICES = pathlib.Path(__file__).resolve().parents[1] / "src" / "scribe"
SERVICES = pathlib.Path(__file__).resolve().parents[1] / "src" / "grimoire"
def test_title_and_body_are_joined_by_a_newline():
+1 -1
View File
@@ -17,7 +17,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from sqlalchemy.dialects import postgresql
from sqlalchemy.exc import OperationalError
from scribe.services import embeddings as emb
from grimoire.services import embeddings as emb
from tests.helpers import compiled_sql
ONE_VECTOR = [[0.0] * 384]
+8 -8
View File
@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.services.embeddings import (
from grimoire.services.embeddings import (
_cosine_similarity, get_embedding,
)
@@ -54,7 +54,7 @@ async def test_get_embedding_returns_list_of_floats():
fake_embedder = MagicMock()
fake_embedder.embed = MagicMock(return_value=iter([fake_vec]))
with patch(
"scribe.services.embeddings._get_model",
"grimoire.services.embeddings._get_model",
AsyncMock(return_value=fake_embedder),
):
out = await get_embedding("hello world")
@@ -67,7 +67,7 @@ async def test_get_embedding_propagates_model_load_failures():
"""If fastembed can't initialize, the error propagates — callers catch
and degrade to keyword search."""
with patch(
"scribe.services.embeddings._get_model",
"grimoire.services.embeddings._get_model",
AsyncMock(side_effect=RuntimeError("model load failed")),
):
with pytest.raises(RuntimeError, match="model load failed"):
@@ -91,7 +91,7 @@ def _counting_embeddings():
@pytest.mark.asyncio
async def test_memo_embeds_a_repeated_query_once_per_scope():
from scribe.services import embeddings as emb
from grimoire.services import embeddings as emb
fake, calls = _counting_embeddings()
with patch.object(emb, "get_embeddings", fake):
@@ -108,7 +108,7 @@ async def test_memo_embeds_a_repeated_query_once_per_scope():
async def test_without_a_scope_every_call_reaches_the_model():
"""The default is None: outside a request scope nothing is remembered, so
every caller other than the plugin routes behaves exactly as before."""
from scribe.services import embeddings as emb
from grimoire.services import embeddings as emb
fake, calls = _counting_embeddings()
with patch.object(emb, "get_embeddings", fake):
@@ -119,7 +119,7 @@ async def test_without_a_scope_every_call_reaches_the_model():
@pytest.mark.asyncio
async def test_a_scope_does_not_leak_into_the_next_one():
from scribe.services import embeddings as emb
from grimoire.services import embeddings as emb
fake, calls = _counting_embeddings()
with patch.object(emb, "get_embeddings", fake):
@@ -136,7 +136,7 @@ async def test_a_scope_does_not_leak_into_the_next_one():
async def test_a_failed_embedding_is_not_remembered():
"""The next arm in the request must try again and degrade on its own,
not inherit a failure as if it were a vector."""
from scribe.services import embeddings as emb
from grimoire.services import embeddings as emb
attempts = {"n": 0}
@@ -156,7 +156,7 @@ async def test_a_failed_embedding_is_not_remembered():
@pytest.mark.asyncio
async def test_the_route_decorator_opens_one_scope_per_call():
from scribe.services import embeddings as emb
from grimoire.services import embeddings as emb
fake, calls = _counting_embeddings()
+7 -7
View File
@@ -13,11 +13,11 @@ from unittest.mock import AsyncMock, patch
import pytest
from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
from scribe.mcp.tools import family as family_tools
from scribe.mcp.tools.milestones import update_milestone
from scribe.services import family as family_svc
from scribe.services.family import CRITERIA_KEYS, lineage_citations, vetoes
from grimoire.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
from grimoire.mcp.tools import family as family_tools
from grimoire.mcp.tools.milestones import update_milestone
from grimoire.services import family as family_svc
from grimoire.services.family import CRITERIA_KEYS, lineage_citations, vetoes
from tests.helpers import fake_milestone
pytestmark = pytest.mark.usefixtures("_bind_user")
@@ -143,7 +143,7 @@ async def test_a_failing_trigger_never_breaks_the_write():
async def test_closing_a_milestone_on_a_platform_carries_the_hint():
closed = fake_milestone(id=5, project_id=3, status="done")
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone",
with patch("grimoire.mcp.tools.milestones.milestones_svc.update_milestone",
AsyncMock(return_value=closed)), \
patch.object(family_svc, "milestone_is_open", AsyncMock(return_value=True)), \
patch.object(family_svc, "milestone_trigger", AsyncMock(return_value="evaluate")):
@@ -154,7 +154,7 @@ async def test_closing_a_milestone_on_a_platform_carries_the_hint():
async def test_re_saving_a_closed_milestone_asks_nothing():
closed = fake_milestone(id=5, project_id=3, status="done")
trigger = AsyncMock(return_value="evaluate")
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone",
with patch("grimoire.mcp.tools.milestones.milestones_svc.update_milestone",
AsyncMock(return_value=closed)), \
patch.object(family_svc, "milestone_is_open", AsyncMock(return_value=False)), \
patch.object(family_svc, "milestone_trigger", trigger):
+4 -4
View File
@@ -12,8 +12,8 @@ from __future__ import annotations
import pytest
from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
from scribe.services.family_adoption import (
from grimoire.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
from grimoire.services.family_adoption import (
CONFLICT_KEYS, CONFLICT_ORDER, OUTCOME_KEYS, _fold_section, _reference_line,
assessment_problems, conflict_problems, needs_recheck,
)
@@ -169,10 +169,10 @@ def test_the_outcomes_the_agent_is_told_are_the_ones_enforced():
"""The outcomes and the order are product text. The tool
docstrings must name every outcome and every ground the service checks,
in the service's order."""
assess_doc = tool_doc("scribe.mcp.tools.family", "assess_family_adoption")
assess_doc = tool_doc("grimoire.mcp.tools.family", "assess_family_adoption")
for n, key in enumerate(OUTCOME_KEYS, start=1):
assert f"{n}. {key} —" in assess_doc, f"outcome {n} is not taught as {key}"
resolve_doc = tool_doc("scribe.mcp.tools.family", "resolve_family_conflict")
resolve_doc = tool_doc("grimoire.mcp.tools.family", "resolve_family_conflict")
for n, key in enumerate(CONFLICT_KEYS, start=1):
assert f"{n}. {key} —" in resolve_doc, f"ground {n} is not taught as {key}"
assert [c["fold_as"] for c in CONFLICT_ORDER] == ["alternative", "trap", "condition", "alternative"]
+7 -7
View File
@@ -9,8 +9,8 @@ from __future__ import annotations
import pathlib
import re
from scribe.services import moment_actions
from scribe.services.family_adoption import family_line
from grimoire.services import moment_actions
from grimoire.services.family_adoption import family_line
from tests.helpers import skill_text, tool_doc
ROOT = pathlib.Path(__file__).resolve().parents[1]
@@ -87,13 +87,13 @@ def test_the_plugin_names_the_skill_it_ships():
# --- the pointers ---------------------------------------------------------------------
def test_every_surface_that_meets_family_canon_points_at_the_skill():
server = (ROOT / "src" / "scribe" / "mcp" / "server.py").read_text()
server = (ROOT / "src" / "grimoire" / "mcp" / "server.py").read_text()
index = re.search(r'_INSTRUCTIONS = """(.*?)"""', server, re.S).group(1)
assert "family-canon" in index and "`family`" in index
for module, tool in (("notes", "create_note"), ("snippets", "create_snippet"),
("shapes", "classify_shapes")):
assert "family-canon" in tool_doc(f"scribe.mcp.tools.{module}", tool), tool
assert "family_hint" in tool_doc("scribe.mcp.tools.notes", "create_note")
assert "`family`" in tool_doc("scribe.mcp.tools.projects", "enter_project")
assert "family_owed" in tool_doc("scribe.mcp.tools.tasks", "update_task")
assert "family-canon" in tool_doc(f"grimoire.mcp.tools.{module}", tool), tool
assert "family_hint" in tool_doc("grimoire.mcp.tools.notes", "create_note")
assert "`family`" in tool_doc("grimoire.mcp.tools.projects", "enter_project")
assert "family_owed" in tool_doc("grimoire.mcp.tools.tasks", "update_task")
assert "family_owed" in skill_text("reporting-back")
+2 -2
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
import importlib.util
from pathlib import Path
from scribe.models import family
from grimoire.models import family
ROOT = Path(__file__).resolve().parents[1]
@@ -62,7 +62,7 @@ def test_a_canon_ideas_scope_lives_on_the_idea_and_nowhere_else():
"""One scope source. A rule topic is linked FROM the idea and carries no
platforms of its own, so the two cannot disagree about who a standard
reaches — two sources of truth."""
from scribe.models.rulebook import RulebookTopic
from grimoire.models.rulebook import RulebookTopic
assert "topic_id" in family.FamilyIdea.__table__.c
assert not {c.name for c in RulebookTopic.__table__.c} & {"platform_id", "platform_ids"}
+7 -7
View File
@@ -13,10 +13,10 @@ from unittest.mock import AsyncMock, patch
import pytest
from scribe.services.family_adoption import (
from grimoire.services.family_adoption import (
_contradiction, path_speaks, shape_outcome, shapes_say,
)
from scribe.services.shape_ledger import _FAMILY_FLOOR, _semantic_canon, pick_semantic
from grimoire.services.shape_ledger import _FAMILY_FLOOR, _semantic_canon, pick_semantic
from tests.helpers import tool_doc
@@ -126,7 +126,7 @@ async def test_a_project_on_a_shared_platform_is_searched_with_no_own_canon():
family references alone are reason to search."""
body = "def throttle(key: str) -> bool:\n return attempts[key] < LIMIT and not locked(key)\n"
hit = SimpleNamespace(id=REF)
with patch("scribe.services.embeddings.semantic_search_notes",
with patch("grimoire.services.embeddings.semantic_search_notes",
AsyncMock(return_value=[(0.72, hit)])):
assert await _semantic_canon(1, body, set(), {REF}) == (REF, 0.72, "family")
@@ -134,8 +134,8 @@ async def test_a_project_on_a_shared_platform_is_searched_with_no_own_canon():
# --- the agent-facing contract -----------------------------------------------------
def test_the_tools_teach_idea_id_and_the_family_basis():
classify = tool_doc("scribe.mcp.tools.shapes", "classify_shapes")
classify = tool_doc("grimoire.mcp.tools.shapes", "classify_shapes")
assert "idea_id" in classify and "shapes contradict" in classify
assert "family" in tool_doc("scribe.mcp.tools.shapes", "list_shapes")
assert '"family"' in tool_doc("scribe.mcp.tools.shapes", "confirm_shape_proposals")
assert "classify_shapes" in tool_doc("scribe.mcp.tools.family", "assess_family_adoption")
assert "family" in tool_doc("grimoire.mcp.tools.shapes", "list_shapes")
assert '"family"' in tool_doc("grimoire.mcp.tools.shapes", "confirm_shape_proposals")
assert "classify_shapes" in tool_doc("grimoire.mcp.tools.family", "assess_family_adoption")
+2 -2
View File
@@ -11,8 +11,8 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from scribe.services import secrets
from scribe.services.forge_connections import connection_token
from grimoire.services import secrets
from grimoire.services.forge_connections import connection_token
_MIGRATION = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0122_seal_forge_tokens.py"
+11 -11
View File
@@ -19,8 +19,8 @@ import hmac
import pytest
import pytest_asyncio
from scribe.routes.webhooks import delivered_signature, push_facts, signature_ok
from scribe.services.snippets import _path_touches
from grimoire.routes.webhooks import delivered_signature, push_facts, signature_ok
from grimoire.services.snippets import _path_touches
from tests.helpers import ensure_user
SECRET = "wh-secret"
@@ -113,8 +113,8 @@ def test_path_touches_uses_the_location_semantics():
def test_route_is_registered_and_unauthenticated_by_design():
from scribe.app import create_app
from scribe.routes import webhooks as wh
from grimoire.app import create_app
from grimoire.routes import webhooks as wh
assert callable(wh.forge_push)
rules = {r.rule for r in create_app().url_map.iter_rules()}
@@ -127,10 +127,10 @@ def test_route_is_registered_and_unauthenticated_by_design():
@pytest_asyncio.fixture
async def seeded(_dispose_engine):
"""User + project + binding + two verified snippets + one unverified."""
from scribe.models import async_session
from scribe.models.project import Project
from scribe.services import snippets as svc
from scribe.services.repo_bindings import set_binding
from grimoire.models import async_session
from grimoire.models.project import Project
from grimoire.services import snippets as svc
from grimoire.services.repo_bindings import set_binding
async with async_session() as s:
user = await ensure_user(s, "webhook_itest")
@@ -165,7 +165,7 @@ async def seeded(_dispose_engine):
@pytest.mark.integration
async def test_push_flags_matched_verdicts_and_replay_is_quiet(seeded):
from scribe.services import snippets as svc
from grimoire.services import snippets as svc
flagged = await svc.invalidate_for_push(
"git.example.com/alice/widget",
@@ -207,7 +207,7 @@ async def test_push_flags_matched_verdicts_and_replay_is_quiet(seeded):
@pytest.mark.integration
async def test_reverifying_clears_the_flag(seeded):
from scribe.services import snippets as svc
from grimoire.services import snippets as svc
await svc.invalidate_for_push(
"git.example.com/alice/widget", changed=["src/x.py"], removed=[],
@@ -227,7 +227,7 @@ async def test_reverifying_clears_the_flag(seeded):
@pytest.mark.integration
async def test_unbound_repo_flags_nothing(seeded):
from scribe.services import snippets as svc
from grimoire.services import snippets as svc
flagged = await svc.invalidate_for_push(
"github.com/somebody/else", changed=["src/x.py"], removed=[],
+7 -7
View File
@@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch
import pytest
from scribe.services.dedup import (
from grimoire.services.dedup import (
_GATE_MIN_BLOCK,
_GATE_MIN_OVERLAP,
GATE_DEFAULT_THRESHOLDS,
@@ -16,7 +16,7 @@ _real_gate_setting = _gate_setting
def _setting(value):
return patch("scribe.services.settings.get_setting", AsyncMock(return_value=value))
return patch("grimoire.services.settings.get_setting", AsyncMock(return_value=value))
@pytest.mark.asyncio
@@ -46,7 +46,7 @@ async def test_an_unparseable_value_falls_back_to_the_default():
@pytest.mark.asyncio
async def test_an_unreadable_setting_falls_back_to_the_default():
with patch("scribe.services.settings.get_setting",
with patch("grimoire.services.settings.get_setting",
AsyncMock(side_effect=RuntimeError("db down"))):
got = await _real_gate_setting(7, "lesson", _GATE_MIN_BLOCK)
assert got == GATE_DEFAULT_THRESHOLDS["lesson"]
@@ -54,18 +54,18 @@ async def test_an_unreadable_setting_falls_back_to_the_default():
@pytest.mark.asyncio
async def test_the_overlap_floor_never_sits_above_the_block_bar():
from scribe.services.dedup import gate_bars
from grimoire.services.dedup import gate_bars
async def _set(user_id, key, lo):
return {"note_copy": 0.85, "note_overlap": 0.90}[key]
with patch("scribe.services.dedup._gate_setting", AsyncMock(side_effect=_set)):
with patch("grimoire.services.dedup._gate_setting", AsyncMock(side_effect=_set)):
assert await gate_bars(7, "note") == (0.85, 0.85)
@pytest.mark.asyncio
async def test_a_kind_outside_the_copy_band_reads_no_overlap_floor():
from scribe.services.dedup import gate_bars
from grimoire.services.dedup import gate_bars
seen = []
@@ -73,6 +73,6 @@ async def test_a_kind_outside_the_copy_band_reads_no_overlap_floor():
seen.append(key)
return GATE_DEFAULT_THRESHOLDS[key]
with patch("scribe.services.dedup._gate_setting", AsyncMock(side_effect=_set)):
with patch("grimoire.services.dedup._gate_setting", AsyncMock(side_effect=_set)):
assert await gate_bars(7, "process") == (0.90, 0.90)
assert seen == ["general"]
+4 -4
View File
@@ -64,7 +64,7 @@ def _live_session_context_source() -> str:
Read as text rather than called: the function needs a database, and what
this module checks is what the product SAYS, which is the literal strings.
"""
src = (ROOT / "src/scribe/services/plugin_context.py").read_text()
src = (ROOT / "src/grimoire/services/plugin_context.py").read_text()
start = src.index("async def build_session_context")
nxt = re.search(r"\n(?:async def|def) ", src[start + 1:])
return src[start:start + 1 + nxt.start()] if nxt else src[start:]
@@ -73,7 +73,7 @@ def _live_session_context_source() -> str:
def _delivered_shapes() -> str:
"""Every reply shape as a session receives it in full. Rendered rather than
read as source: the header a shape arrives under is guidance too."""
from scribe.services import reply_shapes
from grimoire.services import reply_shapes
return "\n\n".join(reply_shapes.render(s, full=True) for s in reply_shapes.SHAPES.values())
@@ -91,12 +91,12 @@ def delivered_surfaces() -> dict[str, str]:
- `shapes` — the reply shapes the server delivers at their moments,
in the full form a session first sees
"""
server = (ROOT / "src/scribe/mcp/server.py").read_text()
server = (ROOT / "src/grimoire/mcp/server.py").read_text()
match = re.search(r'_INSTRUCTIONS = """(.*?)"""', server, re.S)
assert match, "server.py no longer defines _INSTRUCTIONS as a triple-quoted literal"
surfaces = {
"instructions": match.group(1),
"docstrings": "".join(p.read_text() for p in sorted((ROOT / "src/scribe/mcp/tools").glob("*.py"))),
"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(),
"commands": "".join(p.read_text() for p in sorted((ROOT / "plugin/commands").glob("*.md"))),
"live": _live_session_context_source(),
+3 -3
View File
@@ -6,8 +6,8 @@ validation is pinned here; the effects are the integration tests.
Rules are not an inception choice: a rule in a rulebook is global, so there is
nothing to choose.
"""
from scribe.models.project import Project
from scribe.services.inception import (
from grimoire.models.project import Project
from grimoire.services.inception import (
CHOICE_KEYS, INCEPTION_VIAS, is_decided, normalize_choices, validate_inception,
)
@@ -76,7 +76,7 @@ def test_standard_systems_vocabulary_reads_the_catalog_not_a_constant():
read the table, or the list they show and the list they mint diverge."""
import inspect
from scribe.services import systems as systems_svc
from grimoire.services import systems as systems_svc
assert not hasattr(systems_svc, "STANDARD_SYSTEMS"), (
"the constant is gone — the catalog table is the single source"
+2 -2
View File
@@ -35,7 +35,7 @@ def _all_surfaces() -> list[tuple[str, str]]:
for p in (ROOT / "plugin" / "skills").rglob("*.md")]
found += [(str(p.relative_to(ROOT)), p.read_text())
for p in (ROOT / "plugin" / "hooks").glob("*.md")]
server = ROOT / "src" / "scribe" / "mcp" / "server.py"
server = ROOT / "src" / "grimoire" / "mcp" / "server.py"
found.append((str(server.relative_to(ROOT)), server.read_text()))
return found
@@ -43,7 +43,7 @@ def _all_surfaces() -> list[tuple[str, str]]:
def _instructions_text() -> str:
"""The _INSTRUCTIONS literal from server.py, as the client would see it."""
import re
src = (ROOT / "src" / "scribe" / "mcp" / "server.py").read_text()
src = (ROOT / "src" / "grimoire" / "mcp" / "server.py").read_text()
match = re.search(r'_INSTRUCTIONS = """(.*?)"""', src, re.S)
assert match, "server.py no longer defines _INSTRUCTIONS as a triple-quoted literal"
return match.group(1).strip()
@@ -23,12 +23,12 @@ import pytest_asyncio
from datetime import datetime, timezone
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.code_shape import CodeShape
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.user import User
from scribe.services import backup
from grimoire.models import async_session
from grimoire.models.code_shape import CodeShape
from grimoire.models.note import Note
from grimoire.models.project import Project
from grimoire.models.user import User
from grimoire.services import backup
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
@@ -15,11 +15,11 @@ import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.note import Note
from scribe.models.user import User
from scribe.services import backup
from scribe.services.backup_file import write_archive_to
from grimoire.models import async_session
from grimoire.models.note import Note
from grimoire.models.user import User
from grimoire.services import backup
from grimoire.services.backup_file import write_archive_to
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
@@ -1,5 +1,5 @@
"""Real-Postgres proof that the scheduled backup's FILE restores: a payload
written as the tar, read back through `scribe.restore`'s loader, and fed to
written as the tar, read back through `grimoire.restore`'s loader, and fed to
the real `restore_full_backup`.
The unit lane already shows the tar hands back the dict it was given. What
@@ -11,14 +11,14 @@ import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.milestone import Milestone
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.user import User
from scribe.restore import load_backup, run
from scribe.services import backup
from scribe.services.backup_file import write_archive
from grimoire.models import async_session
from grimoire.models.milestone import Milestone
from grimoire.models.note import Note
from grimoire.models.project import Project
from grimoire.models.user import User
from grimoire.restore import load_backup, run
from grimoire.services import backup
from grimoire.services.backup_file import write_archive
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
@@ -15,10 +15,10 @@ import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.note import Note
from scribe.models.user import User
from scribe.services import backup
from grimoire.models import async_session
from grimoire.models.note import Note
from grimoire.models.user import User
from grimoire.services import backup
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
@@ -23,14 +23,14 @@ import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.note import Note
from scribe.models.rule_usage import (
from grimoire.models import async_session
from grimoire.models.note import Note
from grimoire.models.rule_usage import (
APPLIED, DEPARTED, PULLED, SURFACED, RuleUsageEvent,
)
from scribe.models.rulebook import Rule, Rulebook, RulebookTopic
from scribe.models.user import User
from scribe.services import backup
from grimoire.models.rulebook import Rule, Rulebook, RulebookTopic
from grimoire.models.user import User
from grimoire.services import backup
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
@@ -23,11 +23,11 @@ import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.rule_version import RuleVersion
from scribe.models.rulebook import Rule, Rulebook, RulebookTopic
from scribe.models.user import User
from scribe.services import backup
from grimoire.models import async_session
from grimoire.models.rule_version import RuleVersion
from grimoire.models.rulebook import Rule, Rulebook, RulebookTopic
from grimoire.models.user import User
from grimoire.services import backup
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
+1 -1
View File
@@ -8,7 +8,7 @@ AttributeError, reporting 0/6) passes the unit suite but fails here.
"""
import pytest
from scribe.services.db_maintenance import (
from grimoire.services.db_maintenance import (
MAINTENANCE_TABLES,
get_table_health,
run_maintenance,
@@ -20,12 +20,12 @@ import pytest
import pytest_asyncio
from sqlalchemy import delete, select
from scribe.models import async_session
from scribe.models.embedding import NoteEmbedding, RuleEmbedding
from scribe.models.note import Note
from scribe.models.rulebook import Rulebook
from scribe.services import embeddings as emb
from scribe.services import rulebooks as rulebooks_svc
from grimoire.models import async_session
from grimoire.models.embedding import NoteEmbedding, RuleEmbedding
from grimoire.models.note import Note
from grimoire.models.rulebook import Rulebook
from grimoire.services import embeddings as emb
from grimoire.services import rulebooks as rulebooks_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
+20 -20
View File
@@ -19,16 +19,16 @@ import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.canonical_system import CanonicalSystem
from scribe.models.family import FamilyAdoption, FamilyDecision, FamilyIdea, Platform, ProjectPlatform
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.system import RecordSystem, System
from scribe.models.task_log import TaskLog
from scribe.models.user import User
from scribe.services import family as family_svc
from scribe.services import family_adoption as adoption_svc
from grimoire.models import async_session
from grimoire.models.canonical_system import CanonicalSystem
from grimoire.models.family import FamilyAdoption, FamilyDecision, FamilyIdea, Platform, ProjectPlatform
from grimoire.models.note import Note
from grimoire.models.project import Project
from grimoire.models.system import RecordSystem, System
from grimoire.models.task_log import TaskLog
from grimoire.models.user import User
from grimoire.services import family as family_svc
from grimoire.services import family_adoption as adoption_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
@@ -63,7 +63,7 @@ async def _clean():
@pytest.fixture(autouse=True)
def _no_meaning():
with patch("scribe.services.embeddings.semantic_search_notes", AsyncMock(return_value=[])):
with patch("grimoire.services.embeddings.semantic_search_notes", AsyncMock(return_value=[])):
yield
@@ -409,7 +409,7 @@ B_SHAPES = [
async def _reference(f, *, language: str = "kotlin", name: str = "installUpdate") -> int:
from scribe.services import snippets as snippets_svc
from grimoire.services import snippets as snippets_svc
snippet = await snippets_svc.create_snippet(
f["owner"], name=f"{name} ({language})", code=KOTLIN_BODY, language=language,
@@ -422,7 +422,7 @@ async def _reference(f, *, language: str = "kotlin", name: str = "installUpdate"
async def shapes(family):
"""Android two's ledger has two live shapes, and the idea has a Kotlin
reference in android one."""
from scribe.services.shape_ledger import sync_repo_shapes
from grimoire.services.shape_ledger import sync_repo_shapes
await sync_repo_shapes(family["b"], REPO, B_SHAPES, seen_marker="main")
family["kotlin"] = await _reference(family)
@@ -431,7 +431,7 @@ async def shapes(family):
async def _classify(f, project: str, symbol: str, status: str, **item):
from scribe.services import shape_ledger
from grimoire.services import shape_ledger
path = next(p for p, _, s in B_SHAPES if s == symbol)
return await shape_ledger.classify_shapes(
@@ -461,7 +461,7 @@ async def test_a_shape_classified_against_the_idea_answers_it_adopted(shapes):
async def test_idea_id_prefers_the_reference_in_the_shapes_language(shapes):
from scribe.services.shape_ledger import live_rows
from grimoire.services.shape_ledger import live_rows
python_ref = await _reference(shapes, language="python", name="install_update")
await adoption_svc.set_references(shapes["owner"], shapes["note"],
@@ -473,7 +473,7 @@ async def test_idea_id_prefers_the_reference_in_the_shapes_language(shapes):
async def test_an_idea_with_no_reference_is_refused_and_nothing_applies(family):
from scribe.services.shape_ledger import live_rows, sync_repo_shapes
from grimoire.services.shape_ledger import live_rows, sync_repo_shapes
await sync_repo_shapes(family["b"], REPO, B_SHAPES, seen_marker="main")
with pytest.raises(ValueError, match="no reference implementation"):
@@ -532,7 +532,7 @@ async def test_nothing_is_answered_or_proposed_across_projects_sharing_no_platfo
"""The Go service shares no platform with the idea: its shape judged
against the reference is plain canon use, and the proposer never offers
the reference to it."""
from scribe.services import shape_ledger
from grimoire.services import shape_ledger
await shape_ledger.sync_repo_shapes(shapes["c"], "git.example/go", [
("cmd/update.go", "sym", "PushUpdate")], seen_marker="main")
@@ -548,7 +548,7 @@ async def test_nothing_is_answered_or_proposed_across_projects_sharing_no_platfo
async def test_the_proposer_offers_the_family_reference_as_the_top_hit(shapes):
from scribe.services import shape_ledger
from grimoire.services import shape_ledger
body = ("def push_update(apk_path: str) -> None:\n"
" session = installer.open_session(installer.create_session())\n"
@@ -557,7 +557,7 @@ async def test_the_proposer_offers_the_family_reference_as_the_top_hit(shapes):
defs = [("tools/release.py", "sym", "push_update", "def push_update(apk_path: str) -> None:",
"sha-push", body)]
ref = type("Hit", (), {"id": shapes["kotlin"]})()
with patch("scribe.services.embeddings.semantic_search_notes",
with patch("grimoire.services.embeddings.semantic_search_notes",
AsyncMock(return_value=[(0.73, ref)])):
await shape_ledger.propose_for_repo(shapes["owner"], shapes["b"], REPO, defs)
row = next(r for r in await shape_ledger.live_rows(shapes["b"]) if r.symbol == "push_update")
@@ -568,7 +568,7 @@ async def test_the_proposer_offers_the_family_reference_as_the_top_hit(shapes):
async def test_a_conflict_brings_the_losing_sides_variant_shapes_back_to_the_todo(shapes):
from scribe.services.shape_ledger import live_rows
from grimoire.services.shape_ledger import live_rows
await _classify(shapes, "b", "selfUpdate", "variant", idea_id=shapes["note"],
reason="signs in CI with a throwaway key")
+6 -6
View File
@@ -21,15 +21,15 @@ import pytest_asyncio
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from scribe.models import async_session
from scribe.models.family import (
from grimoire.models import async_session
from grimoire.models.family import (
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform,
FamilyIdeaReference, Platform, ProjectPlatform,
)
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.user import User
from scribe.services import backup
from grimoire.models.note import Note
from grimoire.models.project import Project
from grimoire.models.user import User
from grimoire.services import backup
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
+12 -12
View File
@@ -18,16 +18,16 @@ import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.family import (
from grimoire.models import async_session
from grimoire.models.family import (
FamilyAdoption, FamilyDecision, FamilyIdea, Platform, ProjectPlatform,
)
from scribe.models.milestone import Milestone
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.rulebook import Rule, Rulebook, RulebookTopic
from scribe.models.user import User
from scribe.services import family as family_svc
from grimoire.models.milestone import Milestone
from grimoire.models.note import Note
from grimoire.models.project import Project
from grimoire.models.rulebook import Rule, Rulebook, RulebookTopic
from grimoire.models.user import User
from grimoire.services import family as family_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
@@ -62,7 +62,7 @@ async def _clean():
@pytest.fixture(autouse=True)
def _no_meaning():
with patch("scribe.services.embeddings.semantic_search_notes", AsyncMock(return_value=[])):
with patch("grimoire.services.embeddings.semantic_search_notes", AsyncMock(return_value=[])):
yield
@@ -261,7 +261,7 @@ async def test_a_repeat_on_a_shared_platform_opens_an_evaluation(family):
repeat = await _note_in(family["b"], family["owner"], "One keystore, two channels.")
async with async_session() as s:
original = await s.get(Note, family["note"])
with patch("scribe.services.embeddings.semantic_search_notes",
with patch("grimoire.services.embeddings.semantic_search_notes",
AsyncMock(return_value=[(0.86, original)])):
hint = await family_svc.repeat_trigger(family["owner"], repeat)
assert hint and f"#{family['note']}" in hint
@@ -272,7 +272,7 @@ async def test_a_repeat_with_no_shared_platform_is_silent(family):
unrelated = await _note_in(family["c"], family["owner"], "One keystore, two channels.")
async with async_session() as s:
original = await s.get(Note, family["note"])
with patch("scribe.services.embeddings.semantic_search_notes",
with patch("grimoire.services.embeddings.semantic_search_notes",
AsyncMock(return_value=[(0.86, original)])):
assert await family_svc.repeat_trigger(family["owner"], unrelated) is None
assert await _idea(family["note"]) is None
@@ -292,7 +292,7 @@ async def test_a_repeat_across_kinds_is_silent(family):
await s.refresh(snippet)
original = await s.get(Note, family["note"])
assert original.note_type == "note"
with patch("scribe.services.embeddings.semantic_search_notes",
with patch("grimoire.services.embeddings.semantic_search_notes",
AsyncMock(return_value=[(0.86, original)])):
assert await family_svc.repeat_trigger(family["owner"], snippet) is None
assert await _idea(family["note"]) is None
+8 -8
View File
@@ -18,16 +18,16 @@ import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.embedding import EMBEDDING_DIM, NoteEmbedding
from scribe.models.family import (
from grimoire.models import async_session
from grimoire.models.embedding import EMBEDDING_DIM, NoteEmbedding
from grimoire.models.family import (
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform, FamilyIdeaReference,
Platform, ProjectPlatform,
)
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.services import family_adoption as adoption_svc
from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_notes
from grimoire.models.note import Note
from grimoire.models.project import Project
from grimoire.services import family_adoption as adoption_svc
from grimoire.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_notes
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
@@ -102,7 +102,7 @@ def _family(corpus) -> set[int]:
async def _search(uid, **kw):
with patch("scribe.services.embeddings.get_embedding", AsyncMock(return_value=QUERY_VEC)):
with patch("grimoire.services.embeddings.get_embedding", AsyncMock(return_value=QUERY_VEC)):
hits = await semantic_search_notes(uid, "how does the app update itself", limit=20, **kw)
return {note.id for _score, note in hits}
+6 -6
View File
@@ -14,11 +14,11 @@ from unittest.mock import patch
import pytest
import pytest_asyncio
from scribe.config import Config
from scribe.models import async_session
from scribe.models.project import Project
from scribe.services.forge import get_forges
from scribe.services.forge_connections import (
from grimoire.config import Config
from grimoire.models import async_session
from grimoire.models.project import Project
from grimoire.services.forge import get_forges
from grimoire.services.forge_connections import (
create_connection,
delete_connection,
list_connections,
@@ -178,7 +178,7 @@ async def test_the_token_is_stored_sealed_and_a_key_change_drops_the_row(seeded)
rather than sending ciphertext to the forge as a token."""
from sqlalchemy import text
from scribe.services import secrets
from grimoire.services import secrets
owner = seeded["owner"]
row = await create_connection(owner, kind="gitea", base_url=GITEA, token="plain-tok")
+7 -7
View File
@@ -9,13 +9,13 @@ from datetime import datetime, timedelta, timezone
import pytest
import pytest_asyncio
from scribe.models import async_session
from scribe.models.milestone import Milestone
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.task_log import TaskLog
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
from grimoire.models import async_session
from grimoire.models.milestone import Milestone
from grimoire.models.note import Note
from grimoire.models.project import Project
from grimoire.models.task_log import TaskLog
from grimoire.services import milestones as milestones_svc
from grimoire.services import notes as notes_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine", "_no_embedding")]
+5 -5
View File
@@ -10,11 +10,11 @@ global and applies to every project, so there is nothing to subscribe to.
import pytest
import pytest_asyncio
from scribe.models import async_session
from scribe.models.project import Project
from scribe.services import inception as inception_svc
from scribe.services import canonical_systems as canonical_svc
from scribe.services import systems as systems_svc
from grimoire.models import async_session
from grimoire.models.project import Project
from grimoire.services import inception as inception_svc
from grimoire.services import canonical_systems as canonical_svc
from grimoire.services import systems as systems_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
+4 -4
View File
@@ -27,10 +27,10 @@ import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.note import Note
from scribe.services import lessons as lessons_svc
from scribe.services import notes as notes_svc
from grimoire.models import async_session
from grimoire.models.note import Note
from grimoire.services import lessons as lessons_svc
from grimoire.services import notes as notes_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
+7 -7
View File
@@ -18,12 +18,12 @@ from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
from scribe.models import async_session
from scribe.models.embedding import EMBEDDING_DIM, NoteEmbedding
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.services import lessons as lessons_svc
from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_notes
from grimoire.models import async_session
from grimoire.models.embedding import EMBEDDING_DIM, NoteEmbedding
from grimoire.models.note import Note
from grimoire.models.project import Project
from grimoire.services import lessons as lessons_svc
from grimoire.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_notes
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
@@ -81,7 +81,7 @@ async def corpus():
async def _search(uid, **kw):
with patch(
"scribe.services.embeddings.get_embedding", AsyncMock(return_value=QUERY_VEC)
"grimoire.services.embeddings.get_embedding", AsyncMock(return_value=QUERY_VEC)
):
hits = await semantic_search_notes(uid, "when does this apply", limit=20, **kw)
return {note.id for _score, note in hits}
+10 -10
View File
@@ -13,14 +13,14 @@ import pytest_asyncio
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from scribe.models import async_session
from scribe.models.lesson_rule_link import LessonRuleLink
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.rulebook import Rule
from scribe.services import lesson_rules as links_svc
from scribe.services import lessons as lessons_svc
from scribe.services import rulebooks as rulebooks_svc
from grimoire.models import async_session
from grimoire.models.lesson_rule_link import LessonRuleLink
from grimoire.models.note import Note
from grimoire.models.project import Project
from grimoire.models.rulebook import Rule
from grimoire.services import lesson_rules as links_svc
from grimoire.services import lessons as lessons_svc
from grimoire.services import rulebooks as rulebooks_svc
from tests.helpers import ensure_user
pytestmark = [
@@ -32,7 +32,7 @@ pytestmark = [
@pytest.fixture(autouse=True)
def _no_reindex():
"""Rule writes detach an embedding refresh that outlives the test's loop."""
with patch("scribe.services.rulebooks._refresh_rule_embedding", MagicMock()):
with patch("grimoire.services.rulebooks._refresh_rule_embedding", MagicMock()):
yield
@@ -231,7 +231,7 @@ async def test_the_unjudged_list_is_scoped_to_the_reader(world):
async def test_deleting_the_lesson_takes_its_answer_with_it(world):
from scribe.models.lesson_rule_link import LessonNoRule
from grimoire.models.lesson_rule_link import LessonNoRule
await links_svc.set_no_rule(world["owner"], world["lesson"], "alone")
async with async_session() as s:
+8 -8
View File
@@ -11,12 +11,12 @@ from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
from scribe.models import async_session
from scribe.models.embedding import EMBEDDING_DIM, MilestoneEmbedding
from scribe.models.milestone import Milestone
from scribe.models.project import Project
from scribe.services import dedup as dedup_svc
from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_milestones
from grimoire.models import async_session
from grimoire.models.embedding import EMBEDDING_DIM, MilestoneEmbedding
from grimoire.models.milestone import Milestone
from grimoire.models.project import Project
from grimoire.services import dedup as dedup_svc
from grimoire.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_milestones
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine", "_no_embedding")]
@@ -56,7 +56,7 @@ async def roadmap():
async def _found(user_id, **kw) -> list[int]:
with patch("scribe.services.embeddings.get_embedding", AsyncMock(return_value=NEAR)):
with patch("grimoire.services.embeddings.get_embedding", AsyncMock(return_value=NEAR)):
hits = await semantic_search_milestones(user_id, "book metadata and providers",
threshold=0.5, limit=10, **kw)
return [m.id for _s, m in hits]
@@ -89,7 +89,7 @@ LONG = "Resolve works and editions against the metadata providers. " * 5
async def _gate(roadmap, title, text="", project=None):
with patch("scribe.services.embeddings.get_embedding", AsyncMock(return_value=NEAR)):
with patch("grimoire.services.embeddings.get_embedding", AsyncMock(return_value=NEAR)):
return await dedup_svc.plan_gate(
roadmap["owner"], project or roadmap["mine"], title, text,
)
+3 -3
View File
@@ -10,9 +10,9 @@ import pytest
import pytest_asyncio
from sqlalchemy import delete, select
from scribe.models import async_session
from scribe.models.moment_mapping import MomentMapping
from scribe.services import moment_actions as ma
from grimoire.models import async_session
from grimoire.models.moment_mapping import MomentMapping
from grimoire.services import moment_actions as ma
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
+8 -8
View File
@@ -16,11 +16,11 @@ import pytest
import pytest_asyncio
from sqlalchemy import delete
from scribe.models import async_session
from scribe.models.embedding import EMBEDDING_DIM, NoteEmbedding
from scribe.models.note import Note
from scribe.models.user import User
from scribe.services.embeddings import semantic_search_notes
from grimoire.models import async_session
from grimoire.models.embedding import EMBEDDING_DIM, NoteEmbedding
from grimoire.models.note import Note
from grimoire.models.user import User
from grimoire.services.embeddings import semantic_search_notes
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
@@ -33,7 +33,7 @@ def _vec(*nonzero_first):
def _emb(note_id, user_id, chunk_index, vec):
"""A chunk row at the current chunker version."""
from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL
from grimoire.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL
return NoteEmbedding(
note_id=note_id,
@@ -83,7 +83,7 @@ async def seeded():
async def test_semantic_search_ranks_and_thresholds_via_pgvector(seeded):
user_id, near_id, far_id = seeded
with patch(
"scribe.services.embeddings.get_embedding",
"grimoire.services.embeddings.get_embedding",
AsyncMock(return_value=_vec(1.0)),
):
results = await semantic_search_notes(user_id=user_id, query="anything", limit=10)
@@ -105,7 +105,7 @@ async def test_semantic_search_ranks_and_thresholds_via_pgvector(seeded):
async def test_low_threshold_lets_orthogonal_through(seeded):
user_id, near_id, far_id = seeded
with patch(
"scribe.services.embeddings.get_embedding",
"grimoire.services.embeddings.get_embedding",
AsyncMock(return_value=_vec(1.0)),
):
results = await semantic_search_notes(
+7 -7
View File
@@ -8,13 +8,13 @@ of steps they cannot open.
import pytest
import pytest_asyncio
from scribe.models import async_session
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.share import NoteShare, ProjectShare
from scribe.services import notes as notes_svc
from scribe.services.placement import task_placement
from scribe.services.record_batch import BatchItem, BatchMilestone, create_batch
from grimoire.models import async_session
from grimoire.models.note import Note
from grimoire.models.project import Project
from grimoire.models.share import NoteShare, ProjectShare
from grimoire.services import notes as notes_svc
from grimoire.services.placement import task_placement
from grimoire.services.record_batch import BatchItem, BatchMilestone, create_batch
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine", "_no_embedding")]
+5 -5
View File
@@ -10,11 +10,11 @@ import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.family import Platform
from scribe.models.project import Project
from scribe.services import inception as inception_svc
from scribe.services import platforms as platforms_svc
from grimoire.models import async_session
from grimoire.models.family import Platform
from grimoire.models.project import Project
from grimoire.services import inception as inception_svc
from grimoire.services import platforms as platforms_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
+5 -5
View File
@@ -28,11 +28,11 @@ import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.note import Note
from scribe.models.rulebook import Rulebook
from scribe.models.rule_version import RuleVersion
from scribe.services import rulebooks as rulebooks_svc
from grimoire.models import async_session
from grimoire.models.note import Note
from grimoire.models.rulebook import Rulebook
from grimoire.models.rule_version import RuleVersion
from grimoire.services import rulebooks as rulebooks_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
+7 -7
View File
@@ -12,13 +12,13 @@ import pytest
import pytest_asyncio
from sqlalchemy import func, select
from scribe.models import async_session
from scribe.models.milestone import Milestone
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.services import record_refs
from scribe.services.planning import start_planning
from scribe.services.record_batch import BatchItem, create_batch
from grimoire.models import async_session
from grimoire.models.milestone import Milestone
from grimoire.models.note import Note
from grimoire.models.project import Project
from grimoire.services import record_refs
from grimoire.services.planning import start_planning
from grimoire.services.record_batch import BatchItem, create_batch
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine", "_no_embedding")]
+3 -3
View File
@@ -24,9 +24,9 @@ import pytest_asyncio
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from scribe.models import async_session
from scribe.models.rulebook import Rule, Rulebook
from scribe.services import rulebooks as rulebooks_svc
from grimoire.models import async_session
from grimoire.models.rulebook import Rule, Rulebook
from grimoire.services import rulebooks as rulebooks_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
@@ -16,12 +16,12 @@ import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.rule_moment_judgment import RuleMomentJudgment
from scribe.models.rulebook import Rulebook
from scribe.models.user import User
from scribe.services import rule_moment_judgments as judgments_svc
from scribe.services import rulebooks as rulebooks_svc
from grimoire.models import async_session
from grimoire.models.rule_moment_judgment import RuleMomentJudgment
from grimoire.models.rulebook import Rulebook
from grimoire.models.user import User
from grimoire.services import rule_moment_judgments as judgments_svc
from grimoire.services import rulebooks as rulebooks_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
@@ -207,7 +207,7 @@ def days(monkeypatch):
queue: list[str] = []
def situation(_now):
from scribe.services import lesson_rules
from grimoire.services import lesson_rules
return lesson_rules.situation_key("d", queue.pop(0))
monkeypatch.setattr(judgments_svc, "misfire_situation", situation)
+12 -12
View File
@@ -14,14 +14,14 @@ import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.project import Project
from scribe.models.rule_moment_judgment import RuleMomentJudgment
from scribe.models.rulebook import Rule, Rulebook, RulebookTopic
from scribe.models.user import User
from scribe.services import backup
from scribe.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc
from grimoire.models import async_session
from grimoire.models.project import Project
from grimoire.models.rule_moment_judgment import RuleMomentJudgment
from grimoire.models.rulebook import Rule, Rulebook, RulebookTopic
from grimoire.models.user import User
from grimoire.services import backup
from grimoire.services import projects as projects_svc
from grimoire.services import rulebooks as rulebooks_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
@@ -119,7 +119,7 @@ async def test_a_backup_carries_the_mounts_to_the_restored_rule(world):
await rulebooks_svc.set_rule_moments(rule.id, uid, ["work.finish", "reply.report"])
# A misfire report on one of them (v24) — the operator's evidence for
# taking a mount off must not be lost to a restore.
from scribe.services import rule_moment_judgments as judgments_svc
from grimoire.services import rule_moment_judgments as judgments_svc
reported = await judgments_svc.misfired(
uid, rule.id, "reply.report", why="the reply only asked a question",
reached_by="the reply that ends this turn",
@@ -245,9 +245,9 @@ async def test_moment_usage_counts_deliveries_and_the_agent_opens_that_followed(
from sqlalchemy import delete
from scribe.models.rule_usage import PULLED, SURFACED, RuleUsageEvent
from scribe.services.retrieval_pipeline import MOMENT_RULE_SOURCE
from scribe.services.retrieval_telemetry import moment_usage
from grimoire.models.rule_usage import PULLED, SURFACED, RuleUsageEvent
from grimoire.services.retrieval_pipeline import MOMENT_RULE_SOURCE
from grimoire.services.retrieval_telemetry import moment_usage
uid, sid, rule = world["uid"], world["sid"], world["rule"]
other = await rulebooks_svc.create_rule(
+7 -7
View File
@@ -14,12 +14,12 @@ from unittest.mock import MagicMock, patch
import pytest
import pytest_asyncio
from scribe.models import async_session
from scribe.models.project import Project
from scribe.models.rulebook import Rule
from scribe.services import canonical_systems as canonical_svc
from scribe.services import rule_versions as rv_svc
from scribe.services import rulebooks as rulebooks_svc
from grimoire.models import async_session
from grimoire.models.project import Project
from grimoire.models.rulebook import Rule
from grimoire.services import canonical_systems as canonical_svc
from grimoire.services import rule_versions as rv_svc
from grimoire.services import rulebooks as rulebooks_svc
from tests.helpers import ensure_user, rule_row
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
@@ -29,7 +29,7 @@ pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine"
def _no_reindex():
"""Rule writes detach an embedding refresh that outlives the test's loop
and races the next fixture; nothing here is about recall."""
with patch("scribe.services.rulebooks._refresh_rule_embedding", MagicMock()):
with patch("grimoire.services.rulebooks._refresh_rule_embedding", MagicMock()):
yield
+10 -10
View File
@@ -14,12 +14,12 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from scribe.models import async_session
from scribe.models.embedding import EMBEDDING_DIM, RuleEmbedding
from scribe.models.project import Project
from scribe.models.share import ProjectShare
from scribe.services import rulebooks as rulebooks_svc
from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_rules
from grimoire.models import async_session
from grimoire.models.embedding import EMBEDDING_DIM, RuleEmbedding
from grimoire.models.project import Project
from grimoire.models.share import ProjectShare
from grimoire.services import rulebooks as rulebooks_svc
from grimoire.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_rules
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
@@ -46,7 +46,7 @@ async def homes():
ids = {"owner": owner.id, "collaborator": collaborator.id, "a": a.id, "b": b.id}
await s.commit()
with patch("scribe.services.rulebooks._refresh_rule_embedding", MagicMock()):
with patch("grimoire.services.rulebooks._refresh_rule_embedding", MagicMock()):
book = await rulebooks_svc.create_rulebook(ids["owner"], "Scope house style")
topic = await rulebooks_svc.create_topic(book.id, ids["owner"], "everywhere")
glob = await rulebooks_svc.create_rule(
@@ -81,9 +81,9 @@ async def _found(user_id: int, **scope) -> set[int]:
same."""
report: dict = {}
raised: list[str] = []
with patch("scribe.services.embeddings.get_embedding",
with patch("grimoire.services.embeddings.get_embedding",
AsyncMock(return_value=QUERY_VEC)), \
patch("scribe.services.embeddings.logger") as log:
patch("grimoire.services.embeddings.logger") as log:
# Called inside the except block, so the traceback is still current.
log.warning.side_effect = lambda *_a, **_k: raised.append(traceback.format_exc())
hits = await semantic_search_rules(user_id, "anything", limit=10,
@@ -139,7 +139,7 @@ async def test_an_in_scope_rule_is_found_however_many_closer_rules_are_out_of_sc
mine_vec = [0.8, 0.6] + [0.0] * (EMBEDDING_DIM - 2) # cosine 0.8
books = []
try:
with patch("scribe.services.rulebooks._refresh_rule_embedding", MagicMock()):
with patch("grimoire.services.rulebooks._refresh_rule_embedding", MagicMock()):
book = await rulebooks_svc.create_rulebook(reader_id, "Reader's rules")
books.append((book.id, reader_id))
topic = await rulebooks_svc.create_topic(book.id, reader_id, "mine")
+5 -5
View File
@@ -17,11 +17,11 @@ planning read carries — and what mocks can't prove about it:
import pytest
import pytest_asyncio
from scribe.models import async_session
from scribe.models.project import Project
from scribe.services import canonical_systems as canonical_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc
from grimoire.models import async_session
from grimoire.models.project import Project
from grimoire.services import canonical_systems as canonical_svc
from grimoire.services import rulebooks as rulebooks_svc
from grimoire.services import systems as systems_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
+3 -3
View File
@@ -20,9 +20,9 @@ from datetime import datetime, timezone
import pytest
import pytest_asyncio
from scribe.models import async_session
from scribe.models.rulebook import Rule
from scribe.services import rulebooks as rulebooks_svc
from grimoire.models import async_session
from grimoire.models.rulebook import Rule
from grimoire.services import rulebooks as rulebooks_svc
from tests.helpers import ensure_user, rule_row
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
+5 -5
View File
@@ -19,11 +19,11 @@ import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.rulebook import Rule, Rulebook
from scribe.models.rule_version import RuleVersion
from scribe.services import rule_versions as rv_svc
from scribe.services import rulebooks as rulebooks_svc
from grimoire.models import async_session
from grimoire.models.rulebook import Rule, Rulebook
from grimoire.models.rule_version import RuleVersion
from grimoire.services import rule_versions as rv_svc
from grimoire.services import rulebooks as rulebooks_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
+11 -11
View File
@@ -20,17 +20,17 @@ from unittest.mock import AsyncMock, patch
import pytest
from sqlalchemy import delete
from scribe.models import async_session
from scribe.models.embedding import (
from grimoire.models import async_session
from grimoire.models.embedding import (
EMBEDDING_DIM, MilestoneEmbedding, NoteEmbedding, SystemEmbedding,
)
from scribe.models.milestone import Milestone
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.system import System
from scribe.models.user import User
from scribe.services import embeddings as emb
from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL
from grimoire.models.milestone import Milestone
from grimoire.models.note import Note
from grimoire.models.project import Project
from grimoire.models.system import System
from grimoire.models.user import User
from grimoire.services import embeddings as emb
from grimoire.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine", "_no_embedding")]
@@ -59,9 +59,9 @@ async def _found(search, user_id: int, **scope) -> set[int]:
reads as an empty result — the swallowed traceback is captured and named
in the failure instead."""
raised: list[str] = []
with patch("scribe.services.embeddings.get_embedding",
with patch("grimoire.services.embeddings.get_embedding",
AsyncMock(return_value=QUERY_VEC)), \
patch("scribe.services.embeddings.logger") as log:
patch("grimoire.services.embeddings.logger") as log:
log.warning.side_effect = lambda *_a, **_k: raised.append(traceback.format_exc())
hits = await search(user_id, "anything", limit=10, threshold=0.5, **scope)
assert not raised, "the search raised:\n" + "\n".join(raised)
+41 -41
View File
@@ -9,11 +9,11 @@ import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.code_shape import CodeShape
from scribe.models.project import Project
from scribe.models.user import User
from scribe.services.shape_ledger import (
from grimoire.models import async_session
from grimoire.models.code_shape import CodeShape
from grimoire.models.project import Project
from grimoire.models.user import User
from grimoire.services.shape_ledger import (
classify_shapes,
classify_shapes_where,
list_project_shapes,
@@ -36,7 +36,7 @@ SHAPES = [
@pytest_asyncio.fixture
async def seeded():
"""Owner + outsider, a project with a synced 4-shape ledger, one snippet."""
from scribe.services import snippets as snippets_svc
from grimoire.services import snippets as snippets_svc
async with async_session() as s:
owner = await ensure_user(s, "classify_owner")
@@ -174,7 +174,7 @@ async def test_sync_stamps_scoped_rows_and_unstamps_when_they_become_reachable(s
"""By-construction one-offs arrive `scoped` (mechanical), count as
accounted, are reached by the sweep's default, and go back to the todo
if a later tree makes them ordinary. A judgment overrides the stamp."""
from scribe.services.coverage import ArchiveShape
from grimoire.services.coverage import ArchiveShape
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
scoped_shapes = [
ArchiveShape("web/Card.vue", "css", "card", ".card {", "s1", ".card { x: 1 }", True),
@@ -206,7 +206,7 @@ async def test_uses_edges_are_the_consumer_map(seeded):
"""A shape keeps ONE snippet_id (what it is) and any number of
uses edges (what it calls); the snippet's consumer map lists them,
list_shapes(uses=N) finds them, and a sweep can write them."""
from scribe.services import snippets as snippets_svc
from grimoire.services import snippets as snippets_svc
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
helper = await snippets_svc.create_snippet(
owner, name="cls_hash_helper", code="def hash_token(raw):\n return raw\n",
@@ -232,7 +232,7 @@ async def test_uses_edges_are_the_consumer_map(seeded):
assert (await list_project_shapes(owner, pid, uses=hid))[1] == 2
# The proposer writes uses edges for JUDGED rows too: Config (exempt)
# names hash_token in its body → an edge, no proposal.
from scribe.services.shape_ledger import propose_for_repo
from grimoire.services.shape_ledger import propose_for_repo
await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "Config", "status": "exempt", "reason": "settings"},
])
@@ -270,8 +270,8 @@ async def test_list_filters_compose(seeded):
@pytest.mark.integration
async def test_get_snippet_carries_the_structured_consumer_map(seeded):
from scribe.mcp._context import _user_id_ctx
from scribe.mcp.tools.snippets import get_snippet
from grimoire.mcp._context import _user_id_ctx
from grimoire.mcp.tools.snippets import get_snippet
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
await classify_shapes(owner, pid, [
@@ -303,7 +303,7 @@ async def test_sync_refiles_rows_whose_snippet_was_purged(seeded):
{"path": "src/app.py", "symbol": "make_app", "status": "instance",
"snippet_id": sid},
])
from scribe.models.note import Note
from grimoire.models.note import Note
async with async_session() as s:
note = await s.get(Note, sid)
@@ -331,7 +331,7 @@ async def test_write_path_evidence_is_a_proposal_never_a_verdict(seeded):
edge — a fact about the code — is still recorded."""
from datetime import datetime, timezone
from scribe.services.shape_ledger import suggest_write_path_instances
from grimoire.services.shape_ledger import suggest_write_path_instances
owner, other, pid, sid = (
seeded["owner"], seeded["other"], seeded["pid"], seeded["snippet"]
@@ -396,7 +396,7 @@ async def test_a_brand_new_shape_gets_a_provisional_row_carrying_the_suggestion(
carry a suggestion."""
from datetime import datetime, timezone
from scribe.services.shape_ledger import suggest_write_path_instances, unjudged_shapes
from grimoire.services.shape_ledger import suggest_write_path_instances, unjudged_shapes
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
pulled = {sid: datetime.now(timezone.utc)}
@@ -446,8 +446,8 @@ async def test_recent_pulls_reads_the_usage_stream(seeded):
window; a surfacing alone is not a pull."""
from datetime import datetime, timedelta, timezone
from scribe.models.note_usage import PULLED, SURFACED, NoteUsageEvent
from scribe.services.shape_ledger import recent_pulls
from grimoire.models.note_usage import PULLED, SURFACED, NoteUsageEvent
from grimoire.services.shape_ledger import recent_pulls
owner, sid = seeded["owner"], seeded["snippet"]
now = datetime.now(timezone.utc)
@@ -473,7 +473,7 @@ def _quiet_semantic():
bases and the bookkeeping, so it answers "nothing" here."""
from unittest.mock import AsyncMock, patch
from scribe.services import shape_ledger
from grimoire.services import shape_ledger
return patch.object(shape_ledger, "_semantic_canon", AsyncMock(return_value=None))
@@ -494,7 +494,7 @@ async def test_proposer_proposes_and_confirm_classifies(seeded):
nothing for the unrelated — skips rows whose content it already judged,
and a scoped confirm turns proposals into agent instances while a
classify on another retires its proposal."""
from scribe.services.shape_ledger import (
from grimoire.services.shape_ledger import (
confirm_proposals, propose_for_repo,
)
@@ -564,7 +564,7 @@ async def test_derive_groups_land_on_rows_and_in_the_summary(seeded):
"""Shapes with no canon hit that repeat — identical bodies in two files,
the same name in three — carry a derive proposal, and the readout ranks
the families. A canon proposal keeps a row out of any derive group."""
from scribe.services.shape_ledger import (
from grimoire.services.shape_ledger import (
apply_derive_groups, live_rows, propose_for_repo, proposal_summary,
)
@@ -627,7 +627,7 @@ async def test_write_time_derive_names_the_family_or_the_canon_for_a_name(seeded
count; for CSS a NAME family, never a body one); a
name whose canonical row lives elsewhere → that canon; a judged row at
the path, the canon's own file, or an unknown name → silence."""
from scribe.services.shape_ledger import apply_derive_groups, write_time_derive
from grimoire.services.shape_ledger import apply_derive_groups, write_time_derive
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
defs = _defs(
@@ -672,7 +672,7 @@ async def test_derive_new_names_the_copy_that_joined_a_family_since_the_stamp(se
newcomer — the drift since the last refresh, not the whole family."""
from datetime import datetime, timezone
from scribe.services.shape_ledger import (
from grimoire.services.shape_ledger import (
apply_derive_groups, derive_new_summary, live_rows,
)
@@ -704,8 +704,8 @@ async def test_transition_and_prefix_references_clear_the_unused_css_flag(seeded
a transition `name=` and a concatenated name — travel from the real
extractor through resolution into the flag, so rules they reach stop
being reported as unused. Only a rule nothing can reach stays listed."""
from scribe.services.coverage import class_references
from scribe.services.shape_ledger import sync_repo_consumers, used_by_map, live_rows
from grimoire.services.coverage import class_references
from grimoire.services.shape_ledger import sync_repo_consumers, used_by_map, live_rows
pid, owner = seeded["pid"], seeded["owner"]
defs = _defs(
@@ -756,7 +756,7 @@ async def test_consumer_map_syncs_edges_from_template_references(seeded):
resolution for a scoped class, fan-out to the shared sheet for a class a
template does not define, counts refreshed and stale edges removed on
the next sync, and a vanished row's edges gone with it."""
from scribe.services.shape_ledger import consumers_of, live_rows, sync_repo_consumers
from grimoire.services.shape_ledger import consumers_of, live_rows, sync_repo_consumers
pid = seeded["pid"]
defs = _defs(
@@ -791,7 +791,7 @@ async def test_consumer_map_syncs_edges_from_template_references(seeded):
# The readout side: used_by per css row, the unused-css flag, and the
# family's consumers on the write-path check.
from scribe.services.shape_ledger import (
from grimoire.services.shape_ledger import (
apply_derive_groups, used_by_map, write_time_derive,
)
owner = seeded["owner"]
@@ -825,12 +825,12 @@ async def test_a_second_confirm_dialog_is_detected_and_named(seeded):
judgment clears the flag; a shape proposed AS the canon is not flagged."""
from datetime import datetime, timedelta, timezone
from scribe.services.shape_ledger import (
from grimoire.services.shape_ledger import (
divergence_summary, flag_divergence, live_rows, propose_for_repo,
write_time_divergence,
)
from scribe.services import snippets as snippets_svc
from grimoire.services import snippets as snippets_svc
owner, pid = seeded["owner"], seeded["pid"]
# The confirm helper is TS canon: a sym basis only proposes
@@ -917,8 +917,8 @@ async def test_weak_stamps_elect_no_canon_and_their_flags_are_withdrawn(seeded):
from sqlalchemy import update
from scribe.services import snippets as snippets_svc
from scribe.services.shape_ledger import (
from grimoire.services import snippets as snippets_svc
from grimoire.services.shape_ledger import (
_RESEMBLE_REASON, flag_divergence, propose_for_repo, write_time_divergence,
)
@@ -999,9 +999,9 @@ async def test_a_semantic_miss_does_not_silence_the_prompt(seeded):
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
from scribe.services import shape_ledger
from scribe.services import snippets as snippets_svc
from scribe.services.shape_ledger import flag_divergence, live_rows, propose_for_repo
from grimoire.services import shape_ledger
from grimoire.services import snippets as snippets_svc
from grimoire.services.shape_ledger import flag_divergence, live_rows, propose_for_repo
owner, pid = seeded["owner"], seeded["pid"]
canon = await snippets_svc.create_snippet(
@@ -1051,7 +1051,7 @@ async def _two_generations(seeded, tag: str):
"""
from datetime import datetime, timezone
from scribe.services import snippets as snippets_svc
from grimoire.services import snippets as snippets_svc
owner, pid = seeded["owner"], seeded["pid"]
canon = await snippets_svc.create_snippet(
@@ -1092,8 +1092,8 @@ async def test_the_capped_semantic_pass_reaches_the_new_shape_first(seeded):
ONE check, the new shape must be the one that gets it."""
from unittest.mock import AsyncMock, patch
from scribe.services import shape_ledger
from scribe.services.shape_ledger import live_rows, propose_for_repo
from grimoire.services import shape_ledger
from grimoire.services.shape_ledger import live_rows, propose_for_repo
owner, pid, later, _previous = await _two_generations(seeded, "cap")
with patch.object(shape_ledger, "_semantic_canon", AsyncMock(return_value=None)):
@@ -1108,7 +1108,7 @@ async def test_the_capped_semantic_pass_reaches_the_new_shape_first(seeded):
@pytest.mark.integration
async def test_history_records_what_was_used_when_and_drift_asks_for_a_recheck(seeded):
from scribe.services.shape_ledger import shape_history
from grimoire.services.shape_ledger import shape_history
owner, other, pid, sid = (
seeded["owner"], seeded["other"], seeded["pid"], seeded["snippet"]
@@ -1163,7 +1163,7 @@ async def test_a_shape_written_this_turn_is_judged_before_the_sync_sees_it(seede
then reads the shape keeps the judgment, and a sync that does NOT have it
yet (the code has not reached the bound ref) keeps it through the vanish
and the reappearance. Without `repo` the old contract holds."""
from scribe.services import repo_bindings as repo_bindings_svc
from grimoire.services import repo_bindings as repo_bindings_svc
owner, pid = seeded["owner"], seeded["pid"]
await repo_bindings_svc.set_binding(owner, REPO, pid)
@@ -1200,7 +1200,7 @@ async def test_unjudged_is_what_nobody_read(seeded):
audit's verdict is not. Evidence travels; a verdict is never inferred."""
from sqlalchemy import update
from scribe.services.shape_ledger import _RESEMBLE_REASON, unjudged_shapes
from grimoire.services.shape_ledger import _RESEMBLE_REASON, unjudged_shapes
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
await classify_shapes(owner, pid, [
@@ -1238,9 +1238,9 @@ async def test_a_whole_file_snippet_makes_its_file_row_canonical(seeded):
symbol, is the canon for that file row — the claim a whole-file record
makes and a definition row never could. It still covers no definition
inside the file."""
from scribe.services import snippets as snippets_svc
from scribe.services.coverage import _recorded_locations
from scribe.services.shape_ledger import mark_canonicals
from grimoire.services import snippets as snippets_svc
from grimoire.services.coverage import _recorded_locations
from grimoire.services.shape_ledger import mark_canonicals
owner, pid = seeded["owner"], seeded["pid"]
await sync_repo_shapes(pid, REPO, SHAPES + [
+7 -7
View File
@@ -19,11 +19,11 @@ import pytest
import pytest_asyncio
from sqlalchemy import delete, func, select
from scribe.models import async_session
from scribe.models.note import Note
from scribe.models.user import User
from scribe.services.knowledge import location_matches, location_parts
from scribe.services.snippets import (
from grimoire.models import async_session
from grimoire.models.note import Note
from grimoire.models.user import User
from grimoire.services.knowledge import location_matches, location_parts
from grimoire.services.snippets import (
SNIPPET_NOTE_TYPE,
backfill_snippet_data,
compose_body,
@@ -152,7 +152,7 @@ async def test_keyword_arm_applies_the_location_filter(seeded):
keyword (ILIKE) arm, which is the second place the SQL clause has to appear."""
user_id, ids = seeded
with patch(
"scribe.services.embeddings.semantic_search_notes",
"grimoire.services.embeddings.semantic_search_notes",
AsyncMock(return_value=[]),
):
assert await _ids_for(user_id, q="nested", repo="Grimoire") == {ids["nested"]}
@@ -180,7 +180,7 @@ async def test_semantic_arm_applies_the_location_filter(seeded):
candidates = [(0.9, n) for n in rows]
with patch(
"scribe.services.embeddings.semantic_search_notes",
"grimoire.services.embeddings.semantic_search_notes",
AsyncMock(return_value=candidates),
):
# No keyword can match this, so every hit below came from the stub.
+4 -4
View File
@@ -14,8 +14,8 @@ import pytest
import pytest_asyncio
from sqlalchemy.exc import IntegrityError
from scribe.models import async_session
from scribe.models.note import Note
from grimoire.models import async_session
from grimoire.models.note import Note
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
@@ -81,7 +81,7 @@ async def test_a_kind_can_be_corrected_after_the_fact(owner_id):
call succeeded would pass against a route that returns 200 while dropping
the field, which is a failure nothing else reports.
"""
from scribe.services import notes as notes_svc
from grimoire.services import notes as notes_svc
note_id = await _write(owner_id, "work")
await notes_svc.update_note(owner_id, note_id, task_kind="spike")
@@ -91,7 +91,7 @@ async def test_a_kind_can_be_corrected_after_the_fact(owner_id):
async def test_an_invalid_kind_is_refused_before_the_database(owner_id):
"""A readable ValueError, not a CheckViolationError surfacing as a 500."""
from scribe.services import notes as notes_svc
from grimoire.services import notes as notes_svc
note_id = await _write(owner_id, "work")
with pytest.raises(ValueError, match="Invalid kind"):
+2 -2
View File
@@ -14,7 +14,7 @@ makes the trap possible, so it cannot come back by a different route.
"""
import pytest
from scribe.services.knowledge import (
from grimoire.services.knowledge import (
_FACETS,
FACET_TYPES,
NON_TASK_FACETS,
@@ -28,7 +28,7 @@ from tests.helpers import compiled_sql, fake_note, fake_snippet, fake_task
def _sql(note_type):
from sqlalchemy import select
from scribe.models.note import Note
from grimoire.models.note import Note
return compiled_sql(_apply_type_filter(select(Note.id), note_type))
+2 -2
View File
@@ -67,7 +67,7 @@ def _snippet_item(nid, title, user_id=1):
# ── auto-inject ────────────────────────────────────────────────────────────
async def _autoinject(hits, exclude_ids, *, rec=None, surf=None):
from scribe.services import plugin_context as pc
from grimoire.services import plugin_context as pc
search = AsyncMock(return_value=hits)
with patch.object(pc, "get_autoinject_config", AsyncMock(
return_value={"enabled": True, "threshold": 0.55, "top_k": 3})), \
@@ -167,7 +167,7 @@ async def test_the_header_explains_the_seen_marker():
# ── the write path ─────────────────────────────────────────────────────────
async def _write_path(hits, exclude_ids, *, here=(), rec=None, sync_exclude=()):
from scribe.services import plugin_context as pc
from grimoire.services import plugin_context as pc
search = AsyncMock(return_value=hits)
listing = AsyncMock(side_effect=[(list(here), len(here)), ([], 0)])
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_wp_cfg())), \
+8 -8
View File
@@ -13,10 +13,10 @@ from unittest.mock import AsyncMock, patch
import pytest
from scribe.mcp._context import _user_id_ctx
from scribe.mcp.tools.lessons import create_lesson, update_lesson
from scribe.services import lessons as lessons_svc
from scribe.services.lessons import WHAT_MAX_CHARS, claim_line, require_claim
from grimoire.mcp._context import _user_id_ctx
from grimoire.mcp.tools.lessons import create_lesson, update_lesson
from grimoire.services import lessons as lessons_svc
from grimoire.services.lessons import WHAT_MAX_CHARS, claim_line, require_claim
TRIGGER = "an existing call is about to double as a liveness signal"
CLAIM = ("A check-in piggybacked on an existing call inherits every backoff "
@@ -80,8 +80,8 @@ async def test_a_lesson_stored_with_a_long_name_can_still_be_edited():
)
updated = AsyncMock(return_value=note)
with patch.object(lessons_svc, "update_lesson", updated), \
patch("scribe.mcp.tools.lessons.systems_tools.attach_systems", AsyncMock()), \
patch("scribe.mcp.tools.lessons.lesson_rules_svc.attach_lesson_rules", AsyncMock()):
patch("grimoire.mcp.tools.lessons.systems_tools.attach_systems", AsyncMock()), \
patch("grimoire.mcp.tools.lessons.lesson_rules_svc.attach_lesson_rules", AsyncMock()):
await update_lesson(lesson_id=1, when_to_apply=TRIGGER)
assert updated.await_args.kwargs["what"] is None
@@ -90,7 +90,7 @@ async def test_a_lesson_stored_with_a_long_name_can_still_be_edited():
def test_the_rest_door_refuses_too(handler):
"""The web form writes lessons as well, and a bound one door enforces is
a bound the other door walks around."""
from scribe.routes import lessons as routes
from grimoire.routes import lessons as routes
src = inspect.getsource(getattr(routes, handler))
write = "lessons_svc." + handler.removesuffix("_route") + "("
@@ -118,7 +118,7 @@ def test_a_first_sentence_past_the_bound_is_cut_at_a_word():
def test_the_menu_names_a_long_lesson_in_one_line():
from scribe.services.plugin_context import _menu_name
from grimoire.services.plugin_context import _menu_name
name = _menu_name(STORY, "lesson", {"what": STORY, "when_to_apply": TRIGGER}, "")
assert name.endswith(" …")
+5 -5
View File
@@ -13,10 +13,10 @@ from unittest.mock import AsyncMock, patch
import pytest
from scribe.mcp._context import _user_id_ctx
from scribe.mcp.tools import lessons as lesson_tools
from scribe.services import lesson_rules as links_svc
from scribe.services import lessons as lessons_svc
from grimoire.mcp._context import _user_id_ctx
from grimoire.mcp.tools import lessons as lesson_tools
from grimoire.services import lesson_rules as links_svc
from grimoire.services import lessons as lessons_svc
# Bound before conftest's autouse stub replaces the module attribute.
_REAL = links_svc.convergence_for
@@ -122,7 +122,7 @@ def _search_over(sim=_sim):
def _run(no_rule, sim=_sim):
return (
patch.object(lessons_svc, "get_lesson", AsyncMock(return_value=_NOTES[1])),
patch("scribe.services.embeddings.semantic_search_notes", _search_over(sim)),
patch("grimoire.services.embeddings.semantic_search_notes", _search_over(sim)),
patch.object(links_svc, "_no_rule_ids",
AsyncMock(side_effect=lambda ids: {i for i in ids if i in no_rule})),
)
+2 -2
View File
@@ -26,8 +26,8 @@ regression.
"""
from __future__ import annotations
from scribe.services import lessons as lessons_svc
from scribe.services.embeddings import chunk_document, document_title, embedding_text
from grimoire.services import lessons as lessons_svc
from grimoire.services.embeddings import chunk_document, document_title, embedding_text
TRIGGER = "a test fails on code you believe is correct"
SUBJECT = "Suspect the guard before the code"
+1 -1
View File
@@ -45,7 +45,7 @@ from __future__ import annotations
import pytest
from scribe.services import lessons as lessons_svc
from grimoire.services import lessons as lessons_svc
from tests.helpers import fake_lesson
WHAT = "A column written once at create time is a snapshot, not a field"
+5 -5
View File
@@ -26,9 +26,9 @@ and goes red the day one is added without this value.
"""
from types import SimpleNamespace
from scribe.services import knowledge as knowledge_svc
from scribe.services import lessons as lessons_svc
from scribe.services.embeddings import trigger_title
from grimoire.services import knowledge as knowledge_svc
from grimoire.services import lessons as lessons_svc
from grimoire.services.embeddings import trigger_title
def _note(data=None, body=""):
@@ -76,7 +76,7 @@ def test_one_join_builds_every_trigger_title():
assert trigger_title(subject, trigger) == expected
# The two note kinds reach it through the EMBEDDED title,
# from their own mirror key — never a hand-rolled join.
from scribe.services.embeddings import document_title
from grimoire.services.embeddings import document_title
assert document_title(subject, "lesson", {"when_to_apply": trigger}) == expected
assert document_title(subject, "snippet", {"when_to_use": trigger}) == expected
@@ -107,7 +107,7 @@ def test_the_global_kinds_list_names_the_lesson_and_nothing_else():
here instead — the one thing a literal costs is that it can drift, and
this is what stops it.
"""
from scribe.services.embeddings import GLOBAL_NOTE_TYPES
from grimoire.services.embeddings import GLOBAL_NOTE_TYPES
assert GLOBAL_NOTE_TYPES == (lessons_svc.LESSON_NOTE_TYPE,)
+19 -19
View File
@@ -43,8 +43,8 @@ import pytest
def test_both_doors_share_one_serializer():
"""A payload shape spelled once per door answers the two of them
differently the first time a field is added."""
from scribe.mcp.tools import lessons as mcp_lessons
from scribe.services import lessons as lessons_svc
from grimoire.mcp.tools import lessons as mcp_lessons
from grimoire.services import lessons as lessons_svc
assert mcp_lessons._to_dict is lessons_svc.lesson_to_dict
@@ -53,7 +53,7 @@ def test_the_rest_door_composes_nothing_itself():
"""Asserted on structure. The document is what ranks, so a door
that built its own title would produce lessons that rank differently from
the ones the agent writes."""
from scribe.routes import lessons as routes
from grimoire.routes import lessons as routes
src = inspect.getsource(routes)
# It may CALL the service's composer (the dedup gate needs the document),
@@ -67,15 +67,15 @@ def test_the_rest_door_composes_nothing_itself():
def test_the_door_is_registered():
"""A blueprint nobody registers is a file, not a door."""
from scribe import app as app_module
from grimoire import app as app_module
src = inspect.getsource(app_module)
assert "from scribe.routes.lessons import lessons_bp" in src
assert "from grimoire.routes.lessons import lessons_bp" in src
assert "app.register_blueprint(lessons_bp)" in src
def test_the_blueprint_is_mounted_where_the_client_looks():
from scribe.routes.lessons import lessons_bp
from grimoire.routes.lessons import lessons_bp
assert lessons_bp.url_prefix == "/api/lessons"
@@ -84,7 +84,7 @@ def test_the_reverse_lookup_is_registered_before_the_id_route():
"""Quart matches in registration order. The int converter protects
`taught-by` today, but the ordering is what keeps that true if the
converter is ever widened — the same care snippets' `/duplicates` takes."""
from scribe.routes import lessons as routes
from grimoire.routes import lessons as routes
src = inspect.getsource(routes)
assert src.index('"/taught-by/<int:record_id>"') < src.index(
@@ -98,7 +98,7 @@ def test_the_reverse_lookup_is_registered_before_the_id_route():
def test_create_refuses_a_lesson_with_no_trigger():
"""The kind-specific guard. A triggerless lesson saves and never surfaces,
and nothing about the stored record shows it."""
from scribe.routes import lessons as routes
from grimoire.routes import lessons as routes
src = inspect.getsource(routes.create_lesson_route)
assert "when_to_apply is required" in src
@@ -111,14 +111,14 @@ def test_create_refuses_a_lesson_with_no_trigger():
def test_update_refuses_to_clear_the_trigger():
"""The other half. Creating without one is refused; emptying one later
would reach the same broken state by a different path."""
from scribe.routes import lessons as routes
from grimoire.routes import lessons as routes
src = inspect.getsource(routes.update_lesson_route)
assert "cannot be cleared" in src
def test_the_subject_is_required_too():
from scribe.routes import lessons as routes
from grimoire.routes import lessons as routes
src = inspect.getsource(routes.create_lesson_route)
assert "what is required" in src
@@ -133,7 +133,7 @@ def test_the_subject_is_required_too():
def test_writes_check_permission_and_act_as_the_owner(handler):
"""Resolve share-aware, then write as the owner — otherwise a shared
editor is rejected by the owner-scoped service."""
from scribe.routes import lessons as routes
from grimoire.routes import lessons as routes
src = inspect.getsource(getattr(routes, handler))
assert "can_write_note" in src, f"{handler} does not check write permission"
@@ -148,7 +148,7 @@ def test_no_handler_builds_its_own_owner_filter():
`Note.user_id == uid` clause instead of going through the service and the
access helpers. Passing `user_id=uid` INTO a service is the correct call
and is not what this looks for."""
from scribe.routes import lessons as routes
from grimoire.routes import lessons as routes
src = inspect.getsource(routes)
assert "Note.user_id" not in src
@@ -159,7 +159,7 @@ def test_no_handler_builds_its_own_owner_filter():
def test_delete_trashes_recoverably():
"""Every kind's delete is a trash, and the batch id is what restores it."""
from scribe.routes import lessons as routes
from grimoire.routes import lessons as routes
src = inspect.getsource(routes.delete_lesson_route)
assert "trash_svc.delete" in src
@@ -172,7 +172,7 @@ def test_delete_trashes_recoverably():
def test_the_reverse_lookup_reads_the_indexed_mirror():
"""Not by scanning bodies: `data[taught_by]` is JSONB with a GIN index
(0070), which is the whole reason step 4 put the list there."""
from scribe.services import lessons as lessons_svc
from grimoire.services import lessons as lessons_svc
src = inspect.getsource(lessons_svc.lessons_taught_by)
assert "path_exists" in src
@@ -182,7 +182,7 @@ def test_the_reverse_lookup_reads_the_indexed_mirror():
def test_the_reverse_lookup_is_share_aware():
"""It renders beside a record the caller can already see, so a lesson
someone shared with them belongs in the list exactly as their own does."""
from scribe.services import lessons as lessons_svc
from grimoire.services import lessons as lessons_svc
src = inspect.getsource(lessons_svc.lessons_taught_by)
assert "readable_notes_clause" in src
@@ -192,7 +192,7 @@ def test_the_reverse_lookup_is_share_aware():
def test_the_reverse_lookup_refuses_a_nonsense_id_rather_than_interpolating():
"""The jsonpath is built by formatting, so the id has to be an int before
it gets near the expression."""
from scribe.services import lessons as lessons_svc
from grimoire.services import lessons as lessons_svc
src = inspect.getsource(lessons_svc.lessons_taught_by)
assert "int(record_id)" in src
@@ -200,7 +200,7 @@ def test_the_reverse_lookup_refuses_a_nonsense_id_rather_than_interpolating():
@pytest.mark.asyncio
async def test_a_bad_id_returns_nothing_and_raises_nothing():
from scribe.services.lessons import lessons_taught_by
from grimoire.services.lessons import lessons_taught_by
assert await lessons_taught_by(1, 0) == []
assert await lessons_taught_by(1, -3) == []
@@ -214,7 +214,7 @@ def test_the_payload_reads_back_the_composed_fields():
"""A caller that wrote `when_to_apply` reads `when_to_apply` back, not a
body it has to parse."""
from tests.helpers import fake_lesson
from scribe.services.lessons import compose_body, lesson_to_dict
from grimoire.services.lessons import compose_body, lesson_to_dict
what = "Read the job log before waiting longer"
trigger = "a CI run has sat in_progress longer than its suite takes"
@@ -239,7 +239,7 @@ def test_the_payload_reads_back_the_composed_fields():
def test_the_rest_guards_can_fail():
"""Shown turning red once."""
from scribe.routes import lessons as routes
from grimoire.routes import lessons as routes
src = inspect.getsource(routes)
assert "a phrase that is definitely not in this module" not in src
+10 -10
View File
@@ -16,12 +16,12 @@ from unittest.mock import AsyncMock, patch
import pytest
from scribe.mcp._context import _user_id_ctx
from scribe.mcp.tools import lessons as lesson_tools
from scribe.models.lesson_rule_link import LINK_STATES
from scribe.services import backup
from scribe.services import lesson_rules as links_svc
from scribe.services import lessons as lessons_svc
from grimoire.mcp._context import _user_id_ctx
from grimoire.mcp.tools import lessons as lesson_tools
from grimoire.models.lesson_rule_link import LINK_STATES
from grimoire.services import backup
from grimoire.services import lesson_rules as links_svc
from grimoire.services import lessons as lessons_svc
TRIGGER = "a CI run is still in_progress far past its usual time"
@@ -39,9 +39,9 @@ def _stub_note(**kw):
def _create_patches(created):
return (
patch.object(lessons_svc, "create_lesson", created),
patch("scribe.mcp.tools.lessons.dedup_svc.find_duplicate_note",
patch("grimoire.mcp.tools.lessons.dedup_svc.find_duplicate_note",
AsyncMock(return_value=None)),
patch("scribe.mcp.tools.lessons.systems_tools.attach_systems", AsyncMock()),
patch("grimoire.mcp.tools.lessons.systems_tools.attach_systems", AsyncMock()),
)
@@ -237,8 +237,8 @@ async def test_list_unjudged_is_a_listing_and_routes_to_its_own_query():
_user_id_ctx.set(7)
listed = AsyncMock(return_value=([], 0))
with patch.object(links_svc, "list_unjudged", listed), \
patch("scribe.mcp.tools.lessons.access_svc.label_shared_items", AsyncMock(return_value=[])), \
patch("scribe.mcp.tools.lessons.attach_usage", AsyncMock()):
patch("grimoire.mcp.tools.lessons.access_svc.label_shared_items", AsyncMock(return_value=[])), \
patch("grimoire.mcp.tools.lessons.attach_usage", AsyncMock()):
out = await lesson_tools.list_lessons(unjudged=True, tag="ci", project_id=2)
assert out == {"lessons": [], "total": 0}
assert listed.await_args.kwargs["tag"] == "ci"
+6 -6
View File
@@ -20,9 +20,9 @@ import inspect
import re
from pathlib import Path
from scribe.models.lesson_rule_link import LINK_STATES
from scribe.services import lesson_rules as links_svc
from scribe.services.access import PERMISSION_RANK
from grimoire.models.lesson_rule_link import LINK_STATES
from grimoire.services import lesson_rules as links_svc
from grimoire.services.access import PERMISSION_RANK
FRONTEND = Path(__file__).resolve().parents[1] / "frontend" / "src"
@@ -133,7 +133,7 @@ def _payload_fn(view: str) -> str:
def test_the_editor_sends_the_answer_under_the_names_both_routes_read():
"""The contract check: a field the form sends under another name is
dropped without an error, and the lesson saves unjudged."""
from scribe.routes import lessons as routes
from grimoire.routes import lessons as routes
body = _payload_fn(_read("views/LessonEditorView.vue"))
assert "rule_ids:" in body and "no_rule:" in body
@@ -157,7 +157,7 @@ def test_leaving_it_open_after_linking_clears_the_links_rather_than_nothing():
def test_the_candidates_route_is_where_the_client_asks_and_above_the_id_route():
from scribe.routes import lessons as routes
from grimoire.routes import lessons as routes
src = inspect.getsource(routes)
assert src.index('"/rule-candidates"') < src.index('"/<int:lesson_id>"')
@@ -168,7 +168,7 @@ def test_the_candidates_route_keeps_unavailable_apart_from_none():
"""The service returns None when the search could not run. The route has
to pass that through as null, not coerce it to an empty list, because
"nothing resembles it" is a claim the editor shows to the writer."""
from scribe.routes import lessons as routes
from grimoire.routes import lessons as routes
src = inspect.getsource(routes.lesson_rule_candidates_route)
assert 'jsonify({"candidates": candidates})' in src
+4 -4
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import inspect
from datetime import datetime, timedelta, timezone
from scribe.services import lesson_rules as links_svc
from grimoire.services import lesson_rules as links_svc
NOW = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc)
@@ -26,7 +26,7 @@ def test_the_same_ask_reworded_trivially_is_one_situation():
def test_a_file_is_one_situation_however_its_code_differs():
"""The write arm keys on the path, so a run of edits to one file — each
with different code — is one situation, not one per edit."""
path = "src/scribe/services/lesson_rules.py"
path = "src/grimoire/services/lesson_rules.py"
assert links_svc.situation_key("w", path) == links_svc.situation_key("w", path)
assert links_svc.situation_key("w", path) != links_svc.situation_key("w", "src/other.py")
@@ -94,8 +94,8 @@ def test_both_hook_doors_count_the_pair_on_their_own_arm():
"""The two responses that carry lessons and rules together. A door that
renders both and does not record the pair is the miss this step exists
to close, and it would produce no error anywhere — only an empty table."""
from scribe.routes import plugin as plugin_routes
from scribe.services import plugin_context
from grimoire.routes import plugin as plugin_routes
from grimoire.services import plugin_context
prompt_door = inspect.getsource(plugin_routes.autoinject_retrieve)
assert "co_surfaced(" in prompt_door and 'arm="p"' in prompt_door
+2 -2
View File
@@ -20,8 +20,8 @@ that these tests are shaped to catch rather than to describe:
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.services import plugin_context as pc
from scribe.services.lessons import LESSON_NOTE_TYPE
from grimoire.services import plugin_context as pc
from grimoire.services.lessons import LESSON_NOTE_TYPE
from tests.helpers import fake_lesson, fake_note, writepath_cfg
pytestmark = pytest.mark.usefixtures("_no_supersession")
+4 -4
View File
@@ -21,9 +21,9 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.mcp.tools import lessons as lesson_tools
from scribe.services import note_usage
from scribe.services.note_usage import empty_usage
from grimoire.mcp.tools import lessons as lesson_tools
from grimoire.services import note_usage
from grimoire.services.note_usage import empty_usage
def _rows():
@@ -128,7 +128,7 @@ async def test_get_lesson_reads_the_count_before_recording_its_own_pull():
def _route_source(name: str) -> str:
from scribe.routes import lessons as routes
from grimoire.routes import lessons as routes
src = inspect.getsource(routes)
start = src.index(f"async def {name}(")
+12 -12
View File
@@ -22,9 +22,9 @@ from unittest.mock import AsyncMock, patch
import pytest
from scribe.mcp._context import _user_id_ctx
from scribe.mcp.tools.lessons import create_lesson, update_lesson
from scribe.services import lessons as lessons_svc
from grimoire.mcp._context import _user_id_ctx
from grimoire.mcp.tools.lessons import create_lesson, update_lesson
from grimoire.services import lessons as lessons_svc
TRIGGER = "a test fails on code you believe is correct"
SUBJECT = "Suspect the guard before the code"
@@ -73,9 +73,9 @@ async def test_a_lesson_keeps_every_incident_that_taught_it():
_user_id_ctx.set(7)
created = AsyncMock(return_value=_stub_note())
with patch.object(lessons_svc, "create_lesson", created), \
patch("scribe.mcp.tools.lessons.dedup_svc.find_duplicate_note",
patch("grimoire.mcp.tools.lessons.dedup_svc.find_duplicate_note",
AsyncMock(return_value=None)), \
patch("scribe.mcp.tools.lessons.systems_tools.attach_systems", AsyncMock()):
patch("grimoire.mcp.tools.lessons.systems_tools.attach_systems", AsyncMock()):
await create_lesson(
what=SUBJECT, when_to_apply=TRIGGER, learned_from=[11, 22, 33],
)
@@ -101,7 +101,7 @@ async def test_the_duplicate_gate_runs_before_anything_is_created():
hit = SimpleNamespace(id=99, title="already recorded", similarity=0.97,
reason="semantic")
created = AsyncMock()
with patch("scribe.mcp.tools.lessons.dedup_svc.find_duplicate_note",
with patch("grimoire.mcp.tools.lessons.dedup_svc.find_duplicate_note",
AsyncMock(return_value=hit)), \
patch.object(lessons_svc, "create_lesson", created):
out = await create_lesson(what=SUBJECT, when_to_apply=TRIGGER)
@@ -122,10 +122,10 @@ async def test_the_gate_compares_the_composed_document_not_the_raw_fields():
document, and would judge two lessons alike that rank nothing alike."""
_user_id_ctx.set(7)
gate = AsyncMock(return_value=None)
with patch("scribe.mcp.tools.lessons.dedup_svc.find_duplicate_note", gate), \
with patch("grimoire.mcp.tools.lessons.dedup_svc.find_duplicate_note", gate), \
patch.object(lessons_svc, "create_lesson",
AsyncMock(return_value=_stub_note())), \
patch("scribe.mcp.tools.lessons.systems_tools.attach_systems", AsyncMock()):
patch("grimoire.mcp.tools.lessons.systems_tools.attach_systems", AsyncMock()):
await create_lesson(what=SUBJECT, when_to_apply=TRIGGER, insight="Look.")
title, body = gate.await_args.args[1], gate.await_args.args[2]
@@ -140,7 +140,7 @@ def test_a_lesson_is_judged_at_the_trigger_dominated_bar():
mostly prose ABOUT the thing. A lesson's document is that shape, so the
bar sits above that band — otherwise two different lessons about one area
block each other, which is the failure step 4 asked to check for."""
from scribe.services.dedup import (
from grimoire.services.dedup import (
_LESSON_SEMANTIC_THRESHOLD,
_SEMANTIC_THRESHOLD,
_semantic_threshold,
@@ -154,7 +154,7 @@ def test_a_lesson_is_judged_at_the_trigger_dominated_bar():
assert _LESSON_SEMANTIC_THRESHOLD > _SEMANTIC_THRESHOLD
# The carve-out is per kind, not a loosening of the gate: a lesson's bar is
# its own, apart from the note's copy band.
from scribe.services.dedup import _NOTE_COPY_THRESHOLD
from grimoire.services.dedup import _NOTE_COPY_THRESHOLD
assert _semantic_threshold("note") == _NOTE_COPY_THRESHOLD
assert _NOTE_COPY_THRESHOLD != _LESSON_SEMANTIC_THRESHOLD
@@ -175,7 +175,7 @@ async def test_an_update_recomposes_both_halves_of_the_document():
)
updated = AsyncMock(return_value=_stub_note())
with patch.object(lessons_svc, "get_lesson", AsyncMock(return_value=stored)), \
patch("scribe.services.notes.update_note", updated):
patch("grimoire.services.notes.update_note", updated):
await lessons_svc.update_lesson(7, 1, when_to_apply="a guard goes red")
fields = updated.await_args.kwargs
@@ -198,7 +198,7 @@ async def test_an_update_does_not_stack_the_composed_lines():
)
updated = AsyncMock(return_value=_stub_note())
with patch.object(lessons_svc, "get_lesson", AsyncMock(return_value=stored)), \
patch("scribe.services.notes.update_note", updated):
patch("grimoire.services.notes.update_note", updated):
await lessons_svc.update_lesson(7, 1, what="Suspect the guard")
body = updated.await_args.kwargs["body"]
+9 -9
View File
@@ -13,10 +13,10 @@ from unittest.mock import AsyncMock, patch
import pytest
from scribe.mcp.tools.notes import list_notes
from scribe.mcp.tools.systems import list_system_records
from scribe.mcp.tools.tasks import list_tasks
from scribe.services.notes import brief_row
from grimoire.mcp.tools.notes import list_notes
from grimoire.mcp.tools.systems import list_system_records
from grimoire.mcp.tools.tasks import list_tasks
from grimoire.services.notes import brief_row
pytestmark = pytest.mark.usefixtures("_bind_user")
@@ -61,9 +61,9 @@ async def test_a_full_page_of_long_tasks_fits_inline():
"""The ceiling. 100 rows (list_tasks' own limit cap) of ~5k-character step
plans: as to_dict() rows this was over half a million characters."""
rows = [_row(i) for i in range(100)]
with patch("scribe.mcp.tools.tasks.notes_svc.list_notes",
with patch("grimoire.mcp.tools.tasks.notes_svc.list_notes",
AsyncMock(return_value=(rows, 100))), \
patch("scribe.mcp.tools.tasks.milestones_svc.titles_for",
patch("grimoire.mcp.tools.tasks.milestones_svc.titles_for",
AsyncMock(return_value={415: "An existing plan is found"})) as titles:
out = await list_tasks(project_id=2, limit=100)
size = len(json.dumps(out))
@@ -75,7 +75,7 @@ async def test_a_full_page_of_long_tasks_fits_inline():
@pytest.mark.asyncio
async def test_list_notes_rows_leave_the_body_to_get_note():
rows = [_row(i, is_task=False) for i in range(3)]
with patch("scribe.mcp.tools.notes.notes_svc.list_notes",
with patch("grimoire.mcp.tools.notes.notes_svc.list_notes",
AsyncMock(return_value=(rows, 3))):
out = await list_notes(project_id=2)
assert all("body" not in r for r in out["notes"])
@@ -84,9 +84,9 @@ async def test_list_notes_rows_leave_the_body_to_get_note():
@pytest.mark.asyncio
async def test_list_system_records_rows_leave_the_body_to_the_getters():
rows = [_row(1), _row(2, is_task=False)]
with patch("scribe.mcp.tools.systems.systems_svc.list_records_for_system",
with patch("grimoire.mcp.tools.systems.systems_svc.list_records_for_system",
AsyncMock(return_value=rows)), \
patch("scribe.mcp.tools.systems.milestones_svc.titles_for",
patch("grimoire.mcp.tools.systems.milestones_svc.titles_for",
AsyncMock(return_value={415: "Plan"})):
out = await list_system_records(system_id=3)
assert [r["id"] for r in out["records"]] == [1, 2]
+2 -2
View File
@@ -14,8 +14,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.services import embeddings as emb
from scribe.services.text import (
from grimoire.services import embeddings as emb
from grimoire.services.text import (
BODY_OPENING,
MATCHED_PASSAGE,
excerpt_fields,
+10 -10
View File
@@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.mcp.auth import resolve_bearer
from grimoire.mcp.auth import resolve_bearer
@pytest.mark.asyncio
@@ -22,7 +22,7 @@ async def test_resolve_bearer_malformed_header_returns_none():
@pytest.mark.asyncio
async def test_resolve_bearer_unknown_token_returns_none():
with patch(
"scribe.mcp.auth.lookup_key",
"grimoire.mcp.auth.lookup_key",
AsyncMock(return_value=None),
):
assert await resolve_bearer("Bearer fmcp_doesnotexist") is None
@@ -34,7 +34,7 @@ async def test_resolve_bearer_valid_token_returns_user_id():
fake_key.user_id = 42
fake_key.scope = "write"
with patch(
"scribe.mcp.auth.lookup_key",
"grimoire.mcp.auth.lookup_key",
AsyncMock(return_value=fake_key),
):
uid, scope = await resolve_bearer("Bearer fmcp_validkey")
@@ -48,7 +48,7 @@ async def test_resolve_bearer_calls_lookup_with_stripped_token():
fake_key.user_id = 1
fake_key.scope = "write"
mock_lookup = AsyncMock(return_value=fake_key)
with patch("scribe.mcp.auth.lookup_key", mock_lookup):
with patch("grimoire.mcp.auth.lookup_key", mock_lookup):
await resolve_bearer("Bearer fmcp_abc123 ")
mock_lookup.assert_awaited_once_with("fmcp_abc123")
@@ -60,13 +60,13 @@ async def test_resolve_bearer_returns_user_id_and_scope():
fake_key = MagicMock()
fake_key.user_id = 9
fake_key.scope = "read"
with patch("scribe.mcp.auth.lookup_key", AsyncMock(return_value=fake_key)):
with patch("grimoire.mcp.auth.lookup_key", AsyncMock(return_value=fake_key)):
assert await resolve_bearer("Bearer fmcp_x") == (9, "read")
@pytest.mark.asyncio
async def test_resolve_bearer_none_for_invalid():
with patch("scribe.mcp.auth.lookup_key", AsyncMock(return_value=None)):
with patch("grimoire.mcp.auth.lookup_key", AsyncMock(return_value=None)):
assert await resolve_bearer("Bearer nope") is None
assert await resolve_bearer(None) is None
@@ -75,7 +75,7 @@ async def test_resolve_bearer_none_for_invalid():
def test_body_calls_write_tool_classifies_correctly():
import json
from scribe.mcp.server import _body_calls_write_tool
from grimoire.mcp.server import _body_calls_write_tool
def call(name):
return json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
@@ -99,7 +99,7 @@ def test_body_calls_write_tool_classifies_correctly():
def _registered_tool_names() -> set[str]:
"""What the server actually mounts — not a glob of function names, which
would count helpers and miss anything registered another way."""
from scribe.mcp.server import build_mcp_server
from grimoire.mcp.server import build_mcp_server
return {tool.name for tool in build_mcp_server()._tool_manager.list_tools()}
@@ -118,7 +118,7 @@ def test_every_registered_tool_is_classified_exactly_once():
not look like reads (`rules_due_for_verification`, `rule_history`). So the
candidates are EVERY registered tool.
"""
from scribe.mcp.server import (
from grimoire.mcp.server import (
_DELIBERATELY_WRITE_SCOPED, _READ_ONLY_TOOLS, _WRITE_TOOLS,
)
@@ -156,7 +156,7 @@ def test_every_registered_tool_is_classified_exactly_once():
def test_the_completeness_check_can_fail():
"""A guard that cannot fail is indistinguishable from one that is broken
Drop a real tool from its set and the check must notice."""
from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
from grimoire.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
registered = _registered_tool_names()
assert "rule_history" in _READ_ONLY_TOOLS
+1 -1
View File
@@ -1,7 +1,7 @@
"""Tests for MCP per-request user_id context."""
import pytest
from scribe.mcp._context import current_user_id, _user_id_ctx
from grimoire.mcp._context import current_user_id, _user_id_ctx
def test_current_user_id_raises_when_unset():
+3 -3
View File
@@ -15,7 +15,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.app import create_app
from grimoire.app import create_app
async def _send_request(
@@ -73,7 +73,7 @@ async def test_mcp_endpoint_unauthenticated_returns_401():
async def test_mcp_endpoint_invalid_token_returns_401():
app = create_app()
with patch(
"scribe.mcp.auth.lookup_key",
"grimoire.mcp.auth.lookup_key",
AsyncMock(return_value=None),
):
status, _ = await _send_request(
@@ -107,7 +107,7 @@ async def test_mcp_endpoint_valid_token_passes_auth():
}).encode()
async with app.mcp_instance.session_manager.run():
with patch(
"scribe.mcp.auth.lookup_key",
"grimoire.mcp.auth.lookup_key",
AsyncMock(return_value=fake_key),
):
status, _ = await _send_request(
+1 -1
View File
@@ -28,7 +28,7 @@ so any future `list_*` could opt itself out by accident.
import ast
import pathlib
TOOLS_DIR = pathlib.Path(__file__).resolve().parents[1] / "src" / "scribe" / "mcp" / "tools"
TOOLS_DIR = pathlib.Path(__file__).resolve().parents[1] / "src" / "grimoire" / "mcp" / "tools"
# `limit` here caps a RANKED top-N, not a page into a corpus, so there is no
# "rest" to ask for — the 51st most-used tag is not what the caller wanted and
+3 -3
View File
@@ -13,7 +13,7 @@ one is close. An error the caller sees once beats data half-written forever.
import pytest
from mcp.server.mcpserver.exceptions import ToolError, UnexpectedToolError
from scribe.mcp.server import StrictArgsMCPServer
from grimoire.mcp.server import StrictArgsMCPServer
def _echo_server() -> StrictArgsMCPServer:
@@ -103,7 +103,7 @@ async def test_a_crash_stays_masked():
async def test_a_real_tools_refusal_reaches_the_caller():
"""Against the real server, on a refusal that needs no database: the
message judge_menu writes for an empty verdict list."""
from scribe.mcp.server import build_mcp_server
from grimoire.mcp.server import build_mcp_server
with pytest.raises(ToolError) as exc:
await build_mcp_server().call_tool("judge_menu", {"log_id": 1, "verdicts": []})
@@ -114,7 +114,7 @@ async def test_the_real_server_refuses_content_on_create_note():
"""Against the REAL server: `content=` on create_note
must raise before dispatch — naming the bad argument and listing `body`
among the accepted ones — instead of creating a body-less note."""
from scribe.mcp.server import build_mcp_server
from grimoire.mcp.server import build_mcp_server
mcp = build_mcp_server()
assert isinstance(mcp, StrictArgsMCPServer) # the guard is actually mounted
+37 -37
View File
@@ -9,8 +9,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.services import record_refs
from scribe.services.record_batch import BatchItem, BatchMilestone, check_batch
from grimoire.services import record_refs
from grimoire.services.record_batch import BatchItem, BatchMilestone, check_batch
pytestmark = pytest.mark.usefixtures("_bind_user")
@@ -23,14 +23,14 @@ def _max(n: int):
@pytest.mark.parametrize(("module", "name", "kwargs"), [
("scribe.mcp.tools.tasks", "create_task", {"title": "t", "body": "after #101"}),
("scribe.mcp.tools.tasks", "update_task", {"task_id": 5, "body": "see #101"}),
("scribe.mcp.tools.notes", "create_note", {"title": "n", "body": "index: #101"}),
("scribe.mcp.tools.notes", "update_note", {"note_id": 5, "body": "index: #101"}),
("scribe.mcp.tools.milestones", "create_milestone", {"project_id": 1, "title": "m", "body": "#101"}),
("scribe.mcp.tools.milestones", "update_milestone", {"project_id": 1, "milestone_id": 2, "body": "#101"}),
("scribe.mcp.tools.tasks", "start_planning", {"project_id": 1, "title": "p", "body": "step #101"}),
("scribe.mcp.tools.tasks", "create_records", {"records": [{"title": "a", "body": "#101"}]}),
("grimoire.mcp.tools.tasks", "create_task", {"title": "t", "body": "after #101"}),
("grimoire.mcp.tools.tasks", "update_task", {"task_id": 5, "body": "see #101"}),
("grimoire.mcp.tools.notes", "create_note", {"title": "n", "body": "index: #101"}),
("grimoire.mcp.tools.notes", "update_note", {"note_id": 5, "body": "index: #101"}),
("grimoire.mcp.tools.milestones", "create_milestone", {"project_id": 1, "title": "m", "body": "#101"}),
("grimoire.mcp.tools.milestones", "update_milestone", {"project_id": 1, "milestone_id": 2, "body": "#101"}),
("grimoire.mcp.tools.tasks", "start_planning", {"project_id": 1, "title": "p", "body": "step #101"}),
("grimoire.mcp.tools.tasks", "create_records", {"records": [{"title": "a", "body": "#101"}]}),
])
async def test_every_writing_door_refuses_a_guessed_id_before_writing(module, name, kwargs):
import importlib
@@ -40,13 +40,13 @@ async def test_every_writing_door_refuses_a_guessed_id_before_writing(module, na
# Every service a door could reach is a MagicMock that must stay untouched:
# the refusal has to land before the duplicate gate and before the write.
with _max(100), \
patch("scribe.services.dedup.find_duplicate_note", AsyncMock()) as dedup, \
patch("scribe.services.notes.create_note", AsyncMock()) as create, \
patch("scribe.services.notes.update_note", AsyncMock()) as update, \
patch("scribe.services.milestones.create_milestone", AsyncMock()) as ms_create, \
patch("scribe.services.milestones.update_milestone", AsyncMock()) as ms_update, \
patch("scribe.services.record_batch.create_batch", AsyncMock()) as batch, \
patch("scribe.services.planning.start_planning", AsyncMock()) as plan:
patch("grimoire.services.dedup.find_duplicate_note", AsyncMock()) as dedup, \
patch("grimoire.services.notes.create_note", AsyncMock()) as create, \
patch("grimoire.services.notes.update_note", AsyncMock()) as update, \
patch("grimoire.services.milestones.create_milestone", AsyncMock()) as ms_create, \
patch("grimoire.services.milestones.update_milestone", AsyncMock()) as ms_update, \
patch("grimoire.services.record_batch.create_batch", AsyncMock()) as batch, \
patch("grimoire.services.planning.start_planning", AsyncMock()) as plan:
with pytest.raises(ValueError, match="#101"):
await tool(**kwargs)
for mock in (dedup, create, update, ms_create, ms_update, batch, plan):
@@ -54,14 +54,14 @@ async def test_every_writing_door_refuses_a_guessed_id_before_writing(module, na
async def test_a_real_id_passes_the_door():
from scribe.mcp.tools.tasks import create_task
from grimoire.mcp.tools.tasks import create_task
fake = MagicMock(id=90, project_id=None)
fake.to_dict.return_value = {"id": 90}
with _max(100), \
patch("scribe.mcp.tools.tasks.dedup_svc.find_duplicate_note", AsyncMock(return_value=None)), \
patch("scribe.mcp.tools.tasks.notes_svc.create_note", AsyncMock(return_value=fake)), \
patch("scribe.mcp.tools.tasks.systems_tools.attach_systems", AsyncMock()):
patch("grimoire.mcp.tools.tasks.dedup_svc.find_duplicate_note", AsyncMock(return_value=None)), \
patch("grimoire.mcp.tools.tasks.notes_svc.create_note", AsyncMock(return_value=fake)), \
patch("grimoire.mcp.tools.tasks.systems_tools.attach_systems", AsyncMock()):
out = await create_task(title="t", body="follows #42")
assert out["id"] == 90
@@ -70,26 +70,26 @@ async def test_a_real_id_passes_the_door():
async def test_unknown_record_fields_are_refused_not_dropped():
from scribe.mcp.tools.tasks import create_records
from grimoire.mcp.tools.tasks import create_records
with pytest.raises(ValueError, match=r"record 2 has unknown field\(s\) \['milestone'\]"):
await create_records(records=[{"title": "a"}, {"title": "b", "milestone": 4}])
async def test_a_record_type_must_be_task_or_note():
from scribe.mcp.tools.tasks import create_records
from grimoire.mcp.tools.tasks import create_records
with pytest.raises(ValueError, match="type must be 'task' or 'note'"):
await create_records(records=[{"title": "a", "type": "rule"}])
async def test_one_duplicate_blocks_the_whole_batch():
from scribe.mcp.tools.tasks import create_records
from grimoire.mcp.tools.tasks import create_records
dup = MagicMock(id=33, title="existing", similarity=1.0, reason="title")
gate = AsyncMock(side_effect=[None, dup])
with patch("scribe.mcp.tools.tasks.dedup_svc.find_duplicate_note", gate), \
patch("scribe.mcp.tools.tasks.batch_svc.create_batch", AsyncMock()) as batch:
with patch("grimoire.mcp.tools.tasks.dedup_svc.find_duplicate_note", gate), \
patch("grimoire.mcp.tools.tasks.batch_svc.create_batch", AsyncMock()) as batch:
out = await create_records(records=[{"title": "fresh"}, {"title": "existing"}], project_id=3)
assert out["duplicate"] is True and out["record"] == 2 and out["existing_id"] == 33
assert "Nothing in the batch was created" in out["message"]
@@ -97,15 +97,15 @@ async def test_one_duplicate_blocks_the_whole_batch():
async def test_create_records_returns_ids_in_order():
from scribe.mcp.tools.tasks import create_records
from grimoire.mcp.tools.tasks import create_records
notes = []
for nid in (51, 53):
n = MagicMock(id=nid)
n.to_dict.return_value = {"id": nid}
notes.append(n)
with patch("scribe.mcp.tools.tasks.dedup_svc.find_duplicate_note", AsyncMock(return_value=None)), \
patch("scribe.mcp.tools.tasks.batch_svc.create_batch", AsyncMock(return_value=(None, notes))) as batch:
with patch("grimoire.mcp.tools.tasks.dedup_svc.find_duplicate_note", AsyncMock(return_value=None)), \
patch("grimoire.mcp.tools.tasks.batch_svc.create_batch", AsyncMock(return_value=(None, notes))) as batch:
out = await create_records(
records=[{"title": "a"}, {"title": "b", "type": "note", "body": "after {{ref:1}}"}],
milestone_id=8,
@@ -117,11 +117,11 @@ async def test_create_records_returns_ids_in_order():
async def test_start_planning_hands_its_steps_to_the_service():
from scribe.mcp.tools.tasks import start_planning
from grimoire.mcp.tools.tasks import start_planning
with patch("scribe.mcp.tools.tasks.dedup_svc.find_duplicate_note", AsyncMock(return_value=None)), \
patch("scribe.mcp.tools.tasks.dedup_svc.plan_gate", AsyncMock(return_value=None)), \
patch("scribe.mcp.tools.tasks.planning_svc.start_planning",
with patch("grimoire.mcp.tools.tasks.dedup_svc.find_duplicate_note", AsyncMock(return_value=None)), \
patch("grimoire.mcp.tools.tasks.dedup_svc.plan_gate", AsyncMock(return_value=None)), \
patch("grimoire.mcp.tools.tasks.planning_svc.start_planning",
AsyncMock(return_value={"milestone": {"id": 1}})) as svc:
await start_planning(project_id=3, title="Plan", body="see {{ref:1}}",
steps=[{"title": "Step 1", "kind": "spike"}])
@@ -155,17 +155,17 @@ def test_every_record_needs_a_title():
def test_a_batch_has_a_ceiling():
from scribe.services.record_batch import MAX_BATCH
from grimoire.services.record_batch import MAX_BATCH
with pytest.raises(ValueError, match="at most"):
check_batch([BatchItem(title=f"t{i}") for i in range(MAX_BATCH + 1)])
async def test_start_planning_refuses_placeholders_with_no_steps():
from scribe.services.planning import start_planning
from grimoire.services.planning import start_planning
with patch("scribe.services.planning.projects_svc.get_project", AsyncMock(return_value=MagicMock())), \
patch("scribe.services.planning.milestones_svc.create_milestone", AsyncMock()) as create:
with patch("grimoire.services.planning.projects_svc.get_project", AsyncMock(return_value=MagicMock())), \
patch("grimoire.services.planning.milestones_svc.create_milestone", AsyncMock()) as create:
with pytest.raises(ValueError, match="no steps were given"):
await start_planning(user_id=7, project_id=3, title="p", body="see {{ref:1}}")
create.assert_not_awaited()
+34 -34
View File
@@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, patch
import pytest
from scribe.services.design_systems import DesignSystemCycle
from grimoire.services.design_systems import DesignSystemCycle
from tests.helpers import design_token_stub, fake_record
@@ -31,27 +31,27 @@ async def test_creating_without_a_parent_passes_none_not_zero():
"""0 is the "omitted" sentinel, and it must not reach the service as a
system id — there is no system 0, so the create would fail an ACL check for
a record that cannot exist."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
with patch("grimoire.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
from grimoire.mcp.tools.design_systems import create_design_system
await create_design_system(title="FabledSword")
assert svc.create_design_system.await_args.kwargs["parent_id"] is None
@pytest.mark.asyncio
async def test_creating_with_a_parent_passes_it_through():
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
with patch("grimoire.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
from grimoire.mcp.tools.design_systems import create_design_system
await create_design_system(title="Grimoire", parent_id=4)
assert svc.create_design_system.await_args.kwargs["parent_id"] == 4
@pytest.mark.asyncio
async def test_create_raises_when_the_parent_is_not_writable():
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.create_design_system = AsyncMock(return_value=None)
from scribe.mcp.tools.design_systems import create_design_system
from grimoire.mcp.tools.design_systems import create_design_system
with pytest.raises(ValueError):
await create_design_system(title="Grimoire", parent_id=4)
@@ -61,9 +61,9 @@ async def test_create_raises_when_the_parent_is_not_writable():
@pytest.mark.asyncio
async def test_update_with_parent_id_zero_leaves_the_parent_alone():
"""The common case — renaming a system must not silently re-root it."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.update_design_system = AsyncMock(return_value=_fake_design_system())
from scribe.mcp.tools.design_systems import update_design_system
from grimoire.mcp.tools.design_systems import update_design_system
await update_design_system(design_system_id=1, title="Renamed")
fields = svc.update_design_system.await_args.kwargs
assert "parent_id" not in fields
@@ -75,18 +75,18 @@ async def test_update_with_parent_id_minus_one_clears_it():
"""-1 means "make this a family system". It has to arrive at the service as
None, which is the value the service reads as "become a root" — where
omitting the key means "leave alone"."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.update_design_system = AsyncMock(return_value=_fake_design_system())
from scribe.mcp.tools.design_systems import update_design_system
from grimoire.mcp.tools.design_systems import update_design_system
await update_design_system(design_system_id=1, parent_id=-1)
assert svc.update_design_system.await_args.kwargs["parent_id"] is None
@pytest.mark.asyncio
async def test_update_with_a_positive_parent_id_sets_it():
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.update_design_system = AsyncMock(return_value=_fake_design_system())
from scribe.mcp.tools.design_systems import update_design_system
from grimoire.mcp.tools.design_systems import update_design_system
await update_design_system(design_system_id=1, parent_id=4)
assert svc.update_design_system.await_args.kwargs["parent_id"] == 4
@@ -95,11 +95,11 @@ async def test_update_with_a_positive_parent_id_sets_it():
async def test_a_cycle_surfaces_as_a_usable_error_not_a_not_found():
"""The service raises so this layer can keep the two apart. An agent told
"not found" would retry the same call; one told what the loop is can fix it."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.update_design_system = AsyncMock(
side_effect=DesignSystemCycle("2 already inherits from 1")
)
from scribe.mcp.tools.design_systems import update_design_system
from grimoire.mcp.tools.design_systems import update_design_system
with pytest.raises(ValueError, match="already inherits"):
await update_design_system(design_system_id=1, parent_id=2)
@@ -110,9 +110,9 @@ async def test_a_cycle_surfaces_as_a_usable_error_not_a_not_found():
async def test_update_token_treats_order_index_minus_one_as_unchanged():
"""0 is a VALID order_index, so it cannot double as the omitted sentinel —
the same reason the systems tools use -1."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.update_token = AsyncMock(return_value=_fake_token())
from scribe.mcp.tools.design_systems import update_design_token
from grimoire.mcp.tools.design_systems import update_design_token
await update_design_token(token_id=9, purpose="page bg")
fields = svc.update_token.await_args.kwargs
assert "order_index" not in fields
@@ -121,9 +121,9 @@ async def test_update_token_treats_order_index_minus_one_as_unchanged():
@pytest.mark.asyncio
async def test_update_token_accepts_order_index_zero():
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.update_token = AsyncMock(return_value=_fake_token())
from scribe.mcp.tools.design_systems import update_design_token
from grimoire.mcp.tools.design_systems import update_design_token
await update_design_token(token_id=9, order_index=0)
assert svc.update_token.await_args.kwargs["order_index"] == 0
@@ -132,9 +132,9 @@ async def test_update_token_accepts_order_index_zero():
async def test_update_token_can_set_an_empty_value_map():
"""`value_by_mode={}` is meaningful — it strips every mode from a token. The
guard is `is not None`, not truthiness, or that edit would be unreachable."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.update_token = AsyncMock(return_value=_fake_token())
from scribe.mcp.tools.design_systems import update_design_token
from grimoire.mcp.tools.design_systems import update_design_token
await update_design_token(token_id=9, value_by_mode={})
assert svc.update_token.await_args.kwargs["value_by_mode"] == {}
@@ -143,9 +143,9 @@ async def test_update_token_can_set_an_empty_value_map():
@pytest.mark.asyncio
async def test_clearing_a_projects_design_system_passes_none():
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.set_project_design_system = AsyncMock(return_value=True)
from scribe.mcp.tools.design_systems import set_project_design_system
from grimoire.mcp.tools.design_systems import set_project_design_system
result = await set_project_design_system(project_id=2, design_system_id=-1)
assert svc.set_project_design_system.await_args.args[2] is None
assert result["design_system_id"] is None
@@ -156,7 +156,7 @@ async def test_resolve_returns_serialised_tokens_with_their_provenance():
"""The payload has to carry the shadowed entries, not just the winner —
dropping them at the serialisation boundary would discard the one thing
resolution was built to preserve."""
from scribe.services.design_cascade import resolve_tokens
from grimoire.services.design_cascade import resolve_tokens
resolved = resolve_tokens(
@@ -164,9 +164,9 @@ async def test_resolve_returns_serialised_tokens_with_their_provenance():
{1: [design_token_stub("--fs-accent", {"base": "#6b2118"})],
2: [design_token_stub("--fs-accent", {"base": "#5b4a8a"})]},
)
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.resolve_design_system = AsyncMock(return_value=resolved)
from scribe.mcp.tools.design_systems import resolve_design_system
from grimoire.mcp.tools.design_systems import resolve_design_system
payload = await resolve_design_system(design_system_id=2)
token = payload["tokens"][0]
@@ -183,18 +183,18 @@ async def test_update_token_can_clear_supersedes_with_an_empty_list():
"""`[]` means "this token replaces nothing after all" — a real edit. Guarded
on `is not None` so it isn't swallowed as "unchanged", the same trap
update_snippet guards against."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.update_token = AsyncMock(return_value=_fake_token())
from scribe.mcp.tools.design_systems import update_design_token
from grimoire.mcp.tools.design_systems import update_design_token
await update_design_token(token_id=9, supersedes=[])
assert svc.update_token.await_args.kwargs["supersedes"] == []
@pytest.mark.asyncio
async def test_update_token_leaves_supersedes_alone_when_omitted():
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.update_token = AsyncMock(return_value=_fake_token())
from scribe.mcp.tools.design_systems import update_design_token
from grimoire.mcp.tools.design_systems import update_design_token
await update_design_token(token_id=9, purpose="text on action surfaces")
assert "supersedes" not in svc.update_token.await_args.kwargs
@@ -206,15 +206,15 @@ async def test_get_design_system_carries_the_chain_merged_guidance():
"""enter_project carries only a summary and points here. The
system's own `guidance` field is only its departures; a session building
UI needs the house style too, so the merged form rides alongside."""
from scribe.mcp.tools.design_systems import get_design_system
from grimoire.mcp.tools.design_systems import get_design_system
merged = [{"design_system_id": 1, "title": "House", "guidance": "house style"},
{"design_system_id": 2, "title": "App", "guidance": "departures"}]
with patch("scribe.mcp.tools.design_systems.ds_svc.get_design_system",
with patch("grimoire.mcp.tools.design_systems.ds_svc.get_design_system",
AsyncMock(return_value=_fake_design_system())), \
patch("scribe.mcp.tools.design_systems.ds_svc.list_tokens",
patch("grimoire.mcp.tools.design_systems.ds_svc.list_tokens",
AsyncMock(return_value=[])), \
patch("scribe.mcp.tools.design_systems.ds_svc.design_context",
patch("grimoire.mcp.tools.design_systems.ds_svc.design_context",
AsyncMock(return_value={"guidance": merged})):
out = await get_design_system(2)
+21 -21
View File
@@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.mcp.tools.milestones import (
from grimoire.mcp.tools.milestones import (
list_milestones, get_milestone, create_milestone, update_milestone,
)
from tests.helpers import fake_milestone, fake_task
@@ -16,7 +16,7 @@ pytestmark = pytest.mark.usefixtures("_bind_user")
def _no_plan_gate():
"""The plan gate reads the database; these tests are about what reaches
the service. The gate's own tests re-patch it."""
with patch("scribe.mcp.tools.milestones.dedup_svc.plan_gate",
with patch("grimoire.mcp.tools.milestones.dedup_svc.plan_gate",
AsyncMock(return_value=None)):
yield
@@ -25,7 +25,7 @@ def _no_plan_gate():
async def test_list_milestones_returns_dict_with_progress():
rows = [{"id": 1, "title": "MS1", "status": "active", "total": 2}]
with patch(
"scribe.mcp.tools.milestones.milestones_svc.get_project_milestone_summary",
"grimoire.mcp.tools.milestones.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=rows),
):
out = await list_milestones(project_id=1)
@@ -36,7 +36,7 @@ async def test_list_milestones_returns_dict_with_progress():
async def test_create_milestone_passes_through():
m = fake_milestone(id=5)
mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock):
with patch("grimoire.mcp.tools.milestones.milestones_svc.create_milestone", mock):
out = await create_milestone(project_id=1, title="new", description="d")
assert out["id"] == 5
assert mock.call_args.kwargs["project_id"] == 1
@@ -48,7 +48,7 @@ async def test_create_milestone_passes_through():
async def test_create_milestone_empty_description_becomes_none():
m = fake_milestone()
mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock):
with patch("grimoire.mcp.tools.milestones.milestones_svc.create_milestone", mock):
await create_milestone(project_id=1, title="t", description="")
assert mock.call_args.kwargs["description"] is None
@@ -58,7 +58,7 @@ async def test_create_milestone_passes_body_through():
"""The milestone-as-plan body is forwarded to the service."""
m = fake_milestone(id=5)
mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock):
with patch("grimoire.mcp.tools.milestones.milestones_svc.create_milestone", mock):
await create_milestone(project_id=1, title="t", body="## Goal\n\nship")
assert mock.call_args.kwargs["body"] == "## Goal\n\nship"
@@ -67,7 +67,7 @@ async def test_create_milestone_passes_body_through():
async def test_create_milestone_empty_body_becomes_none():
m = fake_milestone()
mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock):
with patch("grimoire.mcp.tools.milestones.milestones_svc.create_milestone", mock):
await create_milestone(project_id=1, title="t", body="")
assert mock.call_args.kwargs["body"] is None
@@ -76,9 +76,9 @@ async def test_create_milestone_empty_body_becomes_none():
async def test_create_milestone_returns_the_active_plan_that_already_covers_it():
match = {"duplicate": True, "existing_id": 4}
create = AsyncMock()
with patch("scribe.mcp.tools.milestones.dedup_svc.plan_gate",
with patch("grimoire.mcp.tools.milestones.dedup_svc.plan_gate",
AsyncMock(return_value=match)) as gate, \
patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", create):
patch("grimoire.mcp.tools.milestones.milestones_svc.create_milestone", create):
out = await create_milestone(project_id=1, title="t", description="d", body="b")
assert out is match
create.assert_not_awaited()
@@ -92,8 +92,8 @@ async def test_create_milestone_skips_the_gate_when_forced_or_done():
plan competing with an open one."""
gate = AsyncMock(return_value={"duplicate": True})
create = AsyncMock(return_value=fake_milestone(id=6))
with patch("scribe.mcp.tools.milestones.dedup_svc.plan_gate", gate), \
patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", create):
with patch("grimoire.mcp.tools.milestones.dedup_svc.plan_gate", gate), \
patch("grimoire.mcp.tools.milestones.milestones_svc.create_milestone", create):
assert (await create_milestone(project_id=1, title="t", force=True))["id"] == 6
assert (await create_milestone(project_id=1, title="t", status="done"))["id"] == 6
gate.assert_not_awaited()
@@ -103,7 +103,7 @@ async def test_create_milestone_skips_the_gate_when_forced_or_done():
async def test_update_milestone_sends_body():
m = fake_milestone()
mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock):
with patch("grimoire.mcp.tools.milestones.milestones_svc.update_milestone", mock):
await update_milestone(project_id=1, milestone_id=5, body="new plan")
assert mock.call_args.kwargs == {"body": "new plan"}
@@ -114,13 +114,13 @@ async def test_get_milestone_returns_body_steps_and_rules():
step = fake_task(id=9, title="step 1", milestone_id=5, body="a long step body")
applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False,
"project_rules": [{"id": 3, "title": "own"}]}
with patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone",
with patch("grimoire.mcp.tools.milestones.milestones_svc.get_milestone",
AsyncMock(return_value=m)), \
patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone_progress",
patch("grimoire.mcp.tools.milestones.milestones_svc.get_milestone_progress",
AsyncMock(return_value={"total": 1, "completed": 0, "pct": 0.0})), \
patch("scribe.mcp.tools.milestones.notes_svc.list_notes",
patch("grimoire.mcp.tools.milestones.notes_svc.list_notes",
AsyncMock(return_value=([step], 1))), \
patch("scribe.mcp.tools.milestones.rulebooks_svc.get_applicable_rules",
patch("grimoire.mcp.tools.milestones.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value=applicable)):
out = await get_milestone(milestone_id=5)
assert out["milestone"]["body"] == "## Goal"
@@ -134,7 +134,7 @@ async def test_get_milestone_returns_body_steps_and_rules():
@pytest.mark.asyncio
async def test_get_milestone_raises_when_not_found():
with patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone",
with patch("grimoire.mcp.tools.milestones.milestones_svc.get_milestone",
AsyncMock(return_value=None)):
with pytest.raises(ValueError, match="milestone 999 not found"):
await get_milestone(milestone_id=999)
@@ -144,7 +144,7 @@ async def test_get_milestone_raises_when_not_found():
async def test_update_milestone_only_sends_non_default_fields():
m = fake_milestone()
mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock):
with patch("grimoire.mcp.tools.milestones.milestones_svc.update_milestone", mock):
await update_milestone(project_id=1, milestone_id=5, status="done")
args, kwargs = mock.call_args
assert args == (7, 5)
@@ -156,7 +156,7 @@ async def test_update_milestone_order_index_negative_is_omitted():
"""order_index=-1 sentinel means leave unchanged."""
m = fake_milestone()
mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock):
with patch("grimoire.mcp.tools.milestones.milestones_svc.update_milestone", mock):
await update_milestone(project_id=1, milestone_id=5, order_index=-1)
assert "order_index" not in mock.call_args.kwargs
@@ -166,7 +166,7 @@ async def test_update_milestone_order_index_zero_is_explicit():
"""order_index=0 is a real value (top of list), not a sentinel."""
m = fake_milestone()
mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock):
with patch("grimoire.mcp.tools.milestones.milestones_svc.update_milestone", mock):
await update_milestone(project_id=1, milestone_id=5, order_index=0)
assert mock.call_args.kwargs["order_index"] == 0
@@ -174,7 +174,7 @@ async def test_update_milestone_order_index_zero_is_explicit():
@pytest.mark.asyncio
async def test_update_milestone_raises_when_not_found():
with patch(
"scribe.mcp.tools.milestones.milestones_svc.update_milestone",
"grimoire.mcp.tools.milestones.milestones_svc.update_milestone",
AsyncMock(return_value=None),
):
with pytest.raises(ValueError, match="milestone 999 not found"):
+36 -36
View File
@@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.mcp.tools.notes import (
from grimoire.mcp.tools.notes import (
list_notes, get_note, create_note,
update_note, delete_note,
)
@@ -23,19 +23,19 @@ def _no_supersession():
relation's own behaviour is covered in test_services_supersession.py, and
the attachment is covered explicitly below.
"""
with patch("scribe.mcp.tools.notes.supersession_svc.get_relations",
with patch("grimoire.mcp.tools.notes.supersession_svc.get_relations",
AsyncMock(return_value={"supersedes": [], "superseded_by": []})):
yield
@pytest.mark.asyncio
async def test_create_note_blocked_by_duplicate_gate():
from scribe.services.dedup import DuplicateMatch
from grimoire.services.dedup import DuplicateMatch
dup = DuplicateMatch(id=88, title="Embeddings notes", similarity=0.94, reason="semantic")
create_mock = AsyncMock()
with patch("scribe.mcp.tools.notes.dedup_svc.find_duplicate_note",
with patch("grimoire.mcp.tools.notes.dedup_svc.find_duplicate_note",
AsyncMock(return_value=dup)), \
patch("scribe.mcp.tools.notes.notes_svc.create_note", create_mock):
patch("grimoire.mcp.tools.notes.notes_svc.create_note", create_mock):
out = await create_note(title="embeddings", body="notes about embeddings")
assert out["duplicate"] is True
assert out["existing_id"] == 88
@@ -46,8 +46,8 @@ async def test_create_note_blocked_by_duplicate_gate():
@pytest.mark.asyncio
async def test_create_note_force_bypasses_duplicate_gate():
find_mock = AsyncMock()
with patch("scribe.mcp.tools.notes.dedup_svc.find_duplicate_note", find_mock), \
patch("scribe.mcp.tools.notes.notes_svc.create_note",
with patch("grimoire.mcp.tools.notes.dedup_svc.find_duplicate_note", find_mock), \
patch("grimoire.mcp.tools.notes.notes_svc.create_note",
AsyncMock(return_value=fake_note(id=3))):
out = await create_note(title="dup", force=True)
assert out["id"] == 3
@@ -58,7 +58,7 @@ async def test_create_note_force_bypasses_duplicate_gate():
async def test_list_notes_repackages_tuple_into_dict():
rows = [fake_note(id=1), fake_note(id=2)]
with patch(
"scribe.mcp.tools.notes.notes_svc.list_notes",
"grimoire.mcp.tools.notes.notes_svc.list_notes",
AsyncMock(return_value=(rows, 2)),
):
out = await list_notes()
@@ -69,7 +69,7 @@ async def test_list_notes_repackages_tuple_into_dict():
@pytest.mark.asyncio
async def test_list_notes_passes_is_task_false():
mock = AsyncMock(return_value=([], 0))
with patch("scribe.mcp.tools.notes.notes_svc.list_notes", mock):
with patch("grimoire.mcp.tools.notes.notes_svc.list_notes", mock):
await list_notes()
assert mock.call_args.kwargs["is_task"] is False
@@ -78,7 +78,7 @@ async def test_list_notes_passes_is_task_false():
async def test_list_notes_tag_filter_maps_to_list():
"""The single-tag string param maps to a one-element list at the service layer."""
mock = AsyncMock(return_value=([], 0))
with patch("scribe.mcp.tools.notes.notes_svc.list_notes", mock):
with patch("grimoire.mcp.tools.notes.notes_svc.list_notes", mock):
await list_notes(tag="ops")
assert mock.call_args.kwargs["tags"] == ["ops"]
@@ -86,7 +86,7 @@ async def test_list_notes_tag_filter_maps_to_list():
@pytest.mark.asyncio
async def test_list_notes_empty_tag_means_no_filter():
mock = AsyncMock(return_value=([], 0))
with patch("scribe.mcp.tools.notes.notes_svc.list_notes", mock):
with patch("grimoire.mcp.tools.notes.notes_svc.list_notes", mock):
await list_notes(tag="")
assert mock.call_args.kwargs["tags"] is None
@@ -94,7 +94,7 @@ async def test_list_notes_empty_tag_means_no_filter():
@pytest.mark.asyncio
async def test_list_notes_search_text_maps_to_q():
mock = AsyncMock(return_value=([], 0))
with patch("scribe.mcp.tools.notes.notes_svc.list_notes", mock):
with patch("grimoire.mcp.tools.notes.notes_svc.list_notes", mock):
await list_notes(search_text="kafka")
assert mock.call_args.kwargs["q"] == "kafka"
@@ -102,7 +102,7 @@ async def test_list_notes_search_text_maps_to_q():
@pytest.mark.asyncio
async def test_list_notes_limit_clamped():
mock = AsyncMock(return_value=([], 0))
with patch("scribe.mcp.tools.notes.notes_svc.list_notes", mock):
with patch("grimoire.mcp.tools.notes.notes_svc.list_notes", mock):
await list_notes(limit=9999)
assert mock.call_args.kwargs["limit"] == 100
@@ -111,7 +111,7 @@ async def test_list_notes_limit_clamped():
async def test_get_note_returns_dict():
fake = fake_note(id=5, title="found")
with patch(
"scribe.mcp.tools.notes.notes_svc.get_note_for_user",
"grimoire.mcp.tools.notes.notes_svc.get_note_for_user",
AsyncMock(return_value=(fake, "owner")),
):
out = await get_note(note_id=5)
@@ -135,9 +135,9 @@ async def test_get_note_warns_in_words_when_a_later_note_overtook_it():
acts on it confidently either way.
"""
fake = fake_note(id=5, title="June's answer")
with patch("scribe.mcp.tools.notes.notes_svc.get_note_for_user",
with patch("grimoire.mcp.tools.notes.notes_svc.get_note_for_user",
AsyncMock(return_value=(fake, "owner"))), \
patch("scribe.mcp.tools.notes.supersession_svc.get_relations",
patch("grimoire.mcp.tools.notes.supersession_svc.get_relations",
AsyncMock(return_value={"supersedes": [], "superseded_by": [9]})):
out = await get_note(note_id=5)
assert out["superseded_by"] == [9]
@@ -153,10 +153,10 @@ async def test_get_note_opens_a_shared_record_and_says_whose_it_is():
handed — and the web UI opens the same note fine."""
theirs = fake_note(id=5, title="Their note", user_id=9)
with patch(
"scribe.mcp.tools.notes.notes_svc.get_note_for_user",
"grimoire.mcp.tools.notes.notes_svc.get_note_for_user",
AsyncMock(return_value=(theirs, "viewer")),
), patch(
"scribe.services.access.describe_provenance",
"grimoire.services.access.describe_provenance",
AsyncMock(return_value={"shared": True, "owner": "alex",
"permission": "viewer"}),
):
@@ -169,7 +169,7 @@ async def test_get_note_opens_a_shared_record_and_says_whose_it_is():
@pytest.mark.asyncio
async def test_get_note_raises_when_not_found():
with patch(
"scribe.mcp.tools.notes.notes_svc.get_note_for_user",
"grimoire.mcp.tools.notes.notes_svc.get_note_for_user",
AsyncMock(return_value=None),
):
with pytest.raises(ValueError, match="note 999 not found"):
@@ -183,7 +183,7 @@ async def test_get_note_treats_a_trashed_note_as_missing():
trashed = fake_note(id=5)
trashed.deleted_at = "2026-07-01T00:00:00Z"
with patch(
"scribe.mcp.tools.notes.notes_svc.get_note_for_user",
"grimoire.mcp.tools.notes.notes_svc.get_note_for_user",
AsyncMock(return_value=(trashed, "owner")),
):
with pytest.raises(ValueError, match="note 5 not found"):
@@ -194,7 +194,7 @@ async def test_get_note_treats_a_trashed_note_as_missing():
async def test_create_note_passes_through():
fake = fake_note(id=10, title="new")
mock = AsyncMock(return_value=fake)
with patch("scribe.mcp.tools.notes.notes_svc.create_note", mock):
with patch("grimoire.mcp.tools.notes.notes_svc.create_note", mock):
out = await create_note(title="new", body="x", tags=["a"], project_id=5)
assert out["id"] == 10
assert mock.call_args.kwargs["title"] == "new"
@@ -206,7 +206,7 @@ async def test_create_note_project_zero_becomes_none():
"""project_id=0 sentinel must become None at the service layer (orphan note)."""
fake = fake_note()
mock = AsyncMock(return_value=fake)
with patch("scribe.mcp.tools.notes.notes_svc.create_note", mock):
with patch("grimoire.mcp.tools.notes.notes_svc.create_note", mock):
await create_note(title="t", project_id=0)
assert mock.call_args.kwargs["project_id"] is None
@@ -221,7 +221,7 @@ async def test_update_note_only_sends_non_default_fields():
door takes, and the same test."""
fake = fake_note()
mock = AsyncMock(return_value=fake)
with patch("scribe.mcp.tools.notes.notes_svc.update_note", mock):
with patch("grimoire.mcp.tools.notes.notes_svc.update_note", mock):
await update_note(note_id=1, title="new title")
args, kwargs = mock.call_args
assert args == (7, 1)
@@ -234,11 +234,11 @@ async def test_update_note_forwards_a_check_but_not_an_empty_one():
""""" means "leave this alone" at this door — an agent updating a body must
not wipe a check it was never asked about."""
mock = AsyncMock(return_value=fake_note())
with patch("scribe.mcp.tools.notes.notes_svc.update_note", mock):
with patch("grimoire.mcp.tools.notes.notes_svc.update_note", mock):
await update_note(note_id=1, verify_with="curl the docs")
assert mock.call_args.kwargs["verify_with"] == "curl the docs"
with patch("scribe.mcp.tools.notes.notes_svc.update_note", mock):
with patch("grimoire.mcp.tools.notes.notes_svc.update_note", mock):
await update_note(note_id=1, title="t")
assert "verify_with" not in mock.call_args.kwargs
@@ -247,7 +247,7 @@ async def test_update_note_forwards_a_check_but_not_an_empty_one():
async def test_update_note_forwards_clear_so_a_check_can_be_removed():
"""The only way to unset a field at a door where "" means "leave alone"."""
mock = AsyncMock(return_value=fake_note())
with patch("scribe.mcp.tools.notes.notes_svc.update_note", mock):
with patch("grimoire.mcp.tools.notes.notes_svc.update_note", mock):
await update_note(note_id=1, clear=["verify_with"])
assert mock.call_args.kwargs["clear"] == ["verify_with"]
@@ -255,7 +255,7 @@ async def test_update_note_forwards_clear_so_a_check_can_be_removed():
@pytest.mark.asyncio
async def test_create_note_forwards_the_check_fields():
mock = AsyncMock(return_value=fake_note())
with patch("scribe.mcp.tools.notes.notes_svc.create_note", mock):
with patch("grimoire.mcp.tools.notes.notes_svc.create_note", mock):
await create_note(title="t", verify_with="curl", expires_when="AMO changes")
assert mock.call_args.kwargs["verify_with"] == "curl"
assert mock.call_args.kwargs["expires_when"] == "AMO changes"
@@ -266,7 +266,7 @@ async def test_update_note_empty_tags_clears_explicitly():
"""tags=[] is an explicit clear, distinct from tags=None (omit)."""
fake = fake_note()
mock = AsyncMock(return_value=fake)
with patch("scribe.mcp.tools.notes.notes_svc.update_note", mock):
with patch("grimoire.mcp.tools.notes.notes_svc.update_note", mock):
await update_note(note_id=1, tags=[])
kwargs = dict(mock.call_args.kwargs)
kwargs.pop("clear")
@@ -277,7 +277,7 @@ async def test_update_note_empty_tags_clears_explicitly():
async def test_update_note_tags_none_means_omit():
fake = fake_note()
mock = AsyncMock(return_value=fake)
with patch("scribe.mcp.tools.notes.notes_svc.update_note", mock):
with patch("grimoire.mcp.tools.notes.notes_svc.update_note", mock):
await update_note(note_id=1, tags=None)
assert "tags" not in mock.call_args.kwargs
@@ -285,7 +285,7 @@ async def test_update_note_tags_none_means_omit():
@pytest.mark.asyncio
async def test_update_note_raises_when_not_found():
with patch(
"scribe.mcp.tools.notes.notes_svc.update_note",
"grimoire.mcp.tools.notes.notes_svc.update_note",
AsyncMock(return_value=None),
):
with pytest.raises(ValueError, match="note 999 not found"):
@@ -297,10 +297,10 @@ async def test_delete_note_soft_deletes_and_returns_batch():
doomed = MagicMock()
doomed.title = "the note that went"
with patch(
"scribe.mcp.tools.notes.trash_svc.delete",
"grimoire.mcp.tools.notes.trash_svc.delete",
AsyncMock(return_value="batch-1"),
), patch(
"scribe.mcp.tools.notes.notes_svc.get_note_for_user",
"grimoire.mcp.tools.notes.notes_svc.get_note_for_user",
AsyncMock(return_value=(doomed, "owner")),
):
result = await delete_note(note_id=7)
@@ -316,10 +316,10 @@ async def test_delete_note_still_deletes_when_the_title_lookup_fails():
"""The title is a courtesy on top of the delete, never a precondition for
it. A lookup that errors costs the name, not the operation."""
with patch(
"scribe.mcp.tools.notes.trash_svc.delete",
"grimoire.mcp.tools.notes.trash_svc.delete",
AsyncMock(return_value="batch-1"),
), patch(
"scribe.mcp.tools.notes.notes_svc.get_note_for_user",
"grimoire.mcp.tools.notes.notes_svc.get_note_for_user",
AsyncMock(side_effect=RuntimeError("database down")),
):
result = await delete_note(note_id=7)
@@ -330,10 +330,10 @@ async def test_delete_note_still_deletes_when_the_title_lookup_fails():
@pytest.mark.asyncio
async def test_delete_note_raises_when_not_found():
with patch(
"scribe.mcp.tools.notes.trash_svc.delete",
"grimoire.mcp.tools.notes.trash_svc.delete",
AsyncMock(return_value=None),
), patch(
"scribe.mcp.tools.notes.notes_svc.get_note_for_user",
"grimoire.mcp.tools.notes.notes_svc.get_note_for_user",
AsyncMock(return_value=None),
):
with pytest.raises(ValueError, match="note 999 not found"):
+16 -16
View File
@@ -10,7 +10,7 @@ pytestmark = pytest.mark.usefixtures("_bind_user")
@pytest.fixture(autouse=True)
def _no_plan_gate():
"""The plan gate reads the database; the tests that are about it re-patch it."""
with patch("scribe.mcp.tools.tasks.dedup_svc.plan_gate", AsyncMock(return_value=None)):
with patch("grimoire.mcp.tools.tasks.dedup_svc.plan_gate", AsyncMock(return_value=None)):
yield
@@ -18,9 +18,9 @@ def _no_plan_gate():
async def test_start_planning_tool_delegates_to_service():
payload = {"milestone": {"id": 5}, "applicable_rules": [], "project_rules": [],
"applicable_rules_truncated": False, "project_goal": "", "open_task_count": 0}
with patch("scribe.mcp.tools.tasks.planning_svc.start_planning",
with patch("grimoire.mcp.tools.tasks.planning_svc.start_planning",
AsyncMock(return_value=payload)) as mock:
from scribe.mcp.tools.tasks import start_planning
from grimoire.mcp.tools.tasks import start_planning
out = await start_planning(project_id=3, title="Plan it")
assert out["milestone"]["id"] == 5
# No body and no steps reach the service as None, so it seeds the template
@@ -36,11 +36,11 @@ async def test_start_planning_returns_the_plan_that_already_covers_it():
handed the first instead, and nothing is created."""
match = {"duplicate": True, "existing_id": 12}
svc = AsyncMock()
with patch("scribe.mcp.tools.tasks.dedup_svc.plan_gate",
with patch("grimoire.mcp.tools.tasks.dedup_svc.plan_gate",
AsyncMock(return_value=match)) as gate, \
patch("scribe.mcp.tools.tasks.dedup_svc.find_duplicate_note", AsyncMock()) as steps, \
patch("scribe.mcp.tools.tasks.planning_svc.start_planning", svc):
from scribe.mcp.tools.tasks import start_planning
patch("grimoire.mcp.tools.tasks.dedup_svc.find_duplicate_note", AsyncMock()) as steps, \
patch("grimoire.mcp.tools.tasks.planning_svc.start_planning", svc):
from grimoire.mcp.tools.tasks import start_planning
out = await start_planning(project_id=3, title="Metadata", body="design",
steps=[{"title": "Resolve editions", "body": "via providers"}])
assert out is match
@@ -55,10 +55,10 @@ async def test_start_planning_returns_the_plan_that_already_covers_it():
@pytest.mark.asyncio
async def test_force_creates_the_plan_without_asking_the_gate():
gate = AsyncMock(return_value={"duplicate": True})
with patch("scribe.mcp.tools.tasks.dedup_svc.plan_gate", gate), \
patch("scribe.mcp.tools.tasks.planning_svc.start_planning",
with patch("grimoire.mcp.tools.tasks.dedup_svc.plan_gate", gate), \
patch("grimoire.mcp.tools.tasks.planning_svc.start_planning",
AsyncMock(return_value={"milestone": {"id": 5}})) as svc:
from scribe.mcp.tools.tasks import start_planning
from grimoire.mcp.tools.tasks import start_planning
out = await start_planning(project_id=3, title="Metadata", force=True)
assert out["milestone"]["id"] == 5
svc.assert_awaited_once()
@@ -69,11 +69,11 @@ async def test_force_creates_the_plan_without_asking_the_gate():
async def test_get_task_augments_plan_with_rules():
applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False,
"project_rules": [{"id": 3, "title": "own"}]}
with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user",
with patch("grimoire.mcp.tools.tasks.notes_svc.get_note_for_user",
AsyncMock(return_value=(fake_task(task_kind="plan", id=9, project_id=3), "owner"))), \
patch("scribe.mcp.tools.tasks.rulebooks_svc.get_applicable_rules",
patch("grimoire.mcp.tools.tasks.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value=applicable)):
from scribe.mcp.tools.tasks import get_task
from grimoire.mcp.tools.tasks import get_task
out = await get_task(task_id=9)
assert out["applicable_rules"] == [{"id": 1, "title": "r"}]
assert out["project_rules"] == [{"id": 3, "title": "own"}]
@@ -83,11 +83,11 @@ async def test_get_task_augments_plan_with_rules():
@pytest.mark.asyncio
async def test_get_task_work_kind_has_no_rules():
with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user",
with patch("grimoire.mcp.tools.tasks.notes_svc.get_note_for_user",
AsyncMock(return_value=(fake_task(task_kind="work", id=9, project_id=3), "owner"))), \
patch("scribe.mcp.tools.tasks.rulebooks_svc.get_applicable_rules",
patch("grimoire.mcp.tools.tasks.rulebooks_svc.get_applicable_rules",
AsyncMock()) as mock_rules:
from scribe.mcp.tools.tasks import get_task
from grimoire.mcp.tools.tasks import get_task
out = await get_task(task_id=9)
assert "applicable_rules" not in out
assert not mock_rules.called
+46 -46
View File
@@ -10,7 +10,7 @@ pytestmark = pytest.mark.usefixtures("_bind_user")
@pytest.mark.asyncio
async def test_create_process_requires_title_and_body():
from scribe.mcp.tools.processes import create_process
from grimoire.mcp.tools.processes import create_process
with pytest.raises(ValueError):
await create_process(title="", body="something")
with pytest.raises(ValueError):
@@ -20,11 +20,11 @@ async def test_create_process_requires_title_and_body():
@pytest.mark.asyncio
async def test_create_process_sets_note_type():
created = fake_note(title="Drift Audit", note_type="process")
with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note",
with patch("grimoire.mcp.tools.processes.dedup_svc.find_duplicate_note",
AsyncMock(return_value=None)), \
patch("scribe.services.notes.create_note",
patch("grimoire.services.notes.create_note",
AsyncMock(return_value=created)) as mock_create:
from scribe.mcp.tools.processes import create_process
from grimoire.mcp.tools.processes import create_process
out = await create_process(title="Drift Audit", body="the prompt", tags=["audit"])
assert out["note_type"] == "process"
# the service was asked to create a process
@@ -37,16 +37,16 @@ async def test_create_process_blocks_a_near_duplicate():
"""The gate matters more for processes than for other kinds: each one becomes
a skill file that auto-surfaces, so two near-identical procedures don't just
bloat the corpus — they compete to be followed."""
from scribe.services.dedup import DuplicateMatch
from grimoire.services.dedup import DuplicateMatch
# The real dataclass, not a MagicMock: a mock answers every attribute, so it
# would pass whatever field names this test happened to guess and prove
# nothing about the payload the tool actually returns.
match = DuplicateMatch(id=42, title="Drift Audit", similarity=0.94, reason="semantic")
with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note",
with patch("grimoire.mcp.tools.processes.dedup_svc.find_duplicate_note",
AsyncMock(return_value=match)), \
patch("scribe.services.notes.create_note", AsyncMock()) as mock_create:
from scribe.mcp.tools.processes import create_process
patch("grimoire.services.notes.create_note", AsyncMock()) as mock_create:
from grimoire.mcp.tools.processes import create_process
out = await create_process(title="Drift Audit", body="the prompt")
assert out["duplicate"] is True
assert out["existing_id"] == 42
@@ -56,11 +56,11 @@ async def test_create_process_blocks_a_near_duplicate():
@pytest.mark.asyncio
async def test_create_process_force_bypasses_the_gate():
created = fake_note(title="Drift Audit", note_type="process")
with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note",
with patch("grimoire.mcp.tools.processes.dedup_svc.find_duplicate_note",
AsyncMock()) as find_mock, \
patch("scribe.services.notes.create_note",
patch("grimoire.services.notes.create_note",
AsyncMock(return_value=created)):
from scribe.mcp.tools.processes import create_process
from grimoire.mcp.tools.processes import create_process
await create_process(title="Drift Audit", body="the prompt", force=True)
find_mock.assert_not_awaited()
@@ -68,9 +68,9 @@ async def test_create_process_force_bypasses_the_gate():
@pytest.mark.asyncio
async def test_get_process_returns_body_and_candidates():
note = fake_note(id=7, title="Drift Audit", note_type="process")
with patch("scribe.services.notes.resolve_process",
with patch("grimoire.services.notes.resolve_process",
AsyncMock(return_value=(note, [{"id": 9, "title": "Drift Audit Notes"}]))):
from scribe.mcp.tools.processes import get_process
from grimoire.mcp.tools.processes import get_process
out = await get_process("drift")
assert out["id"] == 7
assert out["other_matches"] == [{"id": 9, "title": "Drift Audit Notes"}]
@@ -85,12 +85,12 @@ async def test_get_process_flags_another_users_process():
the returned body', so an unlabelled one would put someone else's procedure
in charge of the session."""
note = fake_note(id=7, title="Drift Audit", user_id=9, note_type="process")
with patch("scribe.services.notes.resolve_process",
with patch("grimoire.services.notes.resolve_process",
AsyncMock(return_value=(note, []))), \
patch("scribe.services.access.describe_provenance",
patch("grimoire.services.access.describe_provenance",
AsyncMock(return_value={"shared": True, "owner": "alex",
"permission": "viewer"})):
from scribe.mcp.tools.processes import get_process
from grimoire.mcp.tools.processes import get_process
out = await get_process("deploy")
assert out["shared"] is True
assert out["owner"] == "alex"
@@ -98,9 +98,9 @@ async def test_get_process_flags_another_users_process():
@pytest.mark.asyncio
async def test_get_process_not_found_raises():
with patch("scribe.services.notes.resolve_process",
with patch("grimoire.services.notes.resolve_process",
AsyncMock(return_value=(None, []))):
from scribe.mcp.tools.processes import get_process
from grimoire.mcp.tools.processes import get_process
with pytest.raises(ValueError):
await get_process("missing")
@@ -111,9 +111,9 @@ async def test_update_process_rejects_non_process_note():
# returns (note, permission).
plain = fake_note(id=3, title="Drift Audit", note_type="note")
plain.deleted_at = None
with patch("scribe.services.notes.get_note_for_user",
with patch("grimoire.services.notes.get_note_for_user",
AsyncMock(return_value=(plain, "owner"))):
from scribe.mcp.tools.processes import update_process
from grimoire.mcp.tools.processes import update_process
with pytest.raises(ValueError):
await update_process(process_id=3, title="x")
@@ -125,11 +125,11 @@ async def test_update_process_refuses_a_read_only_share_with_the_reason():
open would just send them looking for a missing id."""
theirs = fake_note(id=5, title="Drift Audit", user_id=9, note_type="process")
theirs.deleted_at = None
with patch("scribe.services.notes.get_note_for_user",
with patch("grimoire.services.notes.get_note_for_user",
AsyncMock(return_value=(theirs, "viewer"))), \
patch("scribe.services.access.can_write_note", AsyncMock(return_value=False)), \
patch("scribe.services.notes.update_note", AsyncMock()) as mock_update:
from scribe.mcp.tools.processes import update_process
patch("grimoire.services.access.can_write_note", AsyncMock(return_value=False)), \
patch("grimoire.services.notes.update_note", AsyncMock()) as mock_update:
from grimoire.mcp.tools.processes import update_process
with pytest.raises(ValueError, match="read-only"):
await update_process(process_id=5, title="x")
mock_update.assert_not_awaited()
@@ -139,11 +139,11 @@ async def test_update_process_refuses_a_read_only_share_with_the_reason():
async def test_delete_process_trashes_it_recoverably():
proc = fake_note(id=4, title="Drift Audit", note_type="process")
proc.deleted_at = None
with patch("scribe.services.notes.get_note_for_user",
with patch("grimoire.services.notes.get_note_for_user",
AsyncMock(return_value=(proc, "owner"))), \
patch("scribe.mcp.tools.processes.trash_svc.delete",
patch("grimoire.mcp.tools.processes.trash_svc.delete",
AsyncMock(return_value="batch-1")) as mock_delete:
from scribe.mcp.tools.processes import delete_process
from grimoire.mcp.tools.processes import delete_process
out = await delete_process(process_id=4)
assert out["deleted_batch_id"] == "batch-1"
# Through the trash, not a hard delete — restorable like every other kind.
@@ -157,10 +157,10 @@ async def test_delete_process_refuses_a_plain_note():
mistyped argument."""
plain = fake_note(id=3, title="Drift Audit", note_type="note")
plain.deleted_at = None
with patch("scribe.services.notes.get_note_for_user",
with patch("grimoire.services.notes.get_note_for_user",
AsyncMock(return_value=(plain, "owner"))), \
patch("scribe.mcp.tools.processes.trash_svc.delete", AsyncMock()) as mock_delete:
from scribe.mcp.tools.processes import delete_process
patch("grimoire.mcp.tools.processes.trash_svc.delete", AsyncMock()) as mock_delete:
from grimoire.mcp.tools.processes import delete_process
with pytest.raises(ValueError):
await delete_process(process_id=3)
mock_delete.assert_not_awaited()
@@ -172,11 +172,11 @@ async def test_delete_process_refuses_a_plain_note():
async def test_create_process_stores_the_moments_it_is_for():
created = fake_note(title="Release", note_type="process",
data={"moments": ["work.deliver"]})
with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note",
with patch("grimoire.mcp.tools.processes.dedup_svc.find_duplicate_note",
AsyncMock(return_value=None)), \
patch("scribe.services.notes.create_note",
patch("grimoire.services.notes.create_note",
AsyncMock(return_value=created)) as mock_create:
from scribe.mcp.tools.processes import create_process
from grimoire.mcp.tools.processes import create_process
out = await create_process(title="Release", body="steps",
moments=["Work.Deliver", "work.deliver"])
assert mock_create.await_args.kwargs["data"] == {"moments": ["work.deliver"]}
@@ -187,10 +187,10 @@ async def test_create_process_stores_the_moments_it_is_for():
async def test_create_process_refuses_an_unknown_moment_before_writing():
"""A typo'd moment would be stored and never fire — a procedure that looks
attached and is not."""
with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note",
with patch("grimoire.mcp.tools.processes.dedup_svc.find_duplicate_note",
AsyncMock(return_value=None)), \
patch("scribe.services.notes.create_note", AsyncMock()) as mock_create:
from scribe.mcp.tools.processes import create_process
patch("grimoire.services.notes.create_note", AsyncMock()) as mock_create:
from grimoire.mcp.tools.processes import create_process
with pytest.raises(ValueError, match="unknown moment"):
await create_process(title="Release", body="steps", moments=["work.shipped"])
mock_create.assert_not_awaited()
@@ -199,11 +199,11 @@ async def test_create_process_refuses_an_unknown_moment_before_writing():
@pytest.mark.asyncio
async def test_create_process_without_moments_stores_no_data():
created = fake_note(title="Release", note_type="process")
with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note",
with patch("grimoire.mcp.tools.processes.dedup_svc.find_duplicate_note",
AsyncMock(return_value=None)), \
patch("scribe.services.notes.create_note",
patch("grimoire.services.notes.create_note",
AsyncMock(return_value=created)) as mock_create:
from scribe.mcp.tools.processes import create_process
from grimoire.mcp.tools.processes import create_process
out = await create_process(title="Release", body="steps")
assert mock_create.await_args.kwargs["data"] is None
assert out["moments"] == []
@@ -211,12 +211,12 @@ async def test_create_process_without_moments_stores_no_data():
async def _update(moments, data):
proc = fake_note(id=5, title="Release", note_type="process", data=data)
with patch("scribe.services.notes.get_note_for_user",
with patch("grimoire.services.notes.get_note_for_user",
AsyncMock(return_value=(proc, "owner"))), \
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), \
patch("scribe.services.notes.update_note",
patch("grimoire.services.access.can_write_note", AsyncMock(return_value=True)), \
patch("grimoire.services.notes.update_note",
AsyncMock(return_value=proc)) as mock_update:
from scribe.mcp.tools.processes import update_process
from grimoire.mcp.tools.processes import update_process
await update_process(process_id=5, moments=moments)
return mock_update.await_args.kwargs
@@ -242,8 +242,8 @@ async def test_update_process_without_moments_leaves_data_alone():
async def test_get_process_says_which_moments_it_is_for():
note = fake_note(id=7, title="Release", note_type="process",
data={"moments": ["work.deliver"]})
with patch("scribe.services.notes.resolve_process", AsyncMock(return_value=(note, []))):
from scribe.mcp.tools.processes import get_process
with patch("grimoire.services.notes.resolve_process", AsyncMock(return_value=(note, []))):
from grimoire.mcp.tools.processes import get_process
out = await get_process("release")
assert out["moments"] == ["work.deliver"]
@@ -253,7 +253,7 @@ def test_register_attaches_every_tool_in_the_module():
registered is invisible to an agent, and nothing else would notice."""
import inspect
from scribe.mcp.tools import processes
from grimoire.mcp.tools import processes
mcp = FakeMCP()
processes.register(mcp)
+56 -56
View File
@@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.mcp.tools.projects import (
from grimoire.mcp.tools.projects import (
list_projects, get_project, create_project,
update_project, enter_project,
)
@@ -21,7 +21,7 @@ def _no_systems():
the lookup is stubbed to the common case — a project with none. The
populated shape is asserted in its own test below.
"""
with patch("scribe.mcp.tools.projects.systems_svc.list_systems",
with patch("grimoire.mcp.tools.projects.systems_svc.list_systems",
AsyncMock(return_value=[])):
yield
@@ -33,7 +33,7 @@ def _no_platforms():
case — a project with none yet. The populated shape is asserted in its
own test.
"""
with patch("scribe.mcp.tools.projects.platforms_svc.project_platforms",
with patch("grimoire.mcp.tools.projects.platforms_svc.project_platforms",
AsyncMock(return_value=[])):
yield
@@ -44,7 +44,7 @@ def _no_coverage():
deal: no database here, stub the common case (nothing computed). The
populated line is asserted in tests/test_pattern_coverage.py.
"""
with patch("scribe.mcp.tools.projects.coverage_svc.cached_coverage",
with patch("grimoire.mcp.tools.projects.coverage_svc.cached_coverage",
AsyncMock(return_value=None)):
yield
@@ -54,7 +54,7 @@ def _no_family():
"""enter_project reads the family-canon readout —
no database here, so the common case is stubbed: nothing to answer. The
populated key is asserted in its own test below."""
with patch("scribe.mcp.tools.projects.family_adoption_svc.family_readout",
with patch("grimoire.mcp.tools.projects.family_adoption_svc.family_readout",
AsyncMock(return_value=None)) as mock:
yield mock
@@ -64,7 +64,7 @@ def _no_background_seed():
"""enter_project fire-and-forgets a coverage self-seed. These
are no-database unit tests, so the spawn is stubbed out; the firing shape
has its own test below."""
with patch("scribe.mcp.tools.projects.spawn") as mock:
with patch("grimoire.mcp.tools.projects.spawn") as mock:
yield mock
@@ -75,7 +75,7 @@ def _no_bootstrap():
records — a database read. Stub the common case (young project, no ask);
the firing shape has its own test below.
"""
with patch("scribe.mcp.tools.projects.systems_tools.bootstrap_systems_ask",
with patch("grimoire.mcp.tools.projects.systems_tools.bootstrap_systems_ask",
AsyncMock(return_value=None)) as mock:
yield mock
@@ -84,7 +84,7 @@ def _no_bootstrap():
async def test_list_projects_wraps_in_dict():
rows = [fake_project(id=1), fake_project(id=2)]
with patch(
"scribe.mcp.tools.projects.projects_svc.list_projects",
"grimoire.mcp.tools.projects.projects_svc.list_projects",
AsyncMock(return_value=rows),
):
out = await list_projects()
@@ -99,13 +99,13 @@ async def test_get_project_enriches_with_milestone_summary():
"rules": [], "truncated": False,
}
with patch(
"scribe.mcp.tools.projects.projects_svc.get_project",
"grimoire.mcp.tools.projects.projects_svc.get_project",
AsyncMock(return_value=p),
), patch(
"scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
"grimoire.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=milestone_summary),
), patch(
"scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
"grimoire.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value=applicable_payload),
):
out = await get_project(project_id=5)
@@ -131,13 +131,13 @@ async def test_get_project_includes_applicable_rules_and_project_rules():
"truncated": False,
}
with patch(
"scribe.mcp.tools.projects.projects_svc.get_project",
"grimoire.mcp.tools.projects.projects_svc.get_project",
AsyncMock(return_value=p),
), patch(
"scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
"grimoire.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=milestone_summary),
), patch(
"scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
"grimoire.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value=applicable_payload),
):
out = await get_project(project_id=3)
@@ -151,7 +151,7 @@ async def test_get_project_includes_applicable_rules_and_project_rules():
@pytest.mark.asyncio
async def test_get_project_raises_when_not_found():
with patch(
"scribe.mcp.tools.projects.projects_svc.get_project",
"grimoire.mcp.tools.projects.projects_svc.get_project",
AsyncMock(return_value=None),
):
with pytest.raises(ValueError, match="project 999 not found"):
@@ -162,7 +162,7 @@ async def test_get_project_raises_when_not_found():
async def test_create_project_passes_color_empty_as_none():
p = fake_project()
mock = AsyncMock(return_value=p)
with patch("scribe.mcp.tools.projects.projects_svc.create_project", mock):
with patch("grimoire.mcp.tools.projects.projects_svc.create_project", mock):
await create_project(title="P", color="")
assert mock.call_args.kwargs["color"] is None
@@ -171,7 +171,7 @@ async def test_create_project_passes_color_empty_as_none():
async def test_update_project_only_sends_non_default_fields():
p = fake_project()
mock = AsyncMock(return_value=p)
with patch("scribe.mcp.tools.projects.projects_svc.update_project", mock):
with patch("grimoire.mcp.tools.projects.projects_svc.update_project", mock):
await update_project(project_id=1, status="archived")
args, kwargs = mock.call_args
assert args == (7, 1)
@@ -181,7 +181,7 @@ async def test_update_project_only_sends_non_default_fields():
@pytest.mark.asyncio
async def test_update_project_raises_when_not_found():
with patch(
"scribe.mcp.tools.projects.projects_svc.update_project",
"grimoire.mcp.tools.projects.projects_svc.update_project",
AsyncMock(return_value=None),
):
with pytest.raises(ValueError, match="project 999 not found"):
@@ -209,16 +209,16 @@ async def test_enter_project_composes_full_context():
note1.updated_at = None # avoids datetime mocking
with patch(
"scribe.mcp.tools.projects.projects_svc.get_project",
"grimoire.mcp.tools.projects.projects_svc.get_project",
AsyncMock(return_value=p),
), patch(
"scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
"grimoire.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value=applicable_payload),
), patch(
"scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
"grimoire.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=milestone_summary),
), patch(
"scribe.mcp.tools.projects.notes_svc.list_notes",
"grimoire.mcp.tools.projects.notes_svc.list_notes",
AsyncMock(side_effect=[([task1], 1), ([note1], 1)]),
):
out = await enter_project(project_id=5)
@@ -260,19 +260,19 @@ async def test_enter_project_surfaces_the_systems_vocabulary():
sys1.description = "Embeddings, ranking, auto-inject.\nLong detail below."
with patch(
"scribe.mcp.tools.projects.projects_svc.get_project",
"grimoire.mcp.tools.projects.projects_svc.get_project",
AsyncMock(return_value=p),
), patch(
"scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
"grimoire.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value={"rules": [], "truncated": False}),
), patch(
"scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
"grimoire.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=[]),
), patch(
"scribe.mcp.tools.projects.notes_svc.list_notes",
"grimoire.mcp.tools.projects.notes_svc.list_notes",
AsyncMock(side_effect=[([], 0), ([], 0)]),
), patch(
"scribe.mcp.tools.projects.systems_svc.list_systems",
"grimoire.mcp.tools.projects.systems_svc.list_systems",
AsyncMock(return_value=[sys1]),
):
out = await enter_project(project_id=5)
@@ -283,13 +283,13 @@ async def test_enter_project_surfaces_the_systems_vocabulary():
def _enter_project_stubs(p):
"""The four patches every enter_project test repeats, as one context list."""
return [
patch("scribe.mcp.tools.projects.projects_svc.get_project",
patch("grimoire.mcp.tools.projects.projects_svc.get_project",
AsyncMock(return_value=p)),
patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
patch("grimoire.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value={"rules": [], "truncated": False})),
patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
patch("grimoire.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=[])),
patch("scribe.mcp.tools.projects.notes_svc.list_notes",
patch("grimoire.mcp.tools.projects.notes_svc.list_notes",
AsyncMock(side_effect=[([], 0), ([], 0)])),
]
@@ -306,7 +306,7 @@ async def test_enter_project_carries_the_bootstrap_ask_when_it_fires():
for cm in _enter_project_stubs(fake_project(id=5)):
stack.enter_context(cm)
stack.enter_context(patch(
"scribe.mcp.tools.projects.systems_tools.bootstrap_systems_ask",
"grimoire.mcp.tools.projects.systems_tools.bootstrap_systems_ask",
AsyncMock(return_value=ask),
))
out = await enter_project(project_id=5)
@@ -327,7 +327,7 @@ async def test_enter_project_never_asks_bootstrap_once_a_vocabulary_exists(
for cm in _enter_project_stubs(fake_project(id=5)):
stack.enter_context(cm)
stack.enter_context(patch(
"scribe.mcp.tools.projects.systems_svc.list_systems",
"grimoire.mcp.tools.projects.systems_svc.list_systems",
AsyncMock(return_value=[sys1]),
))
out = await enter_project(project_id=5)
@@ -351,7 +351,7 @@ async def test_enter_project_fires_the_coverage_seed_on_the_owner(
for cm in _enter_project_stubs(project):
stack.enter_context(cm)
seed = stack.enter_context(patch(
"scribe.mcp.tools.projects.coverage_svc.refresh_if_stale",
"grimoire.mcp.tools.projects.coverage_svc.refresh_if_stale",
MagicMock(return_value=object()),
))
await enter_project(project_id=5)
@@ -372,19 +372,19 @@ async def test_enter_project_hands_back_the_design_system_when_the_project_has_o
"inherits_from": ["House"], "description": ""}
with patch(
"scribe.mcp.tools.projects.projects_svc.get_project",
"grimoire.mcp.tools.projects.projects_svc.get_project",
AsyncMock(return_value=p),
), patch(
"scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
"grimoire.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value={"rules": [], "truncated": False}),
), patch(
"scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
"grimoire.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=[]),
), patch(
"scribe.mcp.tools.projects.notes_svc.list_notes",
"grimoire.mcp.tools.projects.notes_svc.list_notes",
AsyncMock(side_effect=[([], 0), ([], 0)]),
), patch(
"scribe.mcp.tools.projects.design_systems_svc.design_context",
"grimoire.mcp.tools.projects.design_systems_svc.design_context",
AsyncMock(return_value=design),
) as ctx:
out = await enter_project(project_id=5)
@@ -400,7 +400,7 @@ async def test_enter_project_hands_back_the_design_system_when_the_project_has_o
@pytest.mark.asyncio
async def test_enter_project_raises_when_project_not_found():
with patch(
"scribe.mcp.tools.projects.projects_svc.get_project",
"grimoire.mcp.tools.projects.projects_svc.get_project",
AsyncMock(return_value=None),
):
with pytest.raises(ValueError, match="project 999 not found"):
@@ -409,7 +409,7 @@ async def test_enter_project_raises_when_project_not_found():
def test_enter_project_registered_in_register():
"""register(mcp) registers enter_project alongside the existing tools."""
from scribe.mcp.tools.projects import register
from grimoire.mcp.tools.projects import register
mcp = FakeMCP()
register(mcp)
@@ -422,8 +422,8 @@ def test_enter_project_registered_in_register():
@pytest.mark.asyncio
async def test_create_project_without_inception_args_stays_undecided():
p = fake_project(id=5, title="P", inception=None)
with patch("scribe.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \
patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock()) as decide:
with patch("grimoire.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \
patch("grimoire.mcp.tools.projects.inception_svc.decide", AsyncMock()) as decide:
out = await create_project(title="P")
decide.assert_not_awaited()
assert "inception_hint" in out and "inception_effects" not in out
@@ -433,8 +433,8 @@ async def test_create_project_without_inception_args_stays_undecided():
async def test_create_project_with_inception_args_decides_via_mcp():
p = fake_project(id=5, title="P", inception=None)
decided = {"inception": {"via": "mcp", "choices": {}}, "effects": {"systems_seeded": []}}
with patch("scribe.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \
patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide:
with patch("grimoire.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \
patch("grimoire.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide:
out = await create_project(title="P", design_system_id=-1, seed_systems=True)
kw = decide.await_args.kwargs
assert decide.await_args.args[1] == 5 and kw["via"] == "mcp"
@@ -444,9 +444,9 @@ async def test_create_project_with_inception_args_decides_via_mcp():
@pytest.mark.asyncio
async def test_decide_project_inception_tool_records_an_inherit_all_decision_when_given_nothing():
from scribe.mcp.tools.projects import decide_project_inception
from grimoire.mcp.tools.projects import decide_project_inception
decided = {"inception": {"via": "mcp"}, "effects": {}}
with patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide:
with patch("grimoire.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide:
out = await decide_project_inception(project_id=5)
assert decide.await_args.kwargs["choices"] == {}
assert out["project_id"] == 5 and out["inception"]["via"] == "mcp"
@@ -458,13 +458,13 @@ async def test_enter_project_carries_the_inception_ask_only_for_an_undecided_own
ask = {"defaults": {}, "ask": "decide", "call": "decide_project_inception(...)"}
async def run(project):
with patch("scribe.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=project)), \
patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", AsyncMock(return_value=applicable)), \
patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", AsyncMock(return_value=[])), \
patch("scribe.mcp.tools.projects.notes_svc.list_notes", AsyncMock(side_effect=[([], 0), ([], 0)])), \
patch("scribe.mcp.tools.projects.systems_svc.list_systems", AsyncMock(return_value=[])), \
patch("scribe.mcp.tools.projects.systems_tools.bootstrap_systems_ask", AsyncMock(return_value=None)), \
patch("scribe.mcp.tools.projects.inception_svc.inception_ask", AsyncMock(return_value=ask)) as asked:
with patch("grimoire.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=project)), \
patch("grimoire.mcp.tools.projects.rulebooks_svc.get_applicable_rules", AsyncMock(return_value=applicable)), \
patch("grimoire.mcp.tools.projects.milestones_svc.get_project_milestone_summary", AsyncMock(return_value=[])), \
patch("grimoire.mcp.tools.projects.notes_svc.list_notes", AsyncMock(side_effect=[([], 0), ([], 0)])), \
patch("grimoire.mcp.tools.projects.systems_svc.list_systems", AsyncMock(return_value=[])), \
patch("grimoire.mcp.tools.projects.systems_tools.bootstrap_systems_ask", AsyncMock(return_value=None)), \
patch("grimoire.mcp.tools.projects.inception_svc.inception_ask", AsyncMock(return_value=ask)) as asked:
return await enter_project(project_id=5), asked
# Own + undecided → the ask rides along.
@@ -479,8 +479,8 @@ async def test_enter_project_carries_the_inception_ask_only_for_an_undecided_own
def test_inception_routes_and_tool_are_registered():
from scribe.app import create_app
from scribe.mcp.server import build_mcp_server
from grimoire.app import create_app
from grimoire.mcp.server import build_mcp_server
rules = {r.rule for r in create_app().url_map.iter_rules()}
assert "/api/projects/<int:project_id>/inception" in rules
assert "/api/projects/<int:project_id>/inception/defaults" in rules
+7 -7
View File
@@ -16,14 +16,14 @@ pytestmark = pytest.mark.usefixtures("_bind_user")
async def _update(owed=None, **kwargs):
from scribe.mcp.tools.tasks import update_task
from grimoire.mcp.tools.tasks import update_task
note = MagicMock(id=5, user_id=7, project_id=3, started_at="2026-10-06T09:00:00+00:00")
note.to_dict.return_value = {"id": 5}
with patch("scribe.mcp.tools.tasks.notes_svc.update_note", AsyncMock(return_value=note)), \
patch("scribe.mcp.tools.tasks.systems_tools.attach_systems", AsyncMock()), \
patch("scribe.mcp.tools.tasks.placement_svc.attach_placement", AsyncMock()), \
patch("scribe.mcp.tools.tasks.family_adoption_svc.owed_since",
with patch("grimoire.mcp.tools.tasks.notes_svc.update_note", AsyncMock(return_value=note)), \
patch("grimoire.mcp.tools.tasks.systems_tools.attach_systems", AsyncMock()), \
patch("grimoire.mcp.tools.tasks.placement_svc.attach_placement", AsyncMock()), \
patch("grimoire.mcp.tools.tasks.family_adoption_svc.owed_since",
AsyncMock(return_value=list(owed or []))):
return await update_task(task_id=5, **kwargs)
@@ -42,7 +42,7 @@ async def test_other_updates_do_not(kwargs):
async def test_closing_carries_the_plain_cue_and_no_preference_key_of_its_own():
"""The retired key does not come back beside the moment delivery."""
from scribe.mcp.tools.tasks import REPORT_BACK_CUE
from grimoire.mcp.tools.tasks import REPORT_BACK_CUE
out = await _update(status="done")
assert "reply_preferences" not in out
@@ -56,7 +56,7 @@ _OWED = {"idea_id": 9, "idea_title": "Signed APK lane", "project_id": 4,
async def test_closing_names_the_owed_adoptions_filed_while_the_task_was_open():
"""The finishing moment of family canon: owed family work filed
into a project during this task comes back for the report to name."""
from scribe.services.family_adoption import OWED_CUE
from grimoire.services.family_adoption import OWED_CUE
out = await _update(owed=[_OWED], status="done")
assert out["family_owed"] == [_OWED]
+77 -77
View File
@@ -13,10 +13,10 @@ pytestmark = pytest.mark.usefixtures("_bind_user")
async def test_list_rulebooks_wraps_in_dict():
rows = [fake_rulebook(id=1, title="t"), fake_rulebook(id=2, title="t")]
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_rulebooks",
"grimoire.mcp.tools.rulebooks.rulebooks_svc.list_rulebooks",
AsyncMock(return_value=rows),
):
from scribe.mcp.tools.rulebooks import list_rulebooks
from grimoire.mcp.tools.rulebooks import list_rulebooks
out = await list_rulebooks()
assert len(out["rulebooks"]) == 2
@@ -26,13 +26,13 @@ async def test_get_rulebook_includes_topics():
rb = fake_rulebook(id=1, title="t")
topics = [fake_topic(id=10, title="git"), fake_topic(id=11, title="git")]
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rulebook",
"grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rulebook",
AsyncMock(return_value=rb),
), patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_topics",
"grimoire.mcp.tools.rulebooks.rulebooks_svc.list_topics",
AsyncMock(return_value=topics),
):
from scribe.mcp.tools.rulebooks import get_rulebook
from grimoire.mcp.tools.rulebooks import get_rulebook
out = await get_rulebook(rulebook_id=1)
assert out["id"] == 1
assert len(out["topics"]) == 2
@@ -41,10 +41,10 @@ async def test_get_rulebook_includes_topics():
@pytest.mark.asyncio
async def test_get_rulebook_raises_when_not_found():
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rulebook",
"grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rulebook",
AsyncMock(return_value=None),
):
from scribe.mcp.tools.rulebooks import get_rulebook
from grimoire.mcp.tools.rulebooks import get_rulebook
with pytest.raises(ValueError, match="rulebook 999 not found"):
await get_rulebook(rulebook_id=999)
@@ -58,8 +58,8 @@ async def test_get_rulebook_raises_when_not_found():
async def test_create_rule_passes_required_fields():
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", mock), _plain_detail():
from scribe.mcp.tools.rulebooks import create_rule
with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.create_rule", mock), _plain_detail():
from grimoire.mcp.tools.rulebooks import create_rule
await create_rule(
topic_id=10, title="dev is home", statement="Work directly on dev",
when_to_apply="when the moment this fixture stands in for arises",
@@ -72,13 +72,13 @@ async def test_create_rule_passes_required_fields():
@pytest.mark.asyncio
async def test_create_rule_blocked_by_duplicate_gate():
from scribe.services.dedup import DuplicateMatch
from grimoire.services.dedup import DuplicateMatch
dup = DuplicateMatch(id=47, title="dev is home", similarity=1.0, reason="title")
create_mock = AsyncMock()
with patch("scribe.mcp.tools.rulebooks.dedup_svc.find_duplicate_rule",
with patch("grimoire.mcp.tools.rulebooks.dedup_svc.find_duplicate_rule",
AsyncMock(return_value=dup)), \
patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", create_mock):
from scribe.mcp.tools.rulebooks import create_rule
patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.create_rule", create_mock):
from grimoire.mcp.tools.rulebooks import create_rule
out = await create_rule(topic_id=10, title="dev is home", statement="x", when_to_apply="when the moment this fixture stands in for arises")
assert out["duplicate"] is True
assert out["existing_id"] == 47
@@ -89,11 +89,11 @@ async def test_create_rule_blocked_by_duplicate_gate():
@pytest.mark.asyncio
async def test_create_rule_force_bypasses_duplicate_gate():
find_mock = AsyncMock()
with patch("scribe.mcp.tools.rulebooks.dedup_svc.find_duplicate_rule", find_mock), \
patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule",
with patch("grimoire.mcp.tools.rulebooks.dedup_svc.find_duplicate_rule", find_mock), \
patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.create_rule",
AsyncMock(return_value=fake_rule(id=5, title="r", statement="s", topic_id=10))), \
_plain_detail():
from scribe.mcp.tools.rulebooks import create_rule
from grimoire.mcp.tools.rulebooks import create_rule
out = await create_rule(topic_id=10, title="dev is home", statement="x", force=True, when_to_apply="when the moment this fixture stands in for arises")
assert out["id"] == 5
find_mock.assert_not_called()
@@ -103,8 +103,8 @@ async def test_create_rule_force_bypasses_duplicate_gate():
async def test_update_rule_only_sends_non_default_fields():
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock), _plain_detail():
from scribe.mcp.tools.rulebooks import update_rule
with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock), _plain_detail():
from grimoire.mcp.tools.rulebooks import update_rule
await update_rule(rule_id=1, statement="new statement")
args, kwargs = mock.call_args
assert args == (1, 7)
@@ -124,8 +124,8 @@ async def test_update_rule_forwards_the_fields_named_for_clearing():
"""
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock), _plain_detail():
from scribe.mcp.tools.rulebooks import update_rule
with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock), _plain_detail():
from grimoire.mcp.tools.rulebooks import update_rule
await update_rule(rule_id=1, clear_fields=["verify_with"])
_args, kwargs = mock.call_args
assert kwargs == {"clear": ["verify_with"]}
@@ -135,8 +135,8 @@ async def test_update_rule_forwards_the_fields_named_for_clearing():
async def test_update_rule_sends_the_check_fields_when_given():
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock), _plain_detail():
from scribe.mcp.tools.rulebooks import update_rule
with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock), _plain_detail():
from grimoire.mcp.tools.rulebooks import update_rule
await update_rule(
rule_id=1,
verify_with="cat CI-runner/renovate/config.js",
@@ -155,13 +155,13 @@ async def test_delete_rule_without_confirmed_returns_warning():
"""delete_rule with confirmed=False returns a preview, not an action."""
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
"grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=rule),
), patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.delete_rule",
"grimoire.mcp.tools.rulebooks.rulebooks_svc.delete_rule",
AsyncMock(),
) as mock_delete:
from scribe.mcp.tools.rulebooks import delete_rule
from grimoire.mcp.tools.rulebooks import delete_rule
out = await delete_rule(rule_id=1, confirmed=False)
assert out.get("confirmed_required") is True
assert "confirmed=True" in out.get("warning", "")
@@ -173,13 +173,13 @@ async def test_delete_rule_with_confirmed_soft_deletes():
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock_delete = AsyncMock(return_value="batch-1")
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
"grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=rule),
), patch(
"scribe.mcp.tools.rulebooks.trash_svc.delete",
"grimoire.mcp.tools.rulebooks.trash_svc.delete",
mock_delete,
):
from scribe.mcp.tools.rulebooks import delete_rule
from grimoire.mcp.tools.rulebooks import delete_rule
out = await delete_rule(rule_id=1, confirmed=True)
assert out["deleted"] == 1
assert out["deleted_batch_id"] == "batch-1"
@@ -194,7 +194,7 @@ def test_register_attaches_every_tool():
said "sixteen" for ten tools' worth of growth — the number lives in the
assertion, not the title, so it cannot drift again.
"""
from scribe.mcp.tools.rulebooks import register
from grimoire.mcp.tools.rulebooks import register
mcp = FakeMCP()
register(mcp)
@@ -235,8 +235,8 @@ def test_register_attaches_every_tool():
async def test_create_project_rule_passes_required_fields():
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail():
from scribe.mcp.tools.rulebooks import create_project_rule
with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail():
from grimoire.mcp.tools.rulebooks import create_project_rule
await create_project_rule(
project_id=42,
statement="Always run migrations through alembic, not raw SQL.",
@@ -254,8 +254,8 @@ async def test_create_project_rule_passes_required_fields():
async def test_create_project_rule_derives_title_from_statement():
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail():
from scribe.mcp.tools.rulebooks import create_project_rule
with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail():
from grimoire.mcp.tools.rulebooks import create_project_rule
await create_project_rule(
project_id=42,
statement="Avoid auto-generated docstrings. Reviewers find them noise.",
@@ -270,8 +270,8 @@ async def test_create_project_rule_derives_title_from_statement():
async def test_create_project_rule_uses_explicit_title_when_given():
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
mock = AsyncMock(return_value=rule)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail():
from scribe.mcp.tools.rulebooks import create_project_rule
with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail():
from grimoire.mcp.tools.rulebooks import create_project_rule
await create_project_rule(
project_id=42,
statement="anything",
@@ -292,8 +292,8 @@ async def test_relate_rules_forwards_the_kind_and_the_why():
relation = MagicMock()
relation.to_dict.return_value = {"id": 9, "kind": "co_surfaces"}
mock = AsyncMock(return_value=relation)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.add_rule_relation", mock):
from scribe.mcp.tools.rulebooks import relate_rules
with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.add_rule_relation", mock):
from grimoire.mcp.tools.rulebooks import relate_rules
out = await relate_rules(
from_rule_id=46, to_rule_id=144, kind="co_surfaces",
note="a stale channel tag and an unparseable version both read as "
@@ -310,18 +310,18 @@ async def test_relate_rules_forwards_the_kind_and_the_why():
async def test_relate_rules_raises_when_either_end_is_not_yours():
"""The service returns None when it cannot see both rules — a one-sided
edge would surface a rule the caller has no business reading."""
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.add_rule_relation",
with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.add_rule_relation",
AsyncMock(return_value=None)):
from scribe.mcp.tools.rulebooks import relate_rules
from grimoire.mcp.tools.rulebooks import relate_rules
with pytest.raises(ValueError, match="not found"):
await relate_rules(from_rule_id=1, to_rule_id=2, kind="overrides")
@pytest.mark.asyncio
async def test_unrelate_rules_raises_when_the_edge_is_gone():
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.remove_rule_relation",
with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.remove_rule_relation",
AsyncMock(return_value=False)):
from scribe.mcp.tools.rulebooks import unrelate_rules
from grimoire.mcp.tools.rulebooks import unrelate_rules
with pytest.raises(ValueError, match="not found"):
await unrelate_rules(relation_id=99)
@@ -332,7 +332,7 @@ def _fake_version(**over):
"""A RuleVersion-shaped stand-in. A real model instance rather than a
MagicMock, because the tool calls `to_dict` and a mock would hand back
another mock instead of failing."""
from scribe.models.rule_version import RuleVersion
from grimoire.models.rule_version import RuleVersion
from datetime import datetime, timezone
defaults = {
@@ -353,15 +353,15 @@ async def test_rule_history_lists_without_the_heavy_text():
thousands of characters, so a history carrying every field would cost
more to read than the answer is worth."""
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_rule_versions",
"grimoire.mcp.tools.rulebooks.rulebooks_svc.list_rule_versions",
AsyncMock(return_value=[_fake_version(), _fake_version(id=4)]),
), patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
"grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=fake_rule(
id=100, title="The runner has no bash", statement="s", topic_id=10,
)),
):
from scribe.mcp.tools.rulebooks import rule_history
from grimoire.mcp.tools.rulebooks import rule_history
out = await rule_history(rule_id=100)
assert out["total"] == 2
@@ -375,10 +375,10 @@ async def test_rule_history_opens_one_version_in_full():
"""Passing a version id switches from the index to the text — which is
the whole reason the listing can afford to omit it."""
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule_version",
"grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rule_version",
AsyncMock(return_value=_fake_version()),
):
from scribe.mcp.tools.rulebooks import rule_history
from grimoire.mcp.tools.rulebooks import rule_history
out = await rule_history(rule_id=100, version_id=5)
assert out["statement"] == "Use sh."
@@ -390,15 +390,15 @@ async def test_rule_history_says_an_empty_history_is_ordinary():
"""Most rules have never been reworded. Without this line an empty list
reads as a lost history or a broken tool."""
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_rule_versions",
"grimoire.mcp.tools.rulebooks.rulebooks_svc.list_rule_versions",
AsyncMock(return_value=[]),
), patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
"grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=fake_rule(
id=100, title="The runner has no bash", statement="s", topic_id=10,
)),
):
from scribe.mcp.tools.rulebooks import rule_history
from grimoire.mcp.tools.rulebooks import rule_history
out = await rule_history(rule_id=100)
assert out["total"] == 0
@@ -411,10 +411,10 @@ async def test_rule_history_raises_when_the_rule_is_not_yours():
turn that into an empty history — which would read as "this rule has no
past" rather than "this is not your rule"."""
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_rule_versions",
"grimoire.mcp.tools.rulebooks.rulebooks_svc.list_rule_versions",
AsyncMock(return_value=None),
):
from scribe.mcp.tools.rulebooks import rule_history
from grimoire.mcp.tools.rulebooks import rule_history
with pytest.raises(ValueError, match="rule 100 not found"):
await rule_history(rule_id=100)
@@ -422,10 +422,10 @@ async def test_rule_history_raises_when_the_rule_is_not_yours():
@pytest.mark.asyncio
async def test_rule_history_raises_for_a_version_on_another_rule():
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule_version",
"grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rule_version",
AsyncMock(return_value=None),
):
from scribe.mcp.tools.rulebooks import rule_history
from grimoire.mcp.tools.rulebooks import rule_history
with pytest.raises(ValueError, match="version 5 not found"):
await rule_history(rule_id=100, version_id=5)
@@ -435,7 +435,7 @@ def test_rule_history_docstring_says_what_a_version_HOLDS():
edit REPLACED, not the text it introduced. Read the other way, every
diff comes out backwards — so the docstring has to say it, and this is
the guard against a later tidy-up dropping the line."""
from scribe.mcp.tools.rulebooks import rule_history
from grimoire.mcp.tools.rulebooks import rule_history
doc = rule_history.__doc__ or ""
assert "REPLACED" in doc
@@ -454,9 +454,9 @@ async def test_move_rule_passes_one_destination_and_returns_the_detail():
moved = fake_rule(id=94, topic_id=12, project_id=None)
move = AsyncMock(return_value=moved)
detail = AsyncMock(return_value={"id": 94, "topic_id": 12, "project_id": None})
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.move_rule", move), \
patch("scribe.mcp.tools.rulebooks.rulebooks_svc.rule_detail", detail):
from scribe.mcp.tools.rulebooks import move_rule
with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.move_rule", move), \
patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.rule_detail", detail):
from grimoire.mcp.tools.rulebooks import move_rule
out = await move_rule(rule_id=94, topic_id=12)
assert move.await_args.args[0] == 94
assert move.await_args.kwargs == {"topic_id": 12, "project_id": 0}
@@ -465,8 +465,8 @@ async def test_move_rule_passes_one_destination_and_returns_the_detail():
@pytest.mark.asyncio
async def test_move_rule_on_someone_elses_rule_is_not_found():
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.move_rule", AsyncMock(return_value=None)):
from scribe.mcp.tools.rulebooks import move_rule
with patch("grimoire.mcp.tools.rulebooks.rulebooks_svc.move_rule", AsyncMock(return_value=None)):
from grimoire.mcp.tools.rulebooks import move_rule
with pytest.raises(ValueError, match="not found"):
await move_rule(rule_id=94, project_id=3)
@@ -477,12 +477,12 @@ async def test_move_rule_on_someone_elses_rule_is_not_found():
async def test_rule_outcome_records_an_application():
rule = fake_rule(id=156, title="`dev` is home")
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
"grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=rule),
), patch(
"scribe.mcp.tools.rulebooks.record_rule_outcome", MagicMock()
"grimoire.mcp.tools.rulebooks.record_rule_outcome", MagicMock()
) as rec:
from scribe.mcp.tools.rulebooks import rule_outcome
from grimoire.mcp.tools.rulebooks import rule_outcome
out = await rule_outcome(rule_id=156, outcome="applied")
assert out["outcome"] == "applied" and out["recorded"] is True
assert out["why"] is None
@@ -494,12 +494,12 @@ async def test_rule_outcome_records_an_application():
async def test_rule_outcome_records_a_departure_with_its_reason():
rule = fake_rule(id=156, title="`dev` is home")
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
"grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=rule),
), patch(
"scribe.mcp.tools.rulebooks.record_rule_outcome", MagicMock()
"grimoire.mcp.tools.rulebooks.record_rule_outcome", MagicMock()
) as rec:
from scribe.mcp.tools.rulebooks import rule_outcome
from grimoire.mcp.tools.rulebooks import rule_outcome
out = await rule_outcome(
rule_id=156, outcome="departed", why="the operator asked for main"
)
@@ -514,12 +514,12 @@ async def test_a_departure_without_a_reason_is_refused_at_the_door():
and "it cannot be told from a miss" is the argument that lands."""
rule = fake_rule(id=156, title="`dev` is home")
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
"grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=rule),
), patch(
"scribe.mcp.tools.rulebooks.record_rule_outcome", MagicMock()
"grimoire.mcp.tools.rulebooks.record_rule_outcome", MagicMock()
) as rec:
from scribe.mcp.tools.rulebooks import rule_outcome
from grimoire.mcp.tools.rulebooks import rule_outcome
with pytest.raises(ValueError, match="departure needs its reason"):
await rule_outcome(rule_id=156, outcome="departed", why=" ")
rec.assert_not_called()
@@ -533,12 +533,12 @@ async def test_there_is_no_way_to_report_having_ignored_a_rule():
error rather than a row that would read as measurement."""
rule = fake_rule(id=156, title="`dev` is home")
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
"grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=rule),
), patch(
"scribe.mcp.tools.rulebooks.record_rule_outcome", MagicMock()
"grimoire.mcp.tools.rulebooks.record_rule_outcome", MagicMock()
) as rec:
from scribe.mcp.tools.rulebooks import rule_outcome
from grimoire.mcp.tools.rulebooks import rule_outcome
for bogus in ("ignored", "skipped", "read", ""):
with pytest.raises(ValueError, match="must be 'applied' or 'departed'"):
await rule_outcome(rule_id=156, outcome=bogus)
@@ -551,12 +551,12 @@ async def test_rule_outcome_refuses_a_rule_the_caller_cannot_see():
the existence of someone else's rule, and nothing is recorded against
an id the caller has no claim on."""
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
"grimoire.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=None),
), patch(
"scribe.mcp.tools.rulebooks.record_rule_outcome", MagicMock()
"grimoire.mcp.tools.rulebooks.record_rule_outcome", MagicMock()
) as rec:
from scribe.mcp.tools.rulebooks import rule_outcome
from grimoire.mcp.tools.rulebooks import rule_outcome
with pytest.raises(ValueError, match="rule 999 not found"):
await rule_outcome(rule_id=999, outcome="applied")
rec.assert_not_called()
@@ -566,8 +566,8 @@ def test_rule_outcome_is_registered_and_is_not_read_only():
"""Its whole effect is a write, and `why` is prose the agent authored —
a read-scoped key that can put text in the operator's database is not
read-scoped, whatever table it lands in."""
from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
from scribe.mcp.tools import rulebooks as mod
from grimoire.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
from grimoire.mcp.tools import rulebooks as mod
mcp = FakeMCP()
mod.register(mcp)
assert "rule_outcome" in mcp.names
+24 -24
View File
@@ -5,8 +5,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.mcp._context import _user_id_ctx
from scribe.mcp.tools.search import search
from grimoire.mcp._context import _user_id_ctx
from grimoire.mcp.tools.search import search
from tests.helpers import fake_note
@@ -29,7 +29,7 @@ async def test_fable_search_returns_repackaged_results():
_user_id_ctx.set(7)
fake = fake_note(id=1, title="kafka rebalance", is_task=False, body="HPA details", tags=["ops"])
with patch(
"scribe.mcp.tools.search.semantic_search_notes",
"grimoire.mcp.tools.search.semantic_search_notes",
AsyncMock(return_value=[(0.93, fake)]),
):
out = await search(q="kafka")
@@ -65,7 +65,7 @@ async def test_search_shows_the_passage_that_matched_not_the_opening():
}
return [(0.5, fake)]
with patch("scribe.mcp.tools.search.semantic_search_notes", _search):
with patch("grimoire.mcp.tools.search.semantic_search_notes", _search):
out = await search(q="answer")
r = out["results"][0]
assert r["excerpt"] == "THE ANSWER IS 04775c3."
@@ -83,7 +83,7 @@ async def test_a_result_says_whether_its_excerpt_is_the_match_or_the_opening():
_user_id_ctx.set(7)
fake = fake_note(id=1, title="t", body="short body")
with patch(
"scribe.mcp.tools.search.semantic_search_notes",
"grimoire.mcp.tools.search.semantic_search_notes",
AsyncMock(return_value=[(0.5, fake)]),
):
out = await search(q="x")
@@ -98,7 +98,7 @@ async def test_a_long_excerpt_keeps_both_ends_and_says_how_much_went():
body = "OPENING. " + ("m" * 3000) + " CLOSING."
fake = fake_note(id=1, title="t", body=body)
with patch(
"scribe.mcp.tools.search.semantic_search_notes",
"grimoire.mcp.tools.search.semantic_search_notes",
AsyncMock(return_value=[(0.5, fake)]),
):
out = await search(q="x")
@@ -115,7 +115,7 @@ async def test_a_short_record_arrives_whole_and_unmarked():
_user_id_ctx.set(7)
fake = fake_note(id=1, title="t", body="all of it")
with patch(
"scribe.mcp.tools.search.semantic_search_notes",
"grimoire.mcp.tools.search.semantic_search_notes",
AsyncMock(return_value=[(0.5, fake)]),
):
out = await search(q="x")
@@ -134,7 +134,7 @@ async def test_fable_search_content_type_filters_at_service_layer():
that already asks this way."""
_user_id_ctx.set(7)
mock_search = AsyncMock(return_value=[])
with patch("scribe.mcp.tools.search.semantic_search_notes", mock_search):
with patch("grimoire.mcp.tools.search.semantic_search_notes", mock_search):
await search(q="x", content_type="task")
assert mock_search.call_args.kwargs["is_task"] is True
assert mock_search.call_args.kwargs.get("task_kind") is None
@@ -173,7 +173,7 @@ async def test_each_specific_kind_reaches_the_engine_filter_it_names(
for a snippet must narrow to snippets, not merely to non-tasks."""
_user_id_ctx.set(7)
mock_search = AsyncMock(return_value=[])
with patch("scribe.mcp.tools.search.semantic_search_notes", mock_search):
with patch("grimoire.mcp.tools.search.semantic_search_notes", mock_search):
await search(q="x", content_type=content_type)
kwargs = mock_search.call_args.kwargs
for key, value in expected.items():
@@ -183,7 +183,7 @@ async def test_each_specific_kind_reaches_the_engine_filter_it_names(
def test_the_vocabulary_is_derived_from_the_facet_table_not_recopied():
"""Adding a kind to `_FACETS` is one edit, at this door too. A hand-kept
list here lets the agent's search offer fewer kinds than the web's."""
from scribe.services.knowledge import FACET_TYPES, content_type_filters
from grimoire.services.knowledge import FACET_TYPES, content_type_filters
for facet in FACET_TYPES:
filters = content_type_filters(facet) # raises if a kind is unreachable
@@ -203,7 +203,7 @@ async def test_an_unknown_content_type_is_refused_rather_than_returning_nothing(
instead of falling through to a filter that matches no row."""
_user_id_ctx.set(7)
mock_search = AsyncMock(return_value=[])
with patch("scribe.mcp.tools.search.semantic_search_notes", mock_search):
with patch("grimoire.mcp.tools.search.semantic_search_notes", mock_search):
with pytest.raises(ValueError) as err:
await search(q="x", content_type="snippets") # plural typo
mock_search.assert_not_awaited()
@@ -216,8 +216,8 @@ async def test_an_unknown_content_type_is_refused_rather_than_returning_nothing(
async def test_the_docstring_names_every_kind_the_tool_accepts():
"""The docstring IS the agent-facing contract — a filter an agent
has not been told about is unreachable however well it is wired."""
from scribe.mcp.tools.search import search as search_tool
from scribe.services.knowledge import FACET_TYPES
from grimoire.mcp.tools.search import search as search_tool
from grimoire.services.knowledge import FACET_TYPES
doc = search_tool.__doc__ or ""
for facet in FACET_TYPES:
@@ -242,7 +242,7 @@ async def test_an_explicit_search_reaches_a_lesson_from_any_project():
"""
_user_id_ctx.set(7)
mock_search = AsyncMock(return_value=[])
with patch("scribe.mcp.tools.search.semantic_search_notes", mock_search):
with patch("grimoire.mcp.tools.search.semantic_search_notes", mock_search):
await search(q="x", project_id=3)
assert mock_search.call_args.kwargs["include_global_kinds"] is True
@@ -252,7 +252,7 @@ async def test_an_explicit_search_reaches_a_lesson_from_any_project():
async def test_fable_search_limit_is_clamped():
_user_id_ctx.set(7)
mock_search = AsyncMock(return_value=[])
with patch("scribe.mcp.tools.search.semantic_search_notes", mock_search):
with patch("grimoire.mcp.tools.search.semantic_search_notes", mock_search):
await search(q="x", limit=999)
assert mock_search.call_args.kwargs["limit"] == 50
@@ -273,7 +273,7 @@ async def test_rule_search_scopes_to_the_project_it_is_given(project_id, scope):
is asking the whole rulebook — unlike a hook, which speaks unasked."""
_user_id_ctx.set(7)
found = AsyncMock(return_value=[])
with patch("scribe.mcp.tools.search.semantic_search_rules", found):
with patch("grimoire.mcp.tools.search.semantic_search_rules", found):
await search(q="release tagging", content_type="rule", project_id=project_id)
kwargs = found.await_args.kwargs
assert {k: kwargs[k] for k in scope} == scope
@@ -281,7 +281,7 @@ async def test_rule_search_scopes_to_the_project_it_is_given(project_id, scope):
def test_a_milestone_is_embedded_by_what_it_is_for_then_its_plan():
from scribe.services.embeddings import milestone_document
from grimoire.services.embeddings import milestone_document
assert milestone_document("M3", "metadata providers", "## Goal\nx") == (
"M3 — metadata providers", "metadata providers\n\n## Goal\nx")
@@ -313,9 +313,9 @@ async def test_milestone_search_is_its_own_shape_and_scopes_to_the_project():
}
return [(0.81, ms)]
with patch("scribe.mcp.tools.search.semantic_search_milestones",
with patch("grimoire.mcp.tools.search.semantic_search_milestones",
_milestone_search), \
patch("scribe.services.milestones.get_project_milestone_summary", summary):
patch("grimoire.services.milestones.get_project_milestone_summary", summary):
out = await search(q="book metadata", content_type="milestone", project_id=30)
assert seen["project_id"] == 30
assert out["results"] == [{
@@ -356,7 +356,7 @@ async def test_system_search_is_its_own_shape_and_carries_the_matched_passage():
}
return [(0.81, fake)]
with patch("scribe.mcp.tools.search.semantic_search_systems", _system_search):
with patch("grimoire.mcp.tools.search.semantic_search_systems", _system_search):
out = await search(q="where does retrieval telemetry go?",
content_type="system", project_id=2)
@@ -383,12 +383,12 @@ async def test_system_search_scopes_to_the_project_it_was_given():
answer to "where does this belong here?"."""
_user_id_ctx.set(7)
mock = AsyncMock(return_value=[])
with patch("scribe.mcp.tools.search.semantic_search_systems", mock):
with patch("grimoire.mcp.tools.search.semantic_search_systems", mock):
await search(q="x", content_type="system", project_id=30)
assert mock.call_args.kwargs["project_id"] == 30
mock.reset_mock()
with patch("scribe.mcp.tools.search.semantic_search_systems", mock):
with patch("grimoire.mcp.tools.search.semantic_search_systems", mock):
await search(q="x", content_type="system")
assert mock.call_args.kwargs["project_id"] is None
@@ -400,8 +400,8 @@ async def test_a_system_search_does_not_go_through_the_note_search():
_user_id_ctx.set(7)
notes = AsyncMock(return_value=[])
with (
patch("scribe.mcp.tools.search.semantic_search_notes", notes),
patch("scribe.mcp.tools.search.semantic_search_systems", AsyncMock(return_value=[])),
patch("grimoire.mcp.tools.search.semantic_search_notes", notes),
patch("grimoire.mcp.tools.search.semantic_search_systems", AsyncMock(return_value=[])),
):
await search(q="x", content_type="system")
notes.assert_not_awaited()
+39 -39
View File
@@ -10,7 +10,7 @@ pytestmark = pytest.mark.usefixtures("_bind_user")
@pytest.mark.asyncio
async def test_create_snippet_requires_name_and_code():
from scribe.mcp.tools.snippets import create_snippet
from grimoire.mcp.tools.snippets import create_snippet
with pytest.raises(ValueError):
await create_snippet(name="", code="x")
with pytest.raises(ValueError):
@@ -20,10 +20,10 @@ async def test_create_snippet_requires_name_and_code():
@pytest.mark.asyncio
async def test_create_snippet_records_and_returns_parsed():
created = fake_snippet()
with patch("scribe.services.dedup.find_duplicate_note", AsyncMock(return_value=None)), \
patch("scribe.services.snippets.create_snippet",
with patch("grimoire.services.dedup.find_duplicate_note", AsyncMock(return_value=None)), \
patch("grimoire.services.snippets.create_snippet",
AsyncMock(return_value=created)) as mock_create:
from scribe.mcp.tools.snippets import create_snippet
from grimoire.mcp.tools.snippets import create_snippet
out = await create_snippet(
name="debounce", code="return 1", language="js",
when_to_use="rate-limit a callback",
@@ -37,11 +37,11 @@ async def test_create_snippet_records_and_returns_parsed():
@pytest.mark.asyncio
async def test_create_snippet_dedup_blocks_and_labels_snippet():
dup = MagicMock(id=99)
with patch("scribe.services.dedup.find_duplicate_note", AsyncMock(return_value=dup)), \
patch("scribe.services.dedup.duplicate_response",
with patch("grimoire.services.dedup.find_duplicate_note", AsyncMock(return_value=dup)), \
patch("grimoire.services.dedup.duplicate_response",
MagicMock(return_value={"duplicate": True, "existing_id": 99})) as mock_resp, \
patch("scribe.services.snippets.create_snippet", AsyncMock()) as mock_create:
from scribe.mcp.tools.snippets import create_snippet
patch("grimoire.services.snippets.create_snippet", AsyncMock()) as mock_create:
from grimoire.mcp.tools.snippets import create_snippet
out = await create_snippet(name="debounce", code="return 1")
assert out["duplicate"] is True
mock_create.assert_not_awaited()
@@ -50,16 +50,16 @@ async def test_create_snippet_dedup_blocks_and_labels_snippet():
@pytest.mark.asyncio
async def test_get_snippet_not_found_raises():
with patch("scribe.services.snippets.get_snippet", AsyncMock(return_value=None)):
from scribe.mcp.tools.snippets import get_snippet
with patch("grimoire.services.snippets.get_snippet", AsyncMock(return_value=None)):
from grimoire.mcp.tools.snippets import get_snippet
with pytest.raises(ValueError):
await get_snippet(123)
@pytest.mark.asyncio
async def test_update_snippet_missing_raises():
with patch("scribe.services.snippets.update_snippet", AsyncMock(return_value=None)):
from scribe.mcp.tools.snippets import update_snippet
with patch("grimoire.services.snippets.update_snippet", AsyncMock(return_value=None)):
from grimoire.mcp.tools.snippets import update_snippet
with pytest.raises(ValueError):
await update_snippet(123, name="x")
@@ -69,9 +69,9 @@ async def test_update_snippet_empty_string_clears_a_field():
# An omitted field must stay None ("leave alone"), but an explicit empty
# string has to reach the service as "" so a stale field can be removed.
updated = fake_snippet()
with patch("scribe.services.snippets.update_snippet",
with patch("grimoire.services.snippets.update_snippet",
AsyncMock(return_value=updated)) as mock_update:
from scribe.mcp.tools.snippets import update_snippet
from grimoire.mcp.tools.snippets import update_snippet
await update_snippet(1, signature="")
kwargs = mock_update.await_args.kwargs
assert kwargs["signature"] == "" # cleared
@@ -80,13 +80,13 @@ async def test_update_snippet_empty_string_clears_a_field():
@pytest.mark.asyncio
async def test_update_snippet_project_id_conventions():
from scribe.services import snippets as snippets_svc
from grimoire.services import snippets as snippets_svc
updated = fake_snippet()
cases = {0: snippets_svc.UNSET, -1: None, 5: 5}
for given, expected in cases.items():
with patch("scribe.services.snippets.update_snippet",
with patch("grimoire.services.snippets.update_snippet",
AsyncMock(return_value=updated)) as mock_update:
from scribe.mcp.tools.snippets import update_snippet
from grimoire.mcp.tools.snippets import update_snippet
await update_snippet(1, project_id=given)
assert mock_update.await_args.kwargs["project_id"] is expected
@@ -96,16 +96,16 @@ async def test_create_and_update_pass_locations_through():
locs = [{"repo": "a", "path": "a.py", "symbol": "f"},
{"repo": "b", "path": "b.py", "symbol": "g"}]
created = fake_snippet()
with patch("scribe.services.dedup.find_duplicate_note", AsyncMock(return_value=None)), \
patch("scribe.services.snippets.create_snippet",
with patch("grimoire.services.dedup.find_duplicate_note", AsyncMock(return_value=None)), \
patch("grimoire.services.snippets.create_snippet",
AsyncMock(return_value=created)) as mock_create:
from scribe.mcp.tools.snippets import create_snippet
from grimoire.mcp.tools.snippets import create_snippet
await create_snippet(name="f", code="x", locations=locs)
assert mock_create.await_args.kwargs["locations"] == locs
with patch("scribe.services.snippets.update_snippet",
with patch("grimoire.services.snippets.update_snippet",
AsyncMock(return_value=created)) as mock_update:
from scribe.mcp.tools.snippets import update_snippet
from grimoire.mcp.tools.snippets import update_snippet
await update_snippet(1, locations=locs)
assert mock_update.await_args.kwargs["locations"] == locs
@@ -115,50 +115,50 @@ async def test_update_snippet_read_only_share_says_why():
"""A viewer grant must be refused with the REAL reason. "Not found" would be
a lie about a record the caller can plainly open, and would send an agent
hunting for a missing id instead of recording its own version."""
with patch("scribe.services.snippets.update_snippet",
with patch("grimoire.services.snippets.update_snippet",
AsyncMock(side_effect=PermissionError(
"snippet 1 is shared with you read-only — ask its owner for "
"edit access, or record your own version"))):
from scribe.mcp.tools.snippets import update_snippet
from grimoire.mcp.tools.snippets import update_snippet
with pytest.raises(ValueError, match="read-only"):
await update_snippet(1, name="x")
@pytest.mark.asyncio
async def test_merge_snippets_read_only_target_says_why():
with patch("scribe.services.snippets.merge_snippets",
with patch("grimoire.services.snippets.merge_snippets",
AsyncMock(side_effect=PermissionError(
"snippet 1 is shared with you read-only — you can't merge "
"into a record you can't edit"))):
from scribe.mcp.tools.snippets import merge_snippets
from grimoire.mcp.tools.snippets import merge_snippets
with pytest.raises(ValueError, match="read-only"):
await merge_snippets(target_id=1, source_ids=[2])
@pytest.mark.asyncio
async def test_delete_snippet_retires_or_raises():
from scribe.mcp.tools.snippets import delete_snippet
from grimoire.mcp.tools.snippets import delete_snippet
doomed = MagicMock()
doomed.title = "debounce helper"
# The confirmation names what went — after the delete there is
# nothing left to look the title up from.
with patch("scribe.services.snippets.delete_snippet", AsyncMock(return_value=True)), \
patch("scribe.services.snippets.get_snippet", AsyncMock(return_value=doomed)):
with patch("grimoire.services.snippets.delete_snippet", AsyncMock(return_value=True)), \
patch("grimoire.services.snippets.get_snippet", AsyncMock(return_value=doomed)):
assert await delete_snippet(1) == {
"deleted": True, "id": 1, "title": "debounce helper",
}
with patch("scribe.services.snippets.delete_snippet", AsyncMock(return_value=False)), \
patch("scribe.services.snippets.get_snippet", AsyncMock(return_value=None)):
with patch("grimoire.services.snippets.delete_snippet", AsyncMock(return_value=False)), \
patch("grimoire.services.snippets.get_snippet", AsyncMock(return_value=None)):
with pytest.raises(ValueError):
await delete_snippet(404)
@pytest.mark.asyncio
async def test_list_snippets_defaults_to_every_project():
with patch("scribe.services.snippets.list_snippets",
with patch("grimoire.services.snippets.list_snippets",
AsyncMock(return_value=([], 0))) as mock_list:
from scribe.mcp.tools.snippets import list_snippets
from grimoire.mcp.tools.snippets import list_snippets
await list_snippets(q="debounce")
assert mock_list.await_args.kwargs["project_id"] is None
await list_snippets(q="debounce", project_id=3)
@@ -167,7 +167,7 @@ async def test_list_snippets_defaults_to_every_project():
@pytest.mark.asyncio
async def test_merge_snippets_requires_a_source():
from scribe.mcp.tools.snippets import merge_snippets
from grimoire.mcp.tools.snippets import merge_snippets
with pytest.raises(ValueError):
await merge_snippets(target_id=1, source_ids=[])
with pytest.raises(ValueError):
@@ -177,9 +177,9 @@ async def test_merge_snippets_requires_a_source():
@pytest.mark.asyncio
async def test_merge_snippets_returns_survivor_and_merged_ids():
survivor = fake_snippet()
with patch("scribe.services.snippets.merge_snippets",
with patch("grimoire.services.snippets.merge_snippets",
AsyncMock(return_value=(survivor, [2, 3]))) as mock_merge:
from scribe.mcp.tools.snippets import merge_snippets
from grimoire.mcp.tools.snippets import merge_snippets
out = await merge_snippets(target_id=1, source_ids=[2, 3, 1])
assert out["merged_ids"] == [2, 3]
assert out["snippet"]["name"] == "debounce"
@@ -190,14 +190,14 @@ async def test_merge_snippets_returns_survivor_and_merged_ids():
@pytest.mark.asyncio
async def test_merge_snippets_not_found_raises():
with patch("scribe.services.snippets.merge_snippets", AsyncMock(return_value=None)):
from scribe.mcp.tools.snippets import merge_snippets
with patch("grimoire.services.snippets.merge_snippets", AsyncMock(return_value=None)):
from grimoire.mcp.tools.snippets import merge_snippets
with pytest.raises(ValueError):
await merge_snippets(target_id=1, source_ids=[2])
def test_register_attaches_all_tools():
from scribe.mcp.tools import snippets
from grimoire.mcp.tools import snippets
mcp = FakeMCP()
snippets.register(mcp)
+68 -68
View File
@@ -13,11 +13,11 @@ _NO_MATCH = {"duplicate": None, "canonical": None}
@pytest.mark.asyncio
async def test_create_system_returns_dict():
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc:
with patch("grimoire.mcp.tools.systems.current_user_id", return_value=1), \
patch("grimoire.mcp.tools.systems.systems_svc") as svc:
svc.assess_system_name = AsyncMock(return_value=_NO_MATCH)
svc.create_system = AsyncMock(return_value=fake_system(name="Reader"))
from scribe.mcp.tools.systems import create_system
from grimoire.mcp.tools.systems import create_system
result = await create_system(project_id=5, name="Reader", description="pdf reader")
assert result["name"] == "Reader"
# An unmatched name is a project-specific area: created, no offer, no fuss.
@@ -26,11 +26,11 @@ async def test_create_system_returns_dict():
@pytest.mark.asyncio
async def test_create_system_no_access_raises():
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc:
with patch("grimoire.mcp.tools.systems.current_user_id", return_value=1), \
patch("grimoire.mcp.tools.systems.systems_svc") as svc:
svc.assess_system_name = AsyncMock(return_value=_NO_MATCH)
svc.create_system = AsyncMock(return_value=None)
from scribe.mcp.tools.systems import create_system
from grimoire.mcp.tools.systems import create_system
with pytest.raises(ValueError):
await create_system(project_id=5, name="Reader")
@@ -44,10 +44,10 @@ async def test_create_system_applies_an_exact_area_and_offers_a_similar_one():
judgment call, so it is only OFFERED: applying it silently is how a
cross-project rule ends up surfacing in the wrong project.
"""
from scribe.mcp.tools.systems import create_system
from grimoire.mcp.tools.systems import create_system
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc:
with patch("grimoire.mcp.tools.systems.current_user_id", return_value=1), \
patch("grimoire.mcp.tools.systems.systems_svc") as svc:
svc.assess_system_name = AsyncMock(return_value={
"duplicate": None,
"canonical": {"id": 3, "name": "CI & Release", "basis": "exact"},
@@ -57,8 +57,8 @@ async def test_create_system_applies_an_exact_area_and_offers_a_similar_one():
assert svc.create_system.await_args.kwargs["canonical_id"] == 3
assert "canonical_note" in exact and "canonical_suggestion" not in exact
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc:
with patch("grimoire.mcp.tools.systems.current_user_id", return_value=1), \
patch("grimoire.mcp.tools.systems.systems_svc") as svc:
svc.assess_system_name = AsyncMock(return_value={
"duplicate": None,
"canonical": {"id": 3, "name": "CI & Release", "basis": "overlap", "score": 0.33},
@@ -72,9 +72,9 @@ async def test_create_system_applies_an_exact_area_and_offers_a_similar_one():
@pytest.mark.asyncio
async def test_create_system_duplicate_names_the_existing_one_and_creates_nothing():
from scribe.mcp.tools.systems import create_system
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc:
from grimoire.mcp.tools.systems import create_system
with patch("grimoire.mcp.tools.systems.current_user_id", return_value=1), \
patch("grimoire.mcp.tools.systems.systems_svc") as svc:
svc.assess_system_name = AsyncMock(return_value={
"duplicate": {"id": 4, "name": "Reader"}, "canonical": None,
})
@@ -90,11 +90,11 @@ async def test_get_system_splits_records_by_kind():
issue = fake_task(id=10, task_kind="issue", milestone_id=None)
work = fake_task(id=11, milestone_id=None)
note = fake_note(id=12, status=None, milestone_id=None)
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc:
with patch("grimoire.mcp.tools.systems.current_user_id", return_value=1), \
patch("grimoire.mcp.tools.systems.systems_svc") as svc:
svc.get_system = AsyncMock(return_value=fake_system(id=3))
svc.list_records_for_system = AsyncMock(return_value=[issue, work, note])
from scribe.mcp.tools.systems import get_system
from grimoire.mcp.tools.systems import get_system
result = await get_system(system_id=3)
assert [r["id"] for r in result["issues"]] == [10]
assert [r["id"] for r in result["tasks"]] == [11]
@@ -104,12 +104,12 @@ async def test_get_system_splits_records_by_kind():
@pytest.mark.asyncio
async def test_path_patterns_reach_the_service_from_both_tools():
"""Omitted means unchanged; an empty list is a value — it clears them."""
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc:
with patch("grimoire.mcp.tools.systems.current_user_id", return_value=1), \
patch("grimoire.mcp.tools.systems.systems_svc") as svc:
svc.assess_system_name = AsyncMock(return_value=_NO_MATCH)
svc.create_system = AsyncMock(return_value=fake_system(name="Billing"))
svc.update_system = AsyncMock(return_value=fake_system(name="Billing"))
from scribe.mcp.tools.systems import create_system, update_system
from grimoire.mcp.tools.systems import create_system, update_system
await create_system(project_id=5, name="Billing", path_patterns=["src/billing"])
assert svc.create_system.await_args.kwargs["path_patterns"] == ["src/billing"]
await update_system(system_id=1, name="Billing")
@@ -120,10 +120,10 @@ async def test_path_patterns_reach_the_service_from_both_tools():
@pytest.mark.asyncio
async def test_update_system_not_found_raises():
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc:
with patch("grimoire.mcp.tools.systems.current_user_id", return_value=1), \
patch("grimoire.mcp.tools.systems.systems_svc") as svc:
svc.update_system = AsyncMock(return_value=None)
from scribe.mcp.tools.systems import update_system
from grimoire.mcp.tools.systems import update_system
with pytest.raises(ValueError):
await update_system(system_id=99, name="x")
@@ -131,13 +131,13 @@ async def test_update_system_not_found_raises():
@pytest.mark.asyncio
async def test_create_task_issue_sets_kind_provenance_and_systems():
note = MagicMock(); note.id = 50; note.to_dict.return_value = {"id": 50, "task_kind": "issue"}
with patch("scribe.mcp.tools.tasks.current_user_id", return_value=1), \
patch("scribe.mcp.tools.tasks.notes_svc") as notes_svc, \
patch("scribe.mcp.tools.tasks.systems_svc") as systems_svc:
with patch("grimoire.mcp.tools.tasks.current_user_id", return_value=1), \
patch("grimoire.mcp.tools.tasks.notes_svc") as notes_svc, \
patch("grimoire.mcp.tools.tasks.systems_svc") as systems_svc:
notes_svc.create_note = AsyncMock(return_value=note)
systems_svc.set_record_systems = AsyncMock(return_value=[2, 3])
systems_svc.list_record_systems = AsyncMock(return_value=[])
from scribe.mcp.tools.tasks import create_task
from grimoire.mcp.tools.tasks import create_task
result = await create_task(title="bug", kind="issue", system_ids=[2, 3], arose_from_id=9)
_, kwargs = notes_svc.create_note.call_args
assert kwargs["task_kind"] == "issue"
@@ -150,9 +150,9 @@ async def test_create_task_issue_sets_kind_provenance_and_systems():
async def test_untagged_hint_names_the_projects_systems():
sys_a = MagicMock(); sys_a.id = 1; sys_a.name = "workers"
sys_b = MagicMock(); sys_b.id = 2; sys_b.name = "scrape-pipeline"
with patch("scribe.mcp.tools.systems.systems_svc") as svc:
with patch("grimoire.mcp.tools.systems.systems_svc") as svc:
svc.list_systems = AsyncMock(return_value=[sys_a, sys_b])
from scribe.mcp.tools.systems import untagged_systems_hint
from grimoire.mcp.tools.systems import untagged_systems_hint
hint = await untagged_systems_hint(1, 5)
assert "workers" in hint and "scrape-pipeline" in hint
assert "system_ids" in hint
@@ -161,9 +161,9 @@ async def test_untagged_hint_names_the_projects_systems():
@pytest.mark.asyncio
async def test_untagged_hint_zero_systems_prompts_first_create_and_fails_open():
from scribe.mcp.tools.systems import untagged_systems_hint
with patch("scribe.mcp.tools.systems.systems_svc") as svc, \
patch("scribe.mcp.tools.systems.notes_svc") as notes:
from grimoire.mcp.tools.systems import untagged_systems_hint
with patch("grimoire.mcp.tools.systems.systems_svc") as svc, \
patch("grimoire.mcp.tools.systems.notes_svc") as notes:
# Zero Systems in a YOUNG project (below the bootstrap threshold):
# the mild question stays proportionate — it must prompt the FIRST
# create_system, not go silent.
@@ -171,7 +171,7 @@ async def test_untagged_hint_zero_systems_prompts_first_create_and_fails_open():
notes.list_notes = AsyncMock(return_value=([], 3))
hint = await untagged_systems_hint(1, 5)
assert "no Systems yet" in hint and "create_system" in hint
with patch("scribe.mcp.tools.systems.systems_svc") as svc:
with patch("grimoire.mcp.tools.systems.systems_svc") as svc:
# A hint must never break a create — DB failure degrades to no hint.
svc.list_systems = AsyncMock(side_effect=RuntimeError("db down"))
assert await untagged_systems_hint(1, 5) is None
@@ -184,10 +184,10 @@ async def test_untagged_hint_escalates_in_a_mature_zero_systems_project():
project's OWN evidence — record count and recent titles — and demand a
concrete deliverable, because that is the property separating the nudges
that convert from the prose that doesn't."""
from scribe.mcp.tools.systems import untagged_systems_hint
from grimoire.mcp.tools.systems import untagged_systems_hint
recent = [fake_note(title="Fix scrape retry backoff"), fake_note(title="Worker pool sizing")]
with patch("scribe.mcp.tools.systems.systems_svc") as svc, \
patch("scribe.mcp.tools.systems.notes_svc") as notes:
with patch("grimoire.mcp.tools.systems.systems_svc") as svc, \
patch("grimoire.mcp.tools.systems.notes_svc") as notes:
svc.list_systems = AsyncMock(return_value=[])
notes.list_notes = AsyncMock(return_value=(recent, 282))
# The standard names come from the GLOBAL catalog,
@@ -218,10 +218,10 @@ async def test_bootstrap_ask_still_asks_when_the_catalog_is_unreachable():
catalog read fails (or an install has an empty one), the ask must still
carry the project's evidence and demand the same deliverable — degrading to
a weaker nudge is acceptable, going silent is not."""
from scribe.mcp.tools.systems import untagged_systems_hint
from grimoire.mcp.tools.systems import untagged_systems_hint
recent = [fake_note(title="Fix scrape retry backoff")]
with patch("scribe.mcp.tools.systems.systems_svc") as svc, \
patch("scribe.mcp.tools.systems.notes_svc") as notes:
with patch("grimoire.mcp.tools.systems.systems_svc") as svc, \
patch("grimoire.mcp.tools.systems.notes_svc") as notes:
svc.list_systems = AsyncMock(return_value=[])
notes.list_notes = AsyncMock(return_value=(recent, 282))
svc.standard_systems = AsyncMock(side_effect=RuntimeError("db down"))
@@ -233,12 +233,12 @@ async def test_bootstrap_ask_still_asks_when_the_catalog_is_unreachable():
@pytest.mark.asyncio
async def test_bootstrap_ask_stays_quiet_below_threshold_and_fails_open():
from scribe.mcp.tools import systems as tools
with patch("scribe.mcp.tools.systems.notes_svc") as notes:
from grimoire.mcp.tools import systems as tools
with patch("grimoire.mcp.tools.systems.notes_svc") as notes:
notes.list_notes = AsyncMock(
return_value=([], tools._BOOTSTRAP_MIN_RECORDS - 1))
assert await tools.bootstrap_systems_ask(1, 5) is None
with patch("scribe.mcp.tools.systems.notes_svc") as notes:
with patch("grimoire.mcp.tools.systems.notes_svc") as notes:
# The record count is decoration on a hint on a create — three layers
# deep, nothing there may break the call.
notes.list_notes = AsyncMock(side_effect=RuntimeError("db down"))
@@ -249,10 +249,10 @@ async def test_bootstrap_ask_stays_quiet_below_threshold_and_fails_open():
async def test_populated_vocabulary_never_counts_records():
"""The bootstrap query is zero-state-only: once Systems exist the hint
must not spend a count query per untagged record."""
from scribe.mcp.tools.systems import untagged_systems_hint
from grimoire.mcp.tools.systems import untagged_systems_hint
sys_a = MagicMock(); sys_a.id = 1; sys_a.name = "workers"
with patch("scribe.mcp.tools.systems.systems_svc") as svc, \
patch("scribe.mcp.tools.systems.notes_svc") as notes:
with patch("grimoire.mcp.tools.systems.systems_svc") as svc, \
patch("grimoire.mcp.tools.systems.notes_svc") as notes:
svc.list_systems = AsyncMock(return_value=[sys_a])
notes.list_notes = AsyncMock()
hint = await untagged_systems_hint(1, 5)
@@ -271,15 +271,15 @@ async def test_populated_vocabulary_never_counts_records():
async def test_create_task_untagged_in_project_carries_the_question():
note = MagicMock(); note.id = 60; note.to_dict.return_value = {"id": 60}
sys_a = MagicMock(); sys_a.id = 4; sys_a.name = "plugin-hooks"
with patch("scribe.mcp.tools.tasks.current_user_id", return_value=1), \
patch("scribe.mcp.tools.tasks.notes_svc") as notes_svc, \
patch("scribe.mcp.tools.tasks.dedup_svc") as dedup_svc, \
patch("scribe.mcp.tools.systems.systems_svc") as seam_svc:
with patch("grimoire.mcp.tools.tasks.current_user_id", return_value=1), \
patch("grimoire.mcp.tools.tasks.notes_svc") as notes_svc, \
patch("grimoire.mcp.tools.tasks.dedup_svc") as dedup_svc, \
patch("grimoire.mcp.tools.systems.systems_svc") as seam_svc:
notes_svc.create_note = AsyncMock(return_value=note)
dedup_svc.find_duplicate_note = AsyncMock(return_value=None)
seam_svc.list_record_systems = AsyncMock(return_value=[])
seam_svc.list_systems = AsyncMock(return_value=[sys_a])
from scribe.mcp.tools.tasks import create_task
from grimoire.mcp.tools.tasks import create_task
result = await create_task(title="untagged", project_id=5)
assert "plugin-hooks" in result["systems_hint"]
@@ -291,16 +291,16 @@ async def test_create_task_tagged_shows_systems_and_projectless_gets_neither():
# mutation leak into the second create's response.
note.to_dict.side_effect = lambda: {"id": 61}
tagged_sys = MagicMock(); tagged_sys.to_dict.return_value = {"id": 4, "name": "plugin-hooks"}
with patch("scribe.mcp.tools.tasks.current_user_id", return_value=1), \
patch("scribe.mcp.tools.tasks.notes_svc") as notes_svc, \
patch("scribe.mcp.tools.tasks.dedup_svc") as dedup_svc, \
patch("scribe.mcp.tools.tasks.systems_svc") as systems_svc, \
patch("scribe.mcp.tools.systems.systems_svc") as seam_svc:
with patch("grimoire.mcp.tools.tasks.current_user_id", return_value=1), \
patch("grimoire.mcp.tools.tasks.notes_svc") as notes_svc, \
patch("grimoire.mcp.tools.tasks.dedup_svc") as dedup_svc, \
patch("grimoire.mcp.tools.tasks.systems_svc") as systems_svc, \
patch("grimoire.mcp.tools.systems.systems_svc") as seam_svc:
notes_svc.create_note = AsyncMock(return_value=note)
dedup_svc.find_duplicate_note = AsyncMock(return_value=None)
systems_svc.set_record_systems = AsyncMock()
seam_svc.list_record_systems = AsyncMock(return_value=[tagged_sys])
from scribe.mcp.tools.tasks import create_task
from grimoire.mcp.tools.tasks import create_task
tagged = await create_task(title="tagged", project_id=5, system_ids=[4])
seam_svc.list_record_systems = AsyncMock(return_value=[])
orphan = await create_task(title="orphan", project_id=0)
@@ -318,15 +318,15 @@ async def test_get_task_shows_the_records_systems_on_read():
note.deleted_at = None; note.parent_id = None
note.to_dict.return_value = {"id": 70, "task_kind": "work"}
tagged_sys = MagicMock(); tagged_sys.to_dict.return_value = {"id": 2, "name": "exporter"}
with patch("scribe.mcp.tools.tasks.current_user_id", return_value=1), \
patch("scribe.mcp.tools.tasks.notes_svc") as notes_svc, \
patch("scribe.mcp.tools.tasks.access_svc") as access_svc, \
patch("scribe.mcp.tools.tasks.record_pulled"), \
patch("scribe.mcp.tools.systems.systems_svc") as seam_svc:
with patch("grimoire.mcp.tools.tasks.current_user_id", return_value=1), \
patch("grimoire.mcp.tools.tasks.notes_svc") as notes_svc, \
patch("grimoire.mcp.tools.tasks.access_svc") as access_svc, \
patch("grimoire.mcp.tools.tasks.record_pulled"), \
patch("grimoire.mcp.tools.systems.systems_svc") as seam_svc:
notes_svc.get_note_for_user = AsyncMock(return_value=(note, "owner"))
access_svc.describe_provenance = AsyncMock(return_value={})
seam_svc.list_record_systems = AsyncMock(return_value=[tagged_sys])
from scribe.mcp.tools.tasks import get_task
from grimoire.mcp.tools.tasks import get_task
result = await get_task(task_id=70)
assert result["systems"] == [{"id": 2, "name": "exporter"}]
@@ -336,11 +336,11 @@ async def test_add_task_log_on_untagged_project_task_asks_the_question():
"""Logging work IS working in some area — the strongest moment to ask."""
log = MagicMock(); log.to_dict.return_value = {"id": 9, "task_id": 70}
task = MagicMock(); task.user_id = 1; task.project_id = 5
with patch("scribe.mcp.tools.tasks.current_user_id", return_value=1), \
patch("scribe.mcp.tools.tasks.task_logs_svc") as logs_svc, \
patch("scribe.mcp.tools.tasks.notes_svc") as notes_svc, \
patch("scribe.mcp.tools.systems.systems_svc") as seam_svc, \
patch("scribe.mcp.tools.systems.notes_svc") as seam_notes:
with patch("grimoire.mcp.tools.tasks.current_user_id", return_value=1), \
patch("grimoire.mcp.tools.tasks.task_logs_svc") as logs_svc, \
patch("grimoire.mcp.tools.tasks.notes_svc") as notes_svc, \
patch("grimoire.mcp.tools.systems.systems_svc") as seam_svc, \
patch("grimoire.mcp.tools.systems.notes_svc") as seam_notes:
logs_svc.create_log = AsyncMock(return_value=log)
notes_svc.get_note_for_user = AsyncMock(return_value=(task, "owner"))
seam_svc.list_record_systems = AsyncMock(return_value=[])
@@ -348,6 +348,6 @@ async def test_add_task_log_on_untagged_project_task_asks_the_question():
# Young project: below the bootstrap threshold, so the mild
# question is the expected form here.
seam_notes.list_notes = AsyncMock(return_value=([], 2))
from scribe.mcp.tools.tasks import add_task_log
from grimoire.mcp.tools.tasks import add_task_log
result = await add_task_log(task_id=70, content="progress")
assert "no Systems yet" in result["systems_hint"]
+3 -3
View File
@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.mcp.tools.tags import list_tags, _aggregate_tag_counts
from grimoire.mcp.tools.tags import list_tags, _aggregate_tag_counts
from tests.helpers import make_mock_session
@@ -35,7 +35,7 @@ async def test_fable_list_tags_returns_sorted_by_count_desc():
mock_session = make_mock_session()
mock_session.execute = AsyncMock(return_value=mock_result)
mock_ctx = MagicMock(return_value=mock_session)
with patch("scribe.mcp.tools.tags.async_session", mock_ctx):
with patch("grimoire.mcp.tools.tags.async_session", mock_ctx):
out = await list_tags()
assert out["tags"][0] == {"tag": "a", "count": 3}
assert out["tags"][1] == {"tag": "b", "count": 1}
@@ -49,7 +49,7 @@ async def test_fable_list_tags_clamps_limit():
mock_session = make_mock_session()
mock_session.execute = AsyncMock(return_value=mock_result)
mock_ctx = MagicMock(return_value=mock_session)
with patch("scribe.mcp.tools.tags.async_session", mock_ctx):
with patch("grimoire.mcp.tools.tags.async_session", mock_ctx):
# No exception — just exercises the clamping branch
out = await list_tags(limit=99999)
assert out["total"] == 0

Some files were not shown because too many files have changed in this diff Show More