feat(snippets): drift check — verify a snippet still matches its source
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Failing after 20s
CI & Build / Python tests (push) Failing after 22s
CI & Build / Build & push image (push) Has been skipped

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
2026-07-28 18:20:33 -04:00
parent 2b85443dd1
commit 35f3f09d12
7 changed files with 767 additions and 21 deletions
+29
View File
@@ -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<void> {
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<Snippet> {
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(
+84 -11
View File
@@ -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<Set<number>>(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" }}
</button>
<button
class="btn-ghost"
:class="{ 'filter-on': needsAttentionOnly }"
:aria-pressed="needsAttentionOnly"
title="Show only snippets whose recorded location or code no longer checks out — including ones edited since they were last verified"
@click="toggleAttention"
>
{{ needsAttentionOnly ? "Needs attention · filtering" : "Needs attention" }}
</button>
</div>
<!-- Reverse lookup: what's already kept in this repo / file / symbol. -->
@@ -243,20 +295,27 @@ function usageTitle(s: SnippetListItem): string {
<div v-else-if="snippets.length === 0" class="empty-state-rich">
<div class="empty-icon">_</div>
<p class="empty-title">
{{ 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" }}
</p>
<p class="empty-sub">
{{ 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." }}
</p>
<button v-if="locationActive" class="empty-action" @click="clearLocation">
<button v-if="needsAttentionOnly" class="empty-action" @click="toggleAttention">
Show all snippets
</button>
<button v-else-if="locationActive" class="empty-action" @click="clearLocation">
Clear location filter
</button>
<button
@@ -295,6 +354,9 @@ function usageTitle(s: SnippetListItem): string {
</p>
<div class="card-footer">
<span class="meta-date">Updated {{ new Date(s.updated_at).toLocaleDateString() }}</span>
<span v-if="driftBadge(s)" class="drift-tag" :title="driftTitle(s)">
{{ driftBadge(s) }}
</span>
<span
v-if="usageBadge(s)"
class="usage-tag"
@@ -618,6 +680,17 @@ function usageTitle(s: SnippetListItem): string {
color: var(--color-text-muted);
}
/* Drift is a stronger signal than dead weight: the record may be actively
misleading, not merely unused. Danger tone, and it sits first in the footer. */
.drift-tag {
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
border-radius: 4px;
white-space: nowrap;
background: color-mix(in srgb, var(--color-danger, #b91c1c) 15%, transparent);
color: var(--color-danger, #b91c1c);
}
.usage-tag {
font-size: 0.7rem;
padding: 0.1rem 0.4rem;