From 35f3f09d12627b19b3d287862248480d9cd06e1e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 28 Jul 2026 18:20:33 -0400 Subject: [PATCH] =?UTF-8?q?feat(snippets):=20drift=20check=20=E2=80=94=20v?= =?UTF-8?q?erify=20a=20snippet=20still=20matches=20its=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A recorded snippet points at a repo · path · symbol that WILL rot: files move, symbols get renamed, implementations diverge from the copy stored here. Nothing detected any of it, so a record degraded silently from "canonical reference" to "confidently wrong" — worse than no record, since it is surfaced with the same authority either way. WHERE THE CHECK RUNS. Agent-side, which the task flagged as the design question to settle first. Scribe has no checkout of the operator's repos and must not acquire one: giving the server repo access would make every install a credential problem and break instance-agnosticism (rule #115). The agent already has the working tree, so it does the comparing; the server remembers the verdict, makes it queryable, and knows when it has expired. New MCP tool verify_snippet teaches the four-step procedure and records the result; a REST endpoint mirrors it so the UI can clear a marker after a manual fix. WHY THE VERDICT CARRIES A CODE HASH. A verdict describes the code it was checked against. Invalidating it on edit means deciding which edits count — a when_to_use tweak shouldn't void a code check, a rewrite must — which is fiddly and easy to get subtly wrong, and easy for a new write path to forget entirely. Stamping the verdict with a hash sidesteps all of it: one whose code_sha no longer matches is self-evidently expired, computed at read time, no invalidation branch to maintain. That makes "expired" the interesting filter case. It is not `drifted` (nothing was found wrong) and not `unverified` (a check did happen), yet it plainly needs looking at — so `verification=attention` covers both. To keep that one index-served predicate rather than a post-filter that would make the pagination total a lie, data now also mirrors the CURRENT code's fingerprint as data.code_sha, and a jsonpath compares the two fields within the row. The filter is implemented in both dialects, SQL and Python, for the same reason the location filter is: the semantic arm's candidates arrive already fetched. A merge deliberately carries no verdict forward — the survivor's code is a union of several sources, so no prior check describes it, and unverified is the honest answer. UI: a danger-toned drift badge on each card (an actively misleading record outranks a merely unused one), and a "Needs attention" filter. Its empty state says plainly that never-verified snippets don't appear there — otherwise `attention` would mean "everything" on day one and be useless as a worklist. Refs #2086 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs --- frontend/src/api/snippets.ts | 29 +++ frontend/src/views/SnippetListView.vue | 95 ++++++++-- src/scribe/mcp/tools/snippets.py | 64 ++++++- src/scribe/routes/snippets.py | 42 ++++- src/scribe/services/knowledge.py | 111 ++++++++++- src/scribe/services/snippets.py | 196 ++++++++++++++++++- tests/test_snippet_drift_check.py | 251 +++++++++++++++++++++++++ 7 files changed, 767 insertions(+), 21 deletions(-) create mode 100644 tests/test_snippet_drift_check.py diff --git a/frontend/src/api/snippets.ts b/frontend/src/api/snippets.ts index e912d4c..249bd30 100644 --- a/frontend/src/api/snippets.ts +++ b/frontend/src/api/snippets.ts @@ -56,6 +56,19 @@ export interface SnippetUsage { last_pulled_at: string | null; } +/** Result of the last drift check — does the recorded location and code still + * match source? The check runs agent-side (Scribe has no checkout); this is the + * remembered verdict. `current` is false once the snippet has been edited since + * the check, at which point the verdict describes code that's no longer there. */ +export interface SnippetVerification { + status: "ok" | "missing" | "moved" | "changed" | "unverified"; + current: boolean; + checked_at: string | null; + detail?: string | null; + path?: string | null; + needs_attention?: boolean; +} + /** Lightweight list item from the knowledge preview feed. Note: the `snippet` * field here is a truncated *body preview* (the knowledge feed's naming), not * the parsed fields above. */ @@ -73,6 +86,9 @@ export interface SnippetListItem { owner?: string | null; /** Always present from the backend, zero-filled for records with no events. */ usage?: SnippetUsage; + /** Present on the detail record; the list feed carries it when a check has + * been recorded. */ + verification?: SnippetVerification; } /** Create/update payload — discrete fields the backend serializes into the @@ -103,6 +119,8 @@ export async function listSnippets( repo?: string; path?: string; symbol?: string; + /** Drift check: "attention" | "ok" | "unverified" | "drifted" | a status. */ + verification?: string; } = {}, ): Promise<{ snippets: SnippetListItem[]; total: number }> { const qs = new URLSearchParams(); @@ -112,6 +130,7 @@ export async function listSnippets( if (params.repo) qs.set("repo", params.repo); if (params.path) qs.set("path", params.path); if (params.symbol) qs.set("symbol", params.symbol); + if (params.verification) qs.set("verification", params.verification); const query = qs.toString(); return apiGet(`/api/snippets${query ? `?${query}` : ""}`); } @@ -135,6 +154,16 @@ export async function deleteSnippet(id: number): Promise { return apiDelete(`/api/snippets/${id}`); } +/** Record a drift-check verdict. The check itself runs where the code is — an + * agent with the working tree — since Scribe has no checkout. This stores what + * was found, and is how the UI clears a stale marker after a manual fix. */ +export async function verifySnippet( + id: number, + verdict: { status: string; detail?: string; path?: string }, +): Promise { + return apiPost(`/api/snippets/${id}/verify`, verdict); +} + /** Unify `sourceIds` into the canonical snippet `targetId`. Returns the merged * survivor plus `merged_ids` — the sources actually folded in and trashed. */ export async function mergeSnippets( diff --git a/frontend/src/views/SnippetListView.vue b/frontend/src/views/SnippetListView.vue index 2af502f..352ca4b 100644 --- a/frontend/src/views/SnippetListView.vue +++ b/frontend/src/views/SnippetListView.vue @@ -34,6 +34,15 @@ function toggleLocationFilter() { if (!showLocationFilter.value && locationActive.value) clearLocation(); } +// Drift check (#2086) — "attention" is everything whose recorded location or +// code no longer checks out, plus everything whose verdict expired because the +// snippet was edited since it was checked. +const needsAttentionOnly = ref(false); +function toggleAttention() { + needsAttentionOnly.value = !needsAttentionOnly.value; + loadSnippets(); +} + // Multi-select → merge const selectMode = ref(false); const selectedIds = ref>(new Set()); @@ -96,6 +105,7 @@ async function loadSnippets() { repo: locRepo.value.trim() || undefined, path: locPath.value.trim() || undefined, symbol: locSymbol.value.trim() || undefined, + verification: needsAttentionOnly.value ? "attention" : undefined, }); snippets.value = data.snippets; } catch { @@ -144,6 +154,39 @@ function usageBadge(s: SnippetListItem): string { return `${u.pull_count}/${u.surfaced_count} used`; } +/** Short label for the drift verdict, or "" when there's nothing to say. + * An expired verdict is reported as "unchecked" whatever it used to say — + * it was about code that is no longer in the record. */ +function driftBadge(s: SnippetListItem): string { + const v = s.verification; + if (!v) return ""; + if (!v.current) return "unchecked since edit"; + return { ok: "", missing: "path gone", moved: "symbol moved", changed: "code drifted" }[ + v.status + ] ?? ""; +} + +function driftTitle(s: SnippetListItem): string { + const v = s.verification; + if (!v) return ""; + const when = v.checked_at + ? `Checked ${new Date(v.checked_at).toLocaleDateString()}` + : "Checked"; + if (!v.current) { + return ( + `${when}, but the snippet has been edited since — that verdict was about ` + + `code this record no longer holds. Re-verify it.` + ); + } + const what = + { + missing: "the recorded path no longer exists", + moved: "the file is there but the symbol isn't in it", + changed: "the source no longer matches the recorded code", + }[v.status] ?? ""; + return v.detail ? `${when}: ${what}. ${v.detail}` : `${when}: ${what}.`; +} + function usageTitle(s: SnippetListItem): string { const u = s.usage; if (!u) return ""; @@ -201,6 +244,15 @@ function usageTitle(s: SnippetListItem): string { a screen reader too. --> {{ locationActive ? "Location · filtering" : "Location" }} + @@ -243,20 +295,27 @@ function usageTitle(s: SnippetListItem): string {
❭_

- {{ locationActive - ? "Nothing kept at that location yet" - : search.trim() - ? "No snippets match your search" - : "No snippets kept yet" }} + {{ needsAttentionOnly + ? "Everything checks out" + : locationActive + ? "Nothing kept at that location yet" + : search.trim() + ? "No snippets match your search" + : "No snippets kept yet" }}

- {{ locationActive - ? "No recorded snippet lives there — so whatever you're about to write is new. Widen the path, or clear the filter." - : search.trim() - ? "Try a different term, or clear the search." - : "Record a reusable function or component and it will be offered back to you later." }} + {{ needsAttentionOnly + ? "No snippet has drifted from its recorded location or code — as far as anything has been checked. Snippets nobody has verified yet don't appear here." + : locationActive + ? "No recorded snippet lives there — so whatever you're about to write is new. Widen the path, or clear the filter." + : search.trim() + ? "Try a different term, or clear the search." + : "Record a reusable function or component and it will be offered back to you later." }}

- +