feat(ledger): write-path stamping — a pulled canon the session then instantiates lands as a hook instance row (#2791, milestone 294 step 5)
CI & Build / Plugin hooks (push) Failing after 2s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Failing after 28s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 39s

The prior-art hook now names the shapes being written (shapes=kind:name —
every definition in the payload, or the one enclosing an Edit found by
walking the file upward) and the server stamps them as instance rows when
the session PULLED a snippet inside PULL_WINDOW that the payload references
by symbol or that the semantic arm scored for this very payload.
classified_by=hook, evidence in reason; never overrides a judgment or a
canonical row, overridable by classify_shapes. Offered-but-unopened stamps
nothing. Pulled-and-already-seen snippets stay in the semantic query as
evidence without re-entering the deduped menu. A brand-new shape gets a
provisional row the next sync confirms or vanishes. Read-scoped keys get
the hint, never the stamp. Plugin 0.1.34.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 20:19:45 -04:00
co-authored by Claude Fable 5
parent 3e21978a9c
commit 475f0857c9
11 changed files with 807 additions and 18 deletions
+241 -2
View File
@@ -778,11 +778,13 @@ def test_route_reads_every_arg_the_hook_sends():
from scribe.routes import plugin as routes
src = inspect.getsource(routes.write_path_prior_art)
for arg in ("path", "code", "repo", "project_id", "exclude_ids", "exclude_sync_ids"):
for arg in ("path", "code", "repo", "project_id", "exclude_ids",
"exclude_sync_ids", "shapes"):
assert f'request.args.get("{arg}"' in src, f"route ignores {arg}"
hook = HOOK.read_text()
for arg in ("path=", "code=", "repo=", "exclude_ids=", "exclude_sync_ids="):
for arg in ("path=", "code=", "repo=", "exclude_ids=", "exclude_sync_ids=",
"shapes="):
assert arg in hook, f"hook never sends {arg}"
@@ -1009,3 +1011,240 @@ def test_local_arm_finds_duplicates_in_every_language_family(
ctx = json.loads(out.stdout)["hookSpecificOutput"]["additionalContext"]
assert "already defined" in ctx
assert "create_snippet" in ctx
# --- #2791: the write-path feed — hook evidence lands as ledger rows ----------
def _ts():
from datetime import datetime, timezone
return datetime.now(timezone.utc)
@pytest.mark.asyncio
async def test_stamping_needs_named_shapes_and_a_recent_pull():
"""Offered-but-ignored stamps nothing: without a PULLED event there is no
evidence, and without the hook naming shapes there is nothing to stamp.
Neither case may even read the pull stream."""
from scribe.services import plugin_context as pc
pulls = AsyncMock(return_value={})
stamp = AsyncMock(return_value=[])
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])), \
patch.object(pc, "record_retrieval", MagicMock()), \
patch.object(pc.shape_ledger_svc, "recent_pulls", pulls), \
patch.object(pc.shape_ledger_svc, "stamp_write_path_instances", stamp):
out = await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE, project_id=4)
assert out["stamped"] == []
pulls.assert_not_awaited() # no shapes → no read
out = await pc.build_write_path_hint(
1, "src/x.py", code=REAL_CODE, project_id=4,
stamp_shapes=[("sym", "debounce")],
)
pulls.assert_awaited_once()
stamp.assert_not_awaited() # shapes, but no pull
assert out["stamped"] == []
@pytest.mark.asyncio
async def test_a_pulled_snippet_already_seen_is_evidence_not_menu():
"""The pulled-then-written flow IS the dedup-excluded flow: the hint offered
#7 earlier (so it sits in exclude_ids), the session pulled it, and now
writes code resembling it. #7 must be scored for this payload — and handed
to the stamp as resemblance — without being re-listed in the menu."""
from scribe.services import plugin_context as pc
search = AsyncMock(return_value=[(0.91, _note(7, "pulled")), (0.80, _note(8, "fresh"))])
stamp = AsyncMock(return_value=[{
"path": "src/x.py", "symbol": "debounce", "kind": "sym",
"snippet_id": 7, "reason": "hook: pulled #7; payload resembles it (0.91)",
}])
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg(top_k=3))), \
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \
patch.object(pc, "semantic_search_notes", search), \
patch.object(pc, "record_retrieval", MagicMock()), \
patch.object(pc, "owner_names_for", AsyncMock(return_value={})), \
patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={7: _ts()})), \
patch.object(pc.shape_ledger_svc, "stamp_write_path_instances", stamp):
out = await pc.build_write_path_hint(
1, "src/x.py", code=REAL_CODE, project_id=4, exclude_ids=[7],
stamp_shapes=[("sym", "debounce")], repo_key="git.example.com/a/b",
)
# The query kept #7 eligible (and widened the budget by one for it)...
kw = search.call_args.kwargs
assert 7 not in kw["exclude_ids"]
assert kw["limit"] == 4
# ...but the menu still honours the session dedup.
assert out["note_ids"] == [8]
assert "#7" not in "\n".join(
line for line in out["context"].splitlines() if "[similar" in line
)
# The stamp saw the pull and the resemblance score for this payload.
skw = stamp.call_args.kwargs
assert skw["pulled"] == {7: skw["pulled"][7]}
assert skw["resembles"] == {7: 0.91}
assert skw["shapes"] == [("sym", "debounce")]
assert skw["repo_key"] == "git.example.com/a/b"
assert out["stamped"][0]["snippet_id"] == 7
# And the session is told what landed, with the way to correct it.
assert "Shape accounting" in out["context"]
assert "`debounce` → instance of #7" in out["context"]
assert "classify_shapes" in out["context"]
@pytest.mark.asyncio
async def test_a_stamp_renders_even_when_the_hint_is_otherwise_silent():
"""After dedup the common case is an empty hint; the stamp must still run
and still be reported — silence about accounting is how hook rows would
become invisible."""
from scribe.services import plugin_context as pc
stamped = [{"path": "web/b.css", "symbol": "btn-primary", "kind": "css",
"snippet_id": 5, "reason": "hook: pulled #5; payload references `btn-primary`"}]
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])), \
patch.object(pc, "record_retrieval", MagicMock()), \
patch.object(pc, "owner_names_for", AsyncMock(return_value={})), \
patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={5: _ts()})), \
patch.object(pc.shape_ledger_svc, "stamp_write_path_instances",
AsyncMock(return_value=stamped)):
out = await pc.build_write_path_hint(
1, "web/b.css", code=".btn-primary { color: red; }" * 4, project_id=4,
stamp_shapes=[("css", "btn-primary")],
)
assert out["note_ids"] == []
assert out["stamped"] == stamped
assert "`.btn-primary` → instance of #5" in out["context"]
@pytest.mark.asyncio
async def test_a_failing_stamp_does_not_sink_the_hint():
from scribe.services import plugin_context as pc
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \
patch.object(pc.snippets_svc, "list_snippets",
AsyncMock(return_value=([_snippet_item(12, "records me")], 1))), \
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])), \
patch.object(pc, "record_retrieval", MagicMock()), \
patch.object(pc, "owner_names_for", AsyncMock(return_value={})), \
patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={12: _ts()})), \
patch.object(pc.shape_ledger_svc, "stamp_write_path_instances",
AsyncMock(side_effect=RuntimeError("ledger down"))):
out = await pc.build_write_path_hint(
1, "src/x.py", code=REAL_CODE, project_id=4, stamp_shapes=[("sym", "f")],
)
assert out["sync_note_ids"] == [12]
assert out["stamped"] == []
def test_route_stamps_only_for_a_caller_allowed_to_write():
"""A read-scoped key gets the hint — every plugin hook works on a read key
— but a GET must never change accounting for it. The route passes the
shapes through only when the key is write-scoped (or it's a session)."""
import inspect
from scribe.routes import plugin as routes
src = inspect.getsource(routes.write_path_prior_art)
assert 'request.args.get("shapes"' in src
assert '== "write"' in src
assert "stamp_shapes=shapes if may_stamp else None" in src
assert "normalize_repo_key(repo)" in src
hook = HOOK.read_text()
assert "&shapes=" in hook
def test_hook_names_the_shapes_being_written():
"""The feed's two inputs: every definition in the payload, or — for an Edit
that changes a body, not a signature — the definition enclosing the edit,
found by walking the target file upward from the edited lines."""
src = HOOK.read_text()
assert "scribe_defs()" in src # one extractor, two consumers
assert ".tool_input.old_string" in src # the Edit's anchor
assert "| tac | scribe_defs | head -1" in src # nearest definition above
# The ledger feed sends NAMES, never bodies, and stays on the one GET.
assert src.count("/api/plugin/prior-art?") == 1
def _run_hook_against_sink(tmp_path, payload):
"""Run the hook with SCRIBE_URL pointed at a throwaway local listener and
return the query the hook sent. Lets the shell be tested end to end —
the extraction, the encoding, the URL — without a Scribe instance."""
import http.server
import threading
import urllib.parse
seen: dict = {}
class _Sink(http.server.BaseHTTPRequestHandler):
def do_GET(self):
seen.update(urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query))
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"context":"","note_ids":[]}')
def log_message(self, *a):
pass
server = http.server.HTTPServer(("127.0.0.1", 0), _Sink)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{server.server_port}")
out = subprocess.run(
["bash", str(HOOK)], input=json.dumps(payload),
capture_output=True, text=True, env=env,
)
assert out.returncode == 0, out.stderr
finally:
server.shutdown()
server.server_close()
return seen
def test_hook_sends_every_definition_in_a_write(tmp_path):
repo = tmp_path / "repo"
repo.mkdir()
env = _hook_runtime_env()
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
seen = _run_hook_against_sink(tmp_path, {
"session_id": "s-feed-w", "cwd": str(repo), "tool_name": "Write",
"tool_input": {
"file_path": str(repo / "new.ts"),
"content": "export async function onDelete(): Promise<void> {\n"
" const ok = await confirmed({ title: 'x' });\n}\n"
".btn-primary {\n color: red;\n}\n",
},
})
assert seen["path"] == ["new.ts"]
assert seen["shapes"] == ["css:btn-primary,sym:onDelete"]
def test_hook_sends_the_enclosing_definition_for_a_body_edit(tmp_path):
"""An Edit to the inside of onTrash names no definition itself; the hook
must walk the file upward from the edited line and send onTrash."""
import shutil
if shutil.which("tac") is None:
pytest.skip("the enclosing-definition walk needs tac")
repo = tmp_path / "repo"
repo.mkdir()
env = _hook_runtime_env()
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
target = repo / "comp.vue"
target.write_text(
"<script setup lang=\"ts\">\n"
"async function onTrash(): Promise<void> {\n"
" const ok = await confirmed({ title: 'Move to the trash?' });\n"
" if (!ok) return;\n"
"}\n"
"const other = () => {\n return 1;\n};\n"
"</script>\n"
)
seen = _run_hook_against_sink(tmp_path, {
"session_id": "s-feed-e", "cwd": str(repo), "tool_name": "Edit",
"tool_input": {
"file_path": str(target),
"old_string": " if (!ok) return;",
"new_string": " if (!ok) return;\n await guarded(() => store.trashNode(id));",
},
})
assert seen["shapes"] == ["sym:onTrash"]