From 475f0857c93d9b09b3db0fa1c5550f48ef311aa8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 20 Aug 2026 20:19:45 -0400 Subject: [PATCH] =?UTF-8?q?feat(ledger):=20write-path=20stamping=20?= =?UTF-8?q?=E2=80=94=20a=20pulled=20canon=20the=20session=20then=20instant?= =?UTF-8?q?iates=20lands=20as=20a=20hook=20instance=20row=20(#2791,=20mile?= =?UTF-8?q?stone=20294=20step=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- plugin/.claude-plugin/plugin.json | 2 +- plugin/hooks/scribe_prior_art.sh | 62 +++++- plugin/skills/reusing-code/SKILL.md | 3 + plugin/skills/shape-accounting/SKILL.md | 17 ++ src/scribe/mcp/tools/shapes.py | 15 +- src/scribe/routes/plugin.py | 35 ++++ src/scribe/services/plugin_context.py | 75 ++++++- src/scribe/services/shape_ledger.py | 190 +++++++++++++++++- tests/test_integration_shape_classify.py | 130 ++++++++++++ tests/test_shape_ledger.py | 53 +++++ tests/test_write_path_trigger.py | 243 ++++++++++++++++++++++- 11 files changed, 807 insertions(+), 18 deletions(-) diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index f699407..5fec290 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "scribe", "description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.", - "version": "0.1.33", + "version": "0.1.34", "author": { "name": "Bryan Van Deusen" }, "mcpServers": { "scribe": { diff --git a/plugin/hooks/scribe_prior_art.sh b/plugin/hooks/scribe_prior_art.sh index 724580c..bc8276c 100755 --- a/plugin/hooks/scribe_prior_art.sh +++ b/plugin/hooks/scribe_prior_art.sh @@ -14,6 +14,12 @@ # on an instance with no forge connection (decision #2707). Everything else is # the REUSE menu. The two dedup separately (see the state files below). # +# It is also the shape ledger's write-path feed (#2791): it names the +# definitions being written (`shapes=`), and the server — only when the +# session has PULLED a snippet this code references or resembles — records +# them as instance rows, classified_by=hook. Evidence, not judgment; the +# context line says what landed so a wrong stamp is corrected in the moment. +# # NEVER BLOCKS. It returns `additionalContext` with no `permissionDecision`, so # the write proceeds untouched and Claude sees the note beside the tool result. # Any failure — unconfigured, unreachable, malformed — exits 0 in silence. A @@ -96,10 +102,14 @@ fi # `ReturnType name(...)`) needs a real parser, and `impl` blocks are excluded # because several per type is normal Rust, not duplication. # --------------------------------------------------------------------------- -local_lines="" -if [ -n "$repo_root" ] && [ -n "$code" ]; then - # kindname for each thing this payload DEFINES. - names=$(printf '%s' "$code" | awk ' +# kindname for each thing a piece of code DEFINES, in source order. One +# program, two consumers: the local duplicate arm (every definition in the +# payload) and the ledger feed (#2791, below: the definitions being written, +# or the one enclosing an Edit). Rule-for-rule mirrored by the server's +# services/coverage.py extract_shapes — ledger rows are keyed by what THAT +# sees, so the two must agree on what counts as a definition. +scribe_defs() { + awk ' { # CSS class definition: .name { or .name, if (match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/)) { @@ -132,8 +142,16 @@ if [ -n "$repo_root" ] && [ -n "$code" ]; then if (t != "") print "sym\t" t; next } } - ' 2>/dev/null | sort -u | head -12) || names="" + ' 2>/dev/null +} +names="" +if [ -n "$code" ]; then + names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12) || names="" +fi + +local_lines="" +if [ -n "$repo_root" ] && [ -n "$names" ]; then while IFS=$'\t' read -r kind name; do [ -n "${name:-}" ] || continue case "$kind" in @@ -156,6 +174,38 @@ if [ -n "$local_lines" ]; then local_context="> Already defined elsewhere in this repo — check before adding another copy (\`git grep\` shown; this is a nudge, not a gate):"$'\n'"${local_lines}" fi +# --------------------------------------------------------------------------- +# THE LEDGER FEED (#2791). The server keeps a shape ledger — every definition +# in the bound repo, classified against recorded canon — and this hook is the +# one place that sees a shape AT THE MOMENT IT IS WRITTEN. So it names the +# shapes in play: every definition in the payload, or — for an Edit that +# changes the inside of a function rather than its signature — the definition +# enclosing the edit, found by walking the target file upward from the edited +# lines. The server decides whether evidence exists (the session pulled a +# snippet this code references or resembles) and stamps instance rows; with +# no pulled canon in play, nothing is recorded. Titles only still — this sends +# names, not bodies. +# --------------------------------------------------------------------------- +shapes="$names" +if [ -z "$shapes" ] && [ -f "$file_path" ] && command -v tac >/dev/null 2>&1; then + old_first=$(printf '%s' "$event" \ + | jq -r '.tool_input.old_string // .tool_input.old_str // empty' 2>/dev/null \ + | grep -m1 -v '^[[:space:]]*$') || old_first="" + if [ -n "$old_first" ]; then + ln=$(grep -nF -m1 -- "$old_first" "$file_path" 2>/dev/null | cut -d: -f1) || ln="" + if [ -n "$ln" ]; then + shapes=$(head -n "$ln" "$file_path" | tac | scribe_defs | head -1) || shapes="" + fi + fi +fi +shapes_q="" +if [ -n "$shapes" ]; then + enc=$(printf '%s\n' "$shapes" \ + | awk -F'\t' 'NF>=2 {printf "%s%s:%s", (n++?",":""), $1, $2}' \ + | jq -sRr '@uri' 2>/dev/null) || enc="" + [ -n "$enc" ] && shapes_q="&shapes=${enc}" +fi + url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}} token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}} # Guard against an unexpanded ${...} placeholder arriving as a literal. @@ -230,7 +280,7 @@ fi # finding that needed no instance to produce. body=$(curl -fsS --max-time 5 \ -H "Authorization: Bearer ${token}" \ - "${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}" 2>/dev/null) || body="" + "${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${shapes_q}" 2>/dev/null) || body="" context="" if [ -n "$body" ]; then diff --git a/plugin/skills/reusing-code/SKILL.md b/plugin/skills/reusing-code/SKILL.md index bfc9921..2a00166 100644 --- a/plugin/skills/reusing-code/SKILL.md +++ b/plugin/skills/reusing-code/SKILL.md @@ -34,6 +34,9 @@ through recall/auto-inject; this skill is the active reflex around that. to ask both at once. - If a snippet fits, pull it in full with `get_snippet(id)` and reuse it — its `location` points at the reference implementation. Adapt, don't re-derive. + The pull also does the accounting: the code you then write that references + or resembles it is stamped an `instance` of that canon in the shape ledger + (classified_by=hook) — reuse from memory leaves no row. - If auto-inject already surfaced a snippet title that looks relevant, that's your cue to `get_snippet` it rather than start from scratch. - **Prior art offered beside a write is not noise — read it.** When Scribe notes diff --git a/plugin/skills/shape-accounting/SKILL.md b/plugin/skills/shape-accounting/SKILL.md index 9c4b2e5..dc4e57a 100644 --- a/plugin/skills/shape-accounting/SKILL.md +++ b/plugin/skills/shape-accounting/SKILL.md @@ -34,6 +34,23 @@ row carries a status: nothing. Rows, never prose — a consumer list in a note or verification detail cannot be sorted, queried, or diffed. +## Rows that arrive on their own + +Two feeds keep the ledger current between your batches, so most shapes never +need a hand judgment: + +- **The sync** stamps a snippet's own reference location `canonical` + (`classified_by: mechanical`). +- **The write path** stamps instances as you work: when you `get_snippet` a + canon and then Write/Edit code that references or resembles it, the + definitions being written land as `instance` rows (`classified_by: hook`, + the evidence in `reason`), and the prior-art hook tells you what landed + ("Shape accounting: recorded at … → instance of #N"). Offered-but-unopened + snippets stamp nothing — so *pull the canon you are instantiating*; that + pull is what turns your reuse into accounting. A hook row is evidence, not + judgment: it never overrides a classification you made, and a + `classify_shapes` call overrides it. + ## The derive-first rule N same-shaped occurrences matching **no** recorded canon is never N loose diff --git a/src/scribe/mcp/tools/shapes.py b/src/scribe/mcp/tools/shapes.py index 19ed37c..0f3485e 100644 --- a/src/scribe/mcp/tools/shapes.py +++ b/src/scribe/mcp/tools/shapes.py @@ -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( diff --git a/src/scribe/routes/plugin.py b/src/scribe/routes/plugin.py index 1ecdc13..c66d2e0 100644 --- a/src/scribe/routes/plugin.py +++ b/src/scribe/routes/plugin.py @@ -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(): diff --git a/src/scribe/services/plugin_context.py b/src/scribe/services/plugin_context.py index 0cb08e9..6b7a29a 100644 --- a/src/scribe/services/plugin_context.py +++ b/src/scribe/services/plugin_context.py @@ -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: diff --git a/src/scribe/services/shape_ledger.py b/src/scribe/services/shape_ledger.py index d1fcbea..4830d44 100644 --- a/src/scribe/services/shape_ledger.py +++ b/src/scribe/services/shape_ledger.py @@ -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"(? 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 diff --git a/tests/test_integration_shape_classify.py b/tests/test_integration_shape_classify.py index b8d84a8..b8f348d 100644 --- a/tests/test_integration_shape_classify.py +++ b/tests/test_integration_shape_classify.py @@ -219,3 +219,133 @@ async def test_sync_refiles_rows_whose_snippet_was_purged(seeded): ))).scalar_one() assert row.status == "unclassified" assert row.snippet_id is None + + +# --- #2791: the write-path feed lands hook evidence as rows ------------------- + + +@pytest.mark.integration +async def test_write_path_stamp_is_evidence_that_yields_to_judgment(seeded): + """Pulled + referenced → every named shape of the snippet's kind becomes + an instance row, classified_by=hook, carrying the evidence as reason. A + later agent judgment on one of them stands against a re-stamp; the hook + may only overwrite nobody's judgment or its own. The outsider stamps + nothing (write-gated like every other ledger write).""" + from datetime import datetime, timezone + + from scribe.services.shape_ledger import stamp_write_path_instances + + owner, other, pid, sid = ( + seeded["owner"], seeded["other"], seeded["pid"], seeded["snippet"] + ) + pulled = {sid: datetime.now(timezone.utc)} + code = "app = factory()\nreturn app\n" # references the snippet's symbol + + assert await stamp_write_path_instances( + other, pid, path="src/app.py", shapes=[("sym", "make_app")], + code=code, pulled=pulled, + ) == [] + + stamped = await stamp_write_path_instances( + owner, pid, path="src/app.py", + shapes=[("sym", "make_app"), ("sym", "Config"), ("css", "nope")], + code=code, pulled=pulled, + ) + assert {s["symbol"] for s in stamped} == {"make_app", "Config"} # css skipped: no css canon + rows, _ = await list_project_shapes(owner, pid, snippet_id=sid) + by_symbol = {r.symbol: r for r in rows} + assert by_symbol["make_app"].status == "instance" + assert by_symbol["make_app"].classified_by == "hook" + assert by_symbol["make_app"].reason == f"hook: pulled #{sid}; payload references `factory`" + + # A judgment lands; the next stamp must leave it alone but may re-stamp + # its own earlier row. + await classify_shapes(owner, pid, [ + {"path": "src/app.py", "symbol": "make_app", "status": "exempt", + "reason": "the app factory is its own thing"}, + ]) + again = await stamp_write_path_instances( + owner, pid, path="src/app.py", + shapes=[("sym", "make_app"), ("sym", "Config")], code=code, pulled=pulled, + ) + assert {s["symbol"] for s in again} == {"Config"} + rows, _ = await list_project_shapes(owner, pid, path="src/app.py") + by_symbol = {r.symbol: r for r in rows} + assert by_symbol["make_app"].status == "exempt" + assert by_symbol["Config"].status == "instance" + + # Neither pulled nor in play → nothing, even with shapes named. + assert await stamp_write_path_instances( + owner, pid, path="src/util.py", shapes=[("sym", "helper")], + code="print('unrelated')", pulled=pulled, + ) == [] + + +@pytest.mark.integration +async def test_a_brand_new_shape_gets_a_provisional_row_the_sync_settles(seeded): + """The shape being written right now has no ledger row yet. With the + hook's repo key it gets a provisional one — seen markers unset — so the + stamp survives until the next sync, which confirms it (sets the marker) + or stamps it vanished. Without a repo key only existing rows are touched.""" + from datetime import datetime, timezone + + from scribe.services.shape_ledger import stamp_write_path_instances + + owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"] + pulled = {sid: datetime.now(timezone.utc)} + code = "def build():\n return factory()\n" + + assert await stamp_write_path_instances( + owner, pid, path="src/new.py", shapes=[("sym", "build")], + code=code, pulled=pulled, # no repo_key + ) == [] + stamped = await stamp_write_path_instances( + owner, pid, path="src/new.py", shapes=[("sym", "build")], + code=code, pulled=pulled, repo_key=REPO, + ) + assert [s["symbol"] for s in stamped] == ["build"] + async with async_session() as s: + row = (await s.execute(select(CodeShape).where( + CodeShape.project_id == pid, CodeShape.path == "src/new.py", + ))).scalar_one() + assert row.status == "instance" and row.classified_by == "hook" + assert row.first_seen_commit is None and row.last_seen_commit is None + + # The sync sees the shape in the tree → confirmed, stamp intact. + await sync_repo_shapes( + pid, REPO, SHAPES + [("src/new.py", "sym", "build")], seen_marker="abc123", + ) + rows, _ = await list_project_shapes(owner, pid, path="src/new.py") + assert rows[0].status == "instance" and rows[0].last_seen_commit == "abc123" + + # The sync no longer sees it → vanished, out of the live accounting. + await sync_repo_shapes(pid, REPO, SHAPES, seen_marker="def456") + rows, _ = await list_project_shapes(owner, pid, path="src/new.py") + assert rows == [] + rows, _ = await list_project_shapes(owner, pid, path="src/new.py", include_vanished=True) + assert rows[0].vanished_at is not None + + +@pytest.mark.integration +async def test_recent_pulls_reads_the_usage_stream(seeded): + """The "actually pulled it" half is the PULLED usage event, inside the + window; a surfacing alone is not a pull.""" + from datetime import datetime, timedelta, timezone + + from scribe.models.note_usage import PULLED, SURFACED, NoteUsageEvent + from scribe.services.shape_ledger import recent_pulls + + owner, sid = seeded["owner"], seeded["snippet"] + now = datetime.now(timezone.utc) + async with async_session() as s: + s.add_all([ + NoteUsageEvent(user_id=owner, note_id=sid, event=PULLED, source="mcp_get_snippet"), + NoteUsageEvent(user_id=owner, note_id=sid + 1000, event=SURFACED, source="auto_inject"), + NoteUsageEvent(user_id=owner, note_id=sid + 2000, event=PULLED, + source="mcp_get_snippet", created_at=now - timedelta(days=2)), + ]) + await s.commit() + pulls = await recent_pulls(owner) + assert sid in pulls + assert sid + 1000 not in pulls + assert sid + 2000 not in pulls diff --git a/tests/test_shape_ledger.py b/tests/test_shape_ledger.py index e3b95b2..a176551 100644 --- a/tests/test_shape_ledger.py +++ b/tests/test_shape_ledger.py @@ -82,3 +82,56 @@ def test_classify_and_list_are_mounted_as_mcp_tools(): mcp = build_mcp_server() for name in ("classify_shapes", "list_shapes", "refresh_pattern_coverage"): assert mcp._tool_manager.get_tool(name) is not None + + +# --- step 5: the write-path feed's evidence tests (pure) --------------------- + + +def test_symbol_reference_is_word_bounded_and_kind_aware(): + from scribe.services.shape_ledger import references_symbol as ref + + code = "const ok = await confirmed({ title: 'x' });\nif (!ok) return;" + assert ref(code, "confirmed", "sym") + assert not ref(code, "confirm", "sym") # prefix never claims the call + assert not ref("", "confirmed", "sym") + assert not ref(code, "", "sym") + # CSS: the class as a selector or inside a class attribute; dashes are part + # of the name, so `btn` must not claim `btn-primary`. + html = '' + assert ref(html, ".btn-primary", "css") + assert ref(html, "btn-primary", "css") + assert ref(".btn-primary { color: red }", ".btn-primary", "css") + assert not ref('