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
+189 -1
View File
@@ -21,13 +21,17 @@ an extracted shape.
"""
from __future__ import annotations
from datetime import datetime, timezone
import logging
import re
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.code_shape import CodeShape
logger = logging.getLogger(__name__)
# Statuses whose meaning requires a snippet target.
_NEEDS_TARGET = ("canonical", "instance", "variant")
@@ -408,3 +412,187 @@ async def snippet_consumers(user_id: int, note_id: int) -> dict:
_consumer_dict(row)
)
return out
# --- write-path stamping (#2791): hook evidence lands as rows ----------------
#
# The write-path hook (plugin/hooks/scribe_prior_art.sh) fires on every
# Write/Edit and already carries the two halves of a consumer-map row: the
# file being written and the definitions in (or enclosing) the payload. What
# it could not say on its own is WHICH canon the session is instantiating.
# The pull stream answers that: a snippet the session opened in full
# (get_snippet) and is now writing code that references or resembles is being
# reused — and a reused canon's call site is an `instance` (note 2786).
#
# The rule, deliberately two-sided so it cannot fire on noise:
# PULLED — a PULLED usage event by this user inside PULL_WINDOW. Offered-
# but-ignored (surfaced, never opened) stamps nothing.
# IN PLAY — the payload references the snippet's symbol by name, or the
# semantic arm scored it above the write-path threshold for this
# very payload. Either is evidence; the pull alone is not.
# Both hold → every shape the hook named at that path, of the snippet's kind,
# becomes instance-of-N with classified_by="hook" and the evidence as reason.
#
# A hook row is EVIDENCE, not judgment: it only ever lands on rows nobody has
# judged (unclassified) or rows an earlier hook stamped, never on a canonical
# row or an agent/audit/import judgment. Re-judge with classify_shapes.
# "The write path actually pulled it": a working session's reach. The
# precision comes from the in-play test above, not from this window.
PULL_WINDOW = timedelta(hours=6)
def snippet_kind(symbol: str, language: str) -> str:
"""The ledger kind a snippet's reference belongs to — "css" when its
symbol is a class selector (or it is a stylesheet with no symbol),
else "sym"."""
sym = (symbol or "").strip()
if sym.startswith("."):
return "css"
if not sym and (language or "").strip().lower() in ("css", "scss", "sass", "less"):
return "css"
return "sym"
def references_symbol(code: str, symbol: str, kind: str) -> bool:
"""Does this payload name the snippet's symbol? Word-bounded so `confirm`
never claims `confirmed`; a CSS class matches as `.btn` or inside a class
attribute (`btn btn-primary`), dashes counting as part of the name."""
sym = _norm_symbol(symbol or "")
if not sym or not code:
return False
if kind == "css":
pattern = rf"(?<![\w-]){re.escape(sym)}(?![\w-])"
else:
pattern = rf"(?<![\w$]){re.escape(sym)}(?![\w$])"
return re.search(pattern, code) is not None
async def recent_pulls(user_id: int, *, window: timedelta = PULL_WINDOW) -> dict[int, datetime]:
"""{note_id: last pulled at} for every note this user opened in full
inside ``window`` — the "actually pulled it" half of the stamping rule.
Reads the usage telemetry table; an unreadable table means no evidence."""
from sqlalchemy import func
from scribe.models.note_usage import PULLED, NoteUsageEvent
since = datetime.now(timezone.utc) - window
try:
async with async_session() as session:
rows = await session.execute(
select(NoteUsageEvent.note_id, func.max(NoteUsageEvent.created_at))
.where(
NoteUsageEvent.user_id == user_id,
NoteUsageEvent.event == PULLED,
NoteUsageEvent.created_at >= since,
)
.group_by(NoteUsageEvent.note_id)
)
return {int(nid): ts for nid, ts in rows.all()}
except Exception:
logger.warning("recent_pulls read failed — no hook stamping this write", exc_info=True)
return {}
async def stamp_write_path_instances(
user_id: int,
project_id: int,
*,
path: str,
shapes: list[tuple[str, str]],
code: str,
pulled: dict[int, datetime],
resembles: dict[int, float] | None = None,
repo_key: str = "",
) -> list[dict]:
"""Land hook evidence as `instance` rows for the shapes being written.
``shapes`` is the hook's (kind, name) list for ``path``; ``pulled`` is
recent_pulls(); ``resembles`` maps snippet ids the semantic arm scored
for this payload to their score. Returns the rows stamped, each
{path, symbol, kind, snippet_id, reason} — empty in the common case.
A shape the ledger has no live row for yet (it is being written right
now) gets a PROVISIONAL row under ``repo_key`` — first/last-seen unset —
so the stamp is not lost to the next sync, which either confirms the
shape (sets its seen marker) or stamps it vanished. No repo key → only
existing rows are stamped.
When more than one pulled snippet is in play for a shape, a by-name
reference beats resemblance and the most recent pull breaks ties: a row
holds one canon (the known model limit logged on #2790).
"""
from scribe.services import access
from scribe.services import snippets as snippets_svc
from scribe.services.snippets import snippet_fields
resembles = resembles or {}
path = (path or "").strip()
wanted = [(k, n.strip()) for k, n in shapes if k in ("css", "sym") and n.strip()]
if not project_id or not path or not wanted or not pulled:
return []
if not await access.can_write_project(user_id, project_id):
return []
# Which pulled canons are in play for this payload, by kind, ranked.
in_play: dict[str, list[tuple[int, datetime, int, str]]] = {}
for sid, pulled_at in pulled.items():
note = await snippets_svc.get_snippet(user_id, sid)
if note is None:
continue
fields = snippet_fields(note)
symbol = fields.get("symbol") or ""
kind = snippet_kind(symbol, fields.get("language") or "")
if references_symbol(code, symbol, kind):
rank, why = 2, f"hook: pulled #{sid}; payload references `{_norm_symbol(symbol)}`"
elif sid in resembles:
rank, why = 1, f"hook: pulled #{sid}; payload resembles it ({resembles[sid]:.2f})"
else:
continue
in_play.setdefault(kind, []).append((rank, pulled_at, sid, why))
if not in_play:
return []
for bucket in in_play.values():
bucket.sort(key=lambda t: (t[0], t[1]), reverse=True)
now = datetime.now(timezone.utc)
stamped: list[dict] = []
async with async_session() as session:
rows = (
await session.execute(
select(CodeShape).where(
CodeShape.project_id == project_id,
CodeShape.path == path,
CodeShape.vanished_at.is_(None),
)
)
).scalars().all()
by_key = {(r.symbol, r.kind): r for r in rows}
for kind, name in wanted:
bucket = in_play.get(kind)
if not bucket:
continue
_rank, _at, sid, why = bucket[0]
row = by_key.get((name, kind))
if row is None:
if not repo_key:
continue
row = CodeShape(
project_id=project_id, repo_key=repo_key,
path=path, symbol=name, kind=kind,
)
session.add(row)
by_key[(name, kind)] = row
elif not (row.status == "unclassified" or row.classified_by == "hook"):
continue # a judgment — or the canon itself — stands
row.status = "instance"
row.snippet_id = sid
row.reason = why
row.classified_by = "hook"
row.classified_at = now
stamped.append({
"path": path, "symbol": name, "kind": kind,
"snippet_id": sid, "reason": why,
})
if stamped:
await session.commit()
return stamped