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
@@ -200,7 +200,7 @@ _WRITE_TOOLS = frozenset({
"merge_snippets", "unmerge_snippet",
"create_process", "update_process", "delete_process",
"classify_shapes", "classify_shapes_by_rule", "confirm_shape_proposals",
"refresh_pattern_coverage", "move_shapes",
"refresh_pattern_coverage", "move_path",
# design systems
"create_design_system", "update_design_system", "delete_design_system",
"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.services import coverage as coverage_svc
from grimoire.services import shape_ledger as shape_ledger_svc
from grimoire.services import snippets as snippets_svc
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
of #N and later a variant of #M shows both), `vanished`, `reappeared`,
`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.
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:
"""Carry the judgments of a file or directory that moved in the tree.
async def move_path(project_id: int, from_path: str, to_path: str) -> dict:
"""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
shapes again under src/new, unclassified, with every judgment left
behind. Run this once the move is committed, before that refresh:
each row under `from_path` is re-keyed to the same place under
`to_path`, in every repo bound to the project, keeping its judgment,
uses edges and history, and gaining a `moved` event (shape_history).
behind. Each snippet recorded under src/old would also read `missing`.
Run this once the move is committed, before that refresh.
- Every ledger row under `from_path` is re-keyed to the same place under
`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
refresh ran first, or the sync stamped it `scoped`); a judged one stands,
and the old row is left behind and listed in `kept`. The refresh that
follows compares bodies as always: a judged shape whose body changed in
the move comes back flagged `recheck`.
A ledger row already at the new path is replaced when nobody judged it
(the refresh ran first, or the sync stamped it `scoped`). A judged one
stands; the old row stays where it is and is listed in `kept`. The
refresh that follows compares bodies as always, so a judged shape whose
body changed in the move comes back flagged `recheck`.
Args:
project_id: The project whose ledger moves. Requires write access.
from_path: The file or directory the shapes were found at.
project_id: The project whose record moves. Requires write access.
from_path: The file or directory as it was recorded.
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()
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:
for fn in (
classify_shapes, classify_shapes_by_rule, list_shapes,
refresh_pattern_coverage, confirm_shape_proposals, shape_history,
stamps_to_review, move_shapes,
stamps_to_review, move_path,
):
mcp.tool(name=fn.__name__)(fn)