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
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:
@@ -77,10 +77,17 @@ async def list_shapes(
|
||||
limit/offset: page through big ledgers (limit caps at 500).
|
||||
|
||||
Returns {"shapes": [...], "total": N} — total counts every match, not
|
||||
just this page. Classify what you can judge with classify_shapes; a
|
||||
repeating shape with NO recorded canon is a derive-one-first moment
|
||||
(consolidate onto a reference, create_snippet it, then classify the
|
||||
rest against it), never N loose classifications.
|
||||
just this page. Each row's `classified_by` says who judged: agent /
|
||||
audit / import are judgments; `mechanical` is the canonical stamp the
|
||||
sync applies; `hook` is write-path EVIDENCE (#2791) — the session pulled
|
||||
a snippet and then wrote code referencing/resembling it, so the shape
|
||||
was stamped an instance with the evidence in `reason`. A hook row is
|
||||
overridable by any classify_shapes call; it never overrides yours.
|
||||
|
||||
Classify what you can judge with classify_shapes; a repeating shape
|
||||
with NO recorded canon is a derive-one-first moment (consolidate onto a
|
||||
reference, create_snippet it, then classify the rest against it), never
|
||||
N loose classifications.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
rows, total = await shape_ledger_svc.list_project_shapes(
|
||||
|
||||
@@ -127,6 +127,15 @@ async def write_path_prior_art():
|
||||
surfaced. A separate channel on purpose: a reuse
|
||||
hint shown early must not suppress the record-sync
|
||||
nudge when the recorded file is edited later.
|
||||
shapes (opt) — comma-separated `kind:name` definitions the hook
|
||||
found in (or enclosing) the payload, kind being
|
||||
css|sym. The shape ledger's write-path feed
|
||||
(#2791): when the session recently PULLED a
|
||||
snippet this payload references or resembles,
|
||||
these land as instance rows (classified_by=hook).
|
||||
Honoured only for a caller allowed to write — a
|
||||
read-scoped key still gets the hint, and never
|
||||
changes accounting on a GET.
|
||||
"""
|
||||
path = (request.args.get("path") or "").strip()
|
||||
code = request.args.get("code") or ""
|
||||
@@ -149,14 +158,40 @@ async def write_path_prior_art():
|
||||
int(p) for p in (request.args.get("exclude_sync_ids") or "").split(",")
|
||||
if p.strip().isdigit()
|
||||
]
|
||||
shapes = _parse_shapes(request.args.get("shapes") or "")
|
||||
api_key = getattr(g, "api_key", None)
|
||||
may_stamp = api_key is None or getattr(api_key, "scope", "") == "write"
|
||||
|
||||
result = await plugin_ctx_svc.build_write_path_hint(
|
||||
g.user.id, path, code=code, project_id=project_id,
|
||||
exclude_ids=exclude_ids, exclude_sync_ids=exclude_sync_ids,
|
||||
stamp_shapes=shapes if may_stamp else None,
|
||||
repo_key=repo_bindings_svc.normalize_repo_key(repo) if repo else "",
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# The hook names at most a dozen definitions per write; anything past that is
|
||||
# a generated file, not a shape being instantiated.
|
||||
_SHAPES_CAP = 12
|
||||
|
||||
|
||||
def _parse_shapes(raw: str) -> list[tuple[str, str]]:
|
||||
"""`css:btn-primary,sym:onTrash` → [("css", "btn-primary"), ("sym", "onTrash")].
|
||||
Unknown kinds and empty names are dropped, duplicates collapse, and the
|
||||
list is capped — the hook's own cap, re-applied so the contract holds
|
||||
for any caller."""
|
||||
out: list[tuple[str, str]] = []
|
||||
for part in raw.split(","):
|
||||
kind, _sep, name = part.strip().partition(":")
|
||||
kind, name = kind.strip(), name.strip()
|
||||
if kind in ("css", "sym") and name and (kind, name) not in out:
|
||||
out.append((kind, name))
|
||||
if len(out) >= _SHAPES_CAP:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
@plugin_bp.get("/processes")
|
||||
@login_required
|
||||
async def process_manifest():
|
||||
|
||||
@@ -27,6 +27,7 @@ from scribe.services import knowledge as knowledge_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import projects as projects_svc
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services import shape_ledger as shape_ledger_svc
|
||||
from scribe.services import snippets as snippets_svc
|
||||
from scribe.services.access import label_shared_items, owner_names_for
|
||||
from scribe.services.embeddings import semantic_search_notes
|
||||
@@ -703,6 +704,8 @@ async def build_write_path_hint(
|
||||
project_id: int = 0,
|
||||
exclude_ids: list[int] | None = None,
|
||||
exclude_sync_ids: list[int] | None = None,
|
||||
stamp_shapes: list[tuple[str, str]] | None = None,
|
||||
repo_key: str = "",
|
||||
) -> dict:
|
||||
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
|
||||
|
||||
@@ -749,9 +752,20 @@ async def build_write_path_hint(
|
||||
un-scored surfacing now has its own home: every arm emits note_usage_events,
|
||||
tagged 'write_path_sync' vs 'write_path_place' vs 'write_path_semantic', so
|
||||
each claim's pull-through rate is measurable on its own.
|
||||
|
||||
``stamp_shapes`` turns the same request into the ledger's write-path feed
|
||||
(#2791): the (kind, name) definitions the hook saw in — or enclosing —
|
||||
the payload. When the session has PULLED a snippet recently and this
|
||||
payload references or resembles it, those shapes land as `instance` rows
|
||||
(classified_by=hook, see shape_ledger.stamp_write_path_instances) and
|
||||
the result's ``stamped`` lists them. The route passes it only for a
|
||||
caller allowed to write — a read-scoped key gets the hint, never the
|
||||
stamp. ``repo_key`` (the hook's remote, normalised) homes a provisional
|
||||
row for a shape the ledger has not synced yet.
|
||||
"""
|
||||
cfg = await get_writepath_config(user_id)
|
||||
empty = {"context": "", "note_ids": [], "sync_note_ids": [], "config": cfg}
|
||||
empty = {"context": "", "note_ids": [], "sync_note_ids": [], "config": cfg,
|
||||
"stamped": []}
|
||||
path = (path or "").strip()
|
||||
if not cfg["enabled"] or not path:
|
||||
return empty
|
||||
@@ -800,6 +814,15 @@ async def build_write_path_hint(
|
||||
seen.add(nid)
|
||||
placed.append(("nearby", item))
|
||||
|
||||
# The stamping feed's "actually pulled it" half (#2791). Read once, before
|
||||
# the semantic arm, because the arm's query doubles as the resemblance
|
||||
# test: a pulled snippet this session already saw (so it sits in `seen`)
|
||||
# must still be SCORED for this payload — it just isn't re-listed.
|
||||
pulled: dict = {}
|
||||
if stamp_shapes:
|
||||
pulled = await shape_ledger_svc.recent_pulls(user_id)
|
||||
resembles: dict[int, float] = {}
|
||||
|
||||
# --- arm 2: by meaning ---
|
||||
scored: list[tuple[str, dict]] = []
|
||||
remaining = top_k - len(synced) - len(placed)
|
||||
@@ -821,12 +844,15 @@ async def build_write_path_hint(
|
||||
query = concept_query(query) or query
|
||||
if remaining > 0 and query:
|
||||
t0 = time.perf_counter()
|
||||
# Pulled-and-seen ids stay in the query (as evidence) but never in
|
||||
# the menu — the dedup contract holds, the resemblance still lands.
|
||||
pulled_seen = seen & set(pulled)
|
||||
hits = await semantic_search_notes(
|
||||
user_id, query,
|
||||
limit=remaining,
|
||||
limit=remaining + len(pulled_seen),
|
||||
threshold=cfg["threshold"],
|
||||
project_id=scope_project,
|
||||
exclude_ids=seen,
|
||||
exclude_ids=seen - pulled_seen,
|
||||
# Snippets AND recorded experience (#2246). This arm was
|
||||
# snippets-only, which is auto-inject's mistake inverted: an issue
|
||||
# saying "we tried this and it deadlocked", or a dev-log recording
|
||||
@@ -845,6 +871,11 @@ async def build_write_path_hint(
|
||||
# the browse scope and never surfaces a one-to-one direct share.
|
||||
scope="browse",
|
||||
)
|
||||
resembles = {
|
||||
int(note.id): float(score) for score, note in hits
|
||||
if int(note.id) in pulled
|
||||
}
|
||||
hits = [(s, n) for s, n in hits if int(n.id) not in seen][:remaining]
|
||||
record_retrieval(
|
||||
user_id=user_id, source="write_path", query=query,
|
||||
threshold=cfg["threshold"], limit=remaining,
|
||||
@@ -879,7 +910,20 @@ async def build_write_path_hint(
|
||||
))
|
||||
|
||||
menu = (placed + scored)[:max(0, top_k - len(synced))]
|
||||
if not synced and not menu:
|
||||
|
||||
# The stamp runs whether or not anything is rendered — after dedup, the
|
||||
# common case is a silent hint and a pulled canon being instantiated.
|
||||
stamped: list[dict] = []
|
||||
if stamp_shapes and pulled:
|
||||
try:
|
||||
stamped = await shape_ledger_svc.stamp_write_path_instances(
|
||||
user_id, project_id, path=path, shapes=stamp_shapes,
|
||||
code=code or "", pulled=pulled, resembles=resembles,
|
||||
repo_key=repo_key,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Write-path ledger stamping failed", exc_info=True)
|
||||
if not synced and not menu and not stamped:
|
||||
return empty
|
||||
|
||||
owners = await owner_names_for({
|
||||
@@ -943,6 +987,9 @@ async def build_write_path_hint(
|
||||
note_ids.append(int(item["id"]))
|
||||
lines.append(_prior_art_line(item, marker, owner, foreign_lang))
|
||||
|
||||
if stamped:
|
||||
lines.append(_stamp_line(path, stamped))
|
||||
|
||||
# Split by arm, which is the whole reason this table exists. The place arm
|
||||
# carries no score and so has no home in retrieval_logs; before #2085 a
|
||||
# snippet surfaced BY PLACE left no trace anywhere, making the arm that
|
||||
@@ -964,9 +1011,29 @@ async def build_write_path_hint(
|
||||
"note_ids": note_ids,
|
||||
"sync_note_ids": sync_note_ids,
|
||||
"config": cfg,
|
||||
"stamped": stamped,
|
||||
}
|
||||
|
||||
|
||||
def _stamp_line(path: str, stamped: list[dict]) -> str:
|
||||
"""One line saying what the ledger just recorded, so the session can
|
||||
correct a wrong stamp in the moment rather than an audit finding it."""
|
||||
by_snippet: dict[int, list[str]] = {}
|
||||
for row in stamped:
|
||||
label = f".{row['symbol']}" if row["kind"] == "css" else row["symbol"]
|
||||
by_snippet.setdefault(int(row["snippet_id"]), []).append(f"`{label}`")
|
||||
parts = [
|
||||
f"{', '.join(names)} → instance of #{sid}"
|
||||
for sid, names in by_snippet.items()
|
||||
]
|
||||
return (
|
||||
f"> Shape accounting: recorded at `{path}` — {'; '.join(parts)} "
|
||||
"(classified_by=hook: you pulled that snippet this session and this "
|
||||
"code references/resembles it). Not an instance? `classify_shapes` "
|
||||
"overrides a hook stamp."
|
||||
)
|
||||
|
||||
|
||||
async def _topic_titles(topic_ids: set[int]) -> dict[int, str]:
|
||||
"""Map topic_id -> title for the given ids (live topics only)."""
|
||||
if not topic_ids:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user