feat(6243): move_path carries a moved path's snippets with its shapes
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 2m12s
CI & Build / Build & push image (push) Successful in 31s

Snippet locations are keyed by path too, so a snippet recorded under a
moved directory reads `missing` at its next check. The tool is now
move_path(project_id, from_path, to_path). It moves the ledger rows as
before, and re-paths the project's snippet locations under from_path
through update_snippet, so each body and its indexed mirror stay one
record. A snippet the caller can only read is listed in
snippets_read_only. The tool refuses only when nothing at all is recorded
under from_path. The path test is the one snippets already uses
(_path_touches), and the plugin version is minted for the skill edit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 15:07:32 -04:00
co-authored by Claude Opus 5.5
parent 47b52197aa
commit 979efe6662
8 changed files with 114 additions and 36 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "grimoire", "name": "grimoire",
"description": "Grimoire for Claude Code: connects the grimoire MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Grimoire skills (using-grimoire, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting, family-canon), and syncs your saved Grimoire Processes as skills (/grimoire:sync).", "description": "Grimoire for Claude Code: connects the grimoire MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Grimoire skills (using-grimoire, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting, family-canon), and syncs your saved Grimoire Processes as skills (/grimoire:sync).",
"version": "2026.10.21.2344", "version": "2026.10.22.1907",
"author": { "author": {
"name": "Bryan Van Deusen" "name": "Bryan Van Deusen"
}, },
+3 -2
View File
@@ -42,8 +42,9 @@ row carries a status:
detail cannot be sorted, queried, or diffed. detail cannot be sorted, queried, or diffed.
Rows are keyed by repo and path, so a move in the tree is a move in the Rows are keyed by repo and path, so a move in the tree is a move in the
ledger. When a file or directory moves, run `move_shapes(project_id, ledger. When a file or directory moves, run `move_path(project_id,
from_path, to_path)` before the next refresh. For a repo renamed on its from_path, to_path)` before the next refresh; it carries the snippets
recorded there too. For a repo renamed on its
forge, run `bind_repo(new_url, project_id, moved_from=old_url)`. Otherwise forge, run `bind_repo(new_url, project_id, moved_from=old_url)`. Otherwise
the refresh marks every judged row vanished and lists the same shapes again, the refresh marks every judged row vanished and lists the same shapes again,
unjudged. unjudged.
+1 -1
View File
@@ -200,7 +200,7 @@ _WRITE_TOOLS = frozenset({
"merge_snippets", "unmerge_snippet", "merge_snippets", "unmerge_snippet",
"create_process", "update_process", "delete_process", "create_process", "update_process", "delete_process",
"classify_shapes", "classify_shapes_by_rule", "confirm_shape_proposals", "classify_shapes", "classify_shapes_by_rule", "confirm_shape_proposals",
"refresh_pattern_coverage", "move_shapes", "refresh_pattern_coverage", "move_path",
# design systems # design systems
"create_design_system", "update_design_system", "delete_design_system", "create_design_system", "update_design_system", "delete_design_system",
"create_design_token", "update_design_token", "delete_design_token", "create_design_token", "update_design_token", "delete_design_token",
+31 -18
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
from grimoire.mcp._context import current_user_id from grimoire.mcp._context import current_user_id
from grimoire.services import coverage as coverage_svc from grimoire.services import coverage as coverage_svc
from grimoire.services import shape_ledger as shape_ledger_svc from grimoire.services import shape_ledger as shape_ledger_svc
from grimoire.services import snippets as snippets_svc
async def classify_shapes( async def classify_shapes(
@@ -263,7 +264,7 @@ async def shape_history(
snippet_id, who, why — one per judgment, so a shape that was an instance snippet_id, who, why — one per judgment, so a shape that was an instance
of #N and later a variant of #M shows both), `vanished`, `reappeared`, of #N and later a variant of #M shows both), `vanished`, `reappeared`,
`drifted` (the body moved under a judgment; see list_shapes flag= `drifted` (the body moved under a judgment; see list_shapes flag=
"recheck"), `moved` (move_shapes carried the row; the event's `path` "recheck"), `moved` (move_path carried the row; the event's `path`
is the one it left). Each event carries the commit the tree was read at. is the one it left). Each event carries the commit the tree was read at.
Read it as a timeline: "instance of #N from <first classified at>, Read it as a timeline: "instance of #N from <first classified at>,
@@ -399,38 +400,50 @@ async def refresh_pattern_coverage(project_id: int) -> dict:
} }
async def move_shapes(project_id: int, from_path: str, to_path: str) -> dict: async def move_path(project_id: int, from_path: str, to_path: str) -> dict:
"""Carry the judgments of a file or directory that moved in the tree. """Carry what the record knows about a file or directory that moved in
the tree: its shape judgments and its snippets' recorded locations.
Rows are keyed by path. After `git mv src/old src/new`, the next coverage Both are keyed by path. After `git mv src/old src/new`, the next coverage
refresh would mark every row under src/old vanished and list the same refresh would mark every row under src/old vanished and list the same
shapes again under src/new, unclassified, with every judgment left shapes again under src/new, unclassified, with every judgment left
behind. Run this once the move is committed, before that refresh: behind. Each snippet recorded under src/old would also read `missing`.
each row under `from_path` is re-keyed to the same place under Run this once the move is committed, before that refresh.
`to_path`, in every repo bound to the project, keeping its judgment, - Every ledger row under `from_path` is re-keyed to the same place under
uses edges and history, and gaining a `moved` event (shape_history). `to_path`, in every repo bound to the project. It keeps its judgment,
uses edges and history, and gains a `moved` event (shape_history).
- Every snippet of the project recorded under `from_path` has its
location re-pathed.
A row already at the new path is replaced when nobody judged it (the A ledger row already at the new path is replaced when nobody judged it
refresh ran first, or the sync stamped it `scoped`); a judged one stands, (the refresh ran first, or the sync stamped it `scoped`). A judged one
and the old row is left behind and listed in `kept`. The refresh that stands; the old row stays where it is and is listed in `kept`. The
follows compares bodies as always: a judged shape whose body changed in refresh that follows compares bodies as always, so a judged shape whose
the move comes back flagged `recheck`. body changed in the move comes back flagged `recheck`.
Args: Args:
project_id: The project whose ledger moves. Requires write access. project_id: The project whose record moves. Requires write access.
from_path: The file or directory the shapes were found at. from_path: The file or directory as it was recorded.
to_path: Where it lives now. Neither path may contain the other. to_path: Where it lives now. Neither path may contain the other.
Returns {"moved": N, "replaced": N, "kept": [{path, symbol, kind}]}. Returns {"moved": N, "replaced": N, "kept": [{path, symbol, kind}],
"snippets_moved": [ids], "snippets_read_only": [ids]}. A snippet shared
with you read-only stays where it is, and its owner moves it.
""" """
uid = current_user_id() uid = current_user_id()
return await shape_ledger_svc.move_shapes(uid, project_id, from_path, to_path) out = await shape_ledger_svc.move_shapes(uid, project_id, from_path, to_path)
snippets = await snippets_svc.move_locations(uid, project_id, from_path, to_path)
out["snippets_moved"] = snippets["moved"]
out["snippets_read_only"] = snippets["read_only"]
if not (out["moved"] or out["kept"] or snippets["moved"] or snippets["read_only"]):
raise ValueError(f"nothing in project {project_id} is recorded under `{from_path}`")
return out
def register(mcp) -> None: def register(mcp) -> None:
for fn in ( for fn in (
classify_shapes, classify_shapes_by_rule, list_shapes, classify_shapes, classify_shapes_by_rule, list_shapes,
refresh_pattern_coverage, confirm_shape_proposals, shape_history, refresh_pattern_coverage, confirm_shape_proposals, shape_history,
stamps_to_review, move_shapes, stamps_to_review, move_path,
): ):
mcp.tool(name=fn.__name__)(fn) mcp.tool(name=fn.__name__)(fn)
+3 -8
View File
@@ -170,10 +170,6 @@ async def sync_repo_shapes(
await session.commit() await session.commit()
def _under(path: str, prefix: str) -> bool:
return path == prefix or path.startswith(prefix + "/")
async def move_shapes( async def move_shapes(
user_id: int, project_id: int, from_path: str, to_path: str, user_id: int, project_id: int, from_path: str, to_path: str,
) -> dict: ) -> dict:
@@ -193,12 +189,13 @@ async def move_shapes(
from sqlalchemy import or_ from sqlalchemy import or_
from grimoire.services import access from grimoire.services import access
from grimoire.services.snippets import _path_touches
old = (from_path or "").strip().strip("/") old = (from_path or "").strip().strip("/")
new = (to_path or "").strip().strip("/") new = (to_path or "").strip().strip("/")
if not old or not new: if not old or not new:
raise ValueError("from_path and to_path are both required") raise ValueError("from_path and to_path are both required")
if _under(old, new) or _under(new, old): if _path_touches(old, new) or _path_touches(new, old):
raise ValueError(f"`{old}` and `{new}` overlap; move to a path outside the source") raise ValueError(f"`{old}` and `{new}` overlap; move to a path outside the source")
if not await access.can_write_project(user_id, project_id): if not await access.can_write_project(user_id, project_id):
raise ValueError(f"project {project_id} not found or no write access") raise ValueError(f"project {project_id} not found or no write access")
@@ -213,10 +210,8 @@ async def move_shapes(
# LIKE reads `_` as a wildcard; the prefix test is the real filter. # LIKE reads `_` as a wildcard; the prefix test is the real filter.
sources = [ sources = [
r for r in (await session.execute(beneath(old))).scalars().all() r for r in (await session.execute(beneath(old))).scalars().all()
if _under(r.path, old) if _path_touches(old, r.path)
] ]
if not sources:
raise ValueError(f"project {project_id} has no shapes under `{old}`")
targets = { targets = {
(r.repo_key, r.path, r.symbol, r.kind): r (r.repo_key, r.path, r.symbol, r.kind): r
for r in (await session.execute(beneath(new))).scalars().all() for r in (await session.execute(beneath(new))).scalars().all()
+46
View File
@@ -1280,6 +1280,52 @@ def _path_touches(recorded: str, changed: str) -> bool:
return changed == recorded or changed.startswith(recorded + "/") return changed == recorded or changed.startswith(recorded + "/")
async def move_locations(
user_id: int, project_id: int, from_path: str, to_path: str,
) -> dict:
"""Re-path the recorded locations under ``from_path`` (a file or a
directory) to the same place under ``to_path``, on every live snippet of
the project the caller can edit.
The twin of the shape ledger's move: a snippet recorded at a path that
moved reads `missing` at its next check, and its canonical stamp no longer
covers the shape that moved. Each snippet is rewritten through
update_snippet, so its body and indexed fields stay one record. A
snippet the caller can only read is left as it is and listed in
``read_only``.
"""
old = (from_path or "").strip().strip("/")
new = (to_path or "").strip().strip("/")
async with async_session() as session:
notes = (
await session.execute(
select(Note).where(
Note.project_id == project_id,
Note.note_type == SNIPPET_NOTE_TYPE,
Note.deleted_at.is_(None),
)
)
).scalars().all()
moved: list[int] = []
read_only: list[int] = []
for note in notes:
locations = snippet_fields(note).get("locations") or []
if not any(_path_touches(old, loc.get("path") or "") for loc in locations):
continue
repathed = [
{**loc, "path": new + loc["path"].strip("/")[len(old):]}
if _path_touches(old, loc.get("path") or "") else loc
for loc in locations
]
try:
await update_snippet(user_id, int(note.id), locations=repathed)
except PermissionError:
read_only.append(int(note.id))
continue
moved.append(int(note.id))
return {"moved": moved, "read_only": read_only}
async def invalidate_for_push( async def invalidate_for_push(
repo_key: str, repo_key: str,
changed: list[str], changed: list[str],
+26 -3
View File
@@ -115,12 +115,35 @@ async def test_a_judgment_already_at_the_new_path_stands(seeded):
@pytest.mark.integration @pytest.mark.integration
async def test_overlapping_empty_and_unwritable_moves_are_refused(seeded): async def test_overlapping_and_unwritable_moves_are_refused(seeded):
owner, other, pid = seeded["owner"], seeded["other"], seeded["pid"] owner, other, pid = seeded["owner"], seeded["other"], seeded["pid"]
with pytest.raises(ValueError, match="overlap"): with pytest.raises(ValueError, match="overlap"):
await move_shapes(owner, pid, "src/old", "src/old/inner") await move_shapes(owner, pid, "src/old", "src/old/inner")
with pytest.raises(ValueError, match="no shapes under"): assert await move_shapes(owner, pid, "src/gone", "src/new") == {
await move_shapes(owner, pid, "src/gone", "src/new") "moved": 0, "replaced": 0, "kept": [],
}
with pytest.raises(ValueError, match="no write access"): with pytest.raises(ValueError, match="no write access"):
await move_shapes(other, pid, "src/old", "src/new") await move_shapes(other, pid, "src/old", "src/new")
assert ("src/old/app.py", "make_app") in await _ledger(pid) assert ("src/old/app.py", "make_app") in await _ledger(pid)
@pytest.mark.integration
async def test_the_snippets_recorded_there_move_with_it(seeded):
from grimoire.services import snippets as snippets_svc
owner, pid = seeded["owner"], seeded["pid"]
note = await snippets_svc.create_snippet(
owner, name="move_helper", code="def helper():\n pass\n",
language="python", project_id=pid, locations=[
{"repo": "Widget", "path": "src/old/sub/util.py", "symbol": "helper"},
{"repo": "Widget", "path": "src/older/keep.py", "symbol": "untouched"},
],
)
result = await snippets_svc.move_locations(owner, pid, "src/old", "src/new")
assert result == {"moved": [int(note.id)], "read_only": []}
moved = await snippets_svc.get_snippet(owner, int(note.id))
assert [loc["path"] for loc in snippets_svc.snippet_fields(moved)["locations"]] == [
"src/new/sub/util.py", "src/older/keep.py",
]
+3 -3
View File
@@ -383,12 +383,12 @@ def test_bind_repo_tool_can_move_a_renamed_repo():
assert "moved_from" in tool.parameters.get("properties", {}) assert "moved_from" in tool.parameters.get("properties", {})
def test_move_shapes_is_mounted_as_a_write_tool(): def test_move_path_is_mounted_as_a_write_tool():
"""A directory moved in the tree carries its judgments to the new path.""" """A directory moved in the tree carries its judgments to the new path."""
from grimoire.mcp.server import _WRITE_TOOLS, build_mcp_server from grimoire.mcp.server import _WRITE_TOOLS, build_mcp_server
tool = build_mcp_server()._tool_manager.get_tool("move_shapes") tool = build_mcp_server()._tool_manager.get_tool("move_path")
assert {"from_path", "to_path"} <= set(tool.parameters.get("properties", {})) assert {"from_path", "to_path"} <= set(tool.parameters.get("properties", {}))
assert "move_shapes" in _WRITE_TOOLS assert "move_path" in _WRITE_TOOLS
def test_coverage_routes_are_registered(): def test_coverage_routes_are_registered():