Edit-time record-sync nudge — the sync class (#2708) #112
@@ -1316,7 +1316,10 @@ function formatUserDate(iso: string): string {
|
|||||||
Checks the file Claude is about to write or edit against your recorded
|
Checks the file Claude is about to write or edit against your recorded
|
||||||
snippets — what's already kept at that path, and what resembles the code
|
snippets — what's already kept at that path, and what resembles the code
|
||||||
being written — so a helper you already have is offered before it's
|
being written — so a helper you already have is offered before it's
|
||||||
rewritten. Uses the ceiling above with its own threshold below, and never
|
rewritten. When the file being edited is itself a recorded snippet's
|
||||||
|
location, the hint instead asks Claude to update or re-verify that
|
||||||
|
record as part of the edit — how records stay current without any forge
|
||||||
|
connection. Uses the ceiling above with its own threshold below, and never
|
||||||
blocks the edit. Off = prior art surfaces only on your own prompts.
|
blocks the edit. Off = prior art surfaces only on your own prompts.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "scribe",
|
"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.",
|
"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.30",
|
"version": "0.1.31",
|
||||||
"author": { "name": "Bryan Van Deusen" },
|
"author": { "name": "Bryan Van Deusen" },
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"scribe": {
|
"scribe": {
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ On install you'll be asked for:
|
|||||||
(`hooks/scribe_prior_art.sh`) → `GET /api/plugin/prior-art`. Returns
|
(`hooks/scribe_prior_art.sh`) → `GET /api/plugin/prior-art`. Returns
|
||||||
`additionalContext` with **no** permission decision, so it can inform the write
|
`additionalContext` with **no** permission decision, so it can inform the write
|
||||||
but never stop it; silent when nothing is recorded, which is most of the time.
|
but never stop it; silent when nothing is recorded, which is most of the time.
|
||||||
|
Two framings: a REUSE menu (similar/nearby records), and a SYNC nudge when a
|
||||||
|
snippet records the exact file being edited — "updating the record is part of
|
||||||
|
the edit" — each with its own once-per-session dedup.
|
||||||
Toggle in **Settings → Knowledge auto-inject**.
|
Toggle in **Settings → Knowledge auto-inject**.
|
||||||
- `skills/` → the universal process-skills, surfaced by description match.
|
- `skills/` → the universal process-skills, surfaced by description match.
|
||||||
- `hooks/scribe_sync_processes.sh` (a 2nd SessionStart hook) + the `/scribe:sync`
|
- `hooks/scribe_sync_processes.sh` (a 2nd SessionStart hook) + the `/scribe:sync`
|
||||||
|
|||||||
@@ -8,6 +8,12 @@
|
|||||||
# that path or in its directory, plus snippets resembling the code about to be
|
# that path or in its directory, plus snippets resembling the code about to be
|
||||||
# written. Titles + ids only, never bodies.
|
# written. Titles + ids only, never bodies.
|
||||||
#
|
#
|
||||||
|
# The answer comes in two framings (#2708). A snippet recorded AT the exact
|
||||||
|
# file being edited is the SYNC class — "you are editing the recorded file;
|
||||||
|
# updating the record is part of the edit" — which is how records stay current
|
||||||
|
# on an instance with no forge connection (decision #2707). Everything else is
|
||||||
|
# the REUSE menu. The two dedup separately (see the state files below).
|
||||||
|
#
|
||||||
# NEVER BLOCKS. It returns `additionalContext` with no `permissionDecision`, so
|
# NEVER BLOCKS. It returns `additionalContext` with no `permissionDecision`, so
|
||||||
# the write proceeds untouched and Claude sees the note beside the tool result.
|
# the write proceeds untouched and Claude sees the note beside the tool result.
|
||||||
# Any failure — unconfigured, unreachable, malformed — exits 0 in silence. A
|
# Any failure — unconfigured, unreachable, malformed — exits 0 in silence. A
|
||||||
@@ -194,31 +200,51 @@ fi
|
|||||||
# surface shows a given snippet at most once per session, but they don't silence
|
# surface shows a given snippet at most once per session, but they don't silence
|
||||||
# each other: a title that flew past in a prompt menu twenty turns ago is
|
# each other: a title that flew past in a prompt menu twenty turns ago is
|
||||||
# exactly what should reappear at the moment the duplicate is being written.
|
# exactly what should reappear at the moment the duplicate is being written.
|
||||||
|
#
|
||||||
|
# TWO channels, not one (#2708). The server answers in two classes — REUSE
|
||||||
|
# ("something similar/nearby is recorded") and SYNC ("a snippet records the
|
||||||
|
# exact file being edited — updating the record is part of the edit"). They
|
||||||
|
# dedup separately: a reuse hint shown early in the session must not suppress
|
||||||
|
# the sync nudge when the recorded file itself is edited later.
|
||||||
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
|
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
|
||||||
mkdir -p "$state_dir" 2>/dev/null || true
|
mkdir -p "$state_dir" 2>/dev/null || true
|
||||||
idfile=""
|
idfile=""
|
||||||
|
syncfile=""
|
||||||
exclude_q=""
|
exclude_q=""
|
||||||
|
sync_exclude_q=""
|
||||||
if [ -n "$session_id" ]; then
|
if [ -n "$session_id" ]; then
|
||||||
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
||||||
idfile="$state_dir/${safe_sid}.ids"
|
idfile="$state_dir/${safe_sid}.ids"
|
||||||
|
syncfile="$state_dir/${safe_sid}.sync.ids"
|
||||||
if [ -f "$idfile" ]; then
|
if [ -f "$idfile" ]; then
|
||||||
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
|
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
|
||||||
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
|
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
|
||||||
fi
|
fi
|
||||||
|
if [ -f "$syncfile" ]; then
|
||||||
|
sync_seen=$(tr '\n' ',' < "$syncfile" 2>/dev/null | sed 's/,$//')
|
||||||
|
[ -n "$sync_seen" ] && sync_exclude_q="&exclude_sync_ids=${sync_seen}"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# `|| true`, not `|| exit 0`: an unreachable instance must not discard a local
|
# `|| true`, not `|| exit 0`: an unreachable instance must not discard a local
|
||||||
# finding that needed no instance to produce.
|
# finding that needed no instance to produce.
|
||||||
body=$(curl -fsS --max-time 5 \
|
body=$(curl -fsS --max-time 5 \
|
||||||
-H "Authorization: Bearer ${token}" \
|
-H "Authorization: Bearer ${token}" \
|
||||||
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}" 2>/dev/null) || body=""
|
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}" 2>/dev/null) || body=""
|
||||||
|
|
||||||
context=""
|
context=""
|
||||||
if [ -n "$body" ]; then
|
if [ -n "$body" ]; then
|
||||||
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || context=""
|
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || context=""
|
||||||
# Remember what was surfaced so it isn't shown again this session.
|
# Remember what was surfaced so it isn't shown again this session — each
|
||||||
if [ -n "$idfile" ] && [ -n "$context" ]; then
|
# class into its own channel: sync ids (snippets recording the edited file)
|
||||||
printf '%s' "$body" | jq -r '.note_ids[]? // empty' 2>/dev/null >> "$idfile" || true
|
# to the sync file, everything else to the reuse file.
|
||||||
|
if [ -n "$context" ]; then
|
||||||
|
if [ -n "$idfile" ]; then
|
||||||
|
printf '%s' "$body" | jq -r '((.note_ids // []) - (.sync_note_ids // []))[]?' 2>/dev/null >> "$idfile" || true
|
||||||
|
fi
|
||||||
|
if [ -n "$syncfile" ]; then
|
||||||
|
printf '%s' "$body" | jq -r '(.sync_note_ids // [])[]?' 2>/dev/null >> "$syncfile" || true
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ through recall/auto-inject; this skill is the active reflex around that.
|
|||||||
it before you go any further. Either it's the helper you were about to
|
it before you go any further. Either it's the helper you were about to
|
||||||
duplicate — reuse it and drop yours — or it isn't, and the record needs the new
|
duplicate — reuse it and drop yours — or it isn't, and the record needs the new
|
||||||
location adding. Both are cheaper now than after the duplicate settles in.
|
location adding. Both are cheaper now than after the duplicate settles in.
|
||||||
|
- **A `[records this file]` hint is a duty, not a menu.** When the hint says a
|
||||||
|
snippet records the very file you're editing, the record's freshness is now
|
||||||
|
YOUR edit's responsibility: if the edit changes the recorded shape,
|
||||||
|
`update_snippet(id, code=…)` with the new form as part of the same task; if
|
||||||
|
it doesn't, `verify_snippet(id, status="ok", commit_sha=…)` costs one call
|
||||||
|
and re-stamps the record as checked. Scribe never reads the repo — this
|
||||||
|
moment, in the session that has the context, is the only place the record
|
||||||
|
gets kept true.
|
||||||
|
|
||||||
## The first time a shape is built — record it
|
## The first time a shape is built — record it
|
||||||
|
|
||||||
|
|||||||
@@ -120,7 +120,13 @@ async def write_path_prior_art():
|
|||||||
is NOT used as the location `repo` filter — see the
|
is NOT used as the location `repo` filter — see the
|
||||||
service docstring for why those two differ.
|
service docstring for why those two differ.
|
||||||
project_id (opt) — explicit project scope override (ad-hoc/testing).
|
project_id (opt) — explicit project scope override (ad-hoc/testing).
|
||||||
exclude_ids (opt) — comma-separated ids already surfaced this session.
|
exclude_ids (opt) — comma-separated REUSE-class ids already surfaced
|
||||||
|
this session.
|
||||||
|
exclude_sync_ids (opt) — comma-separated SYNC-class ids (snippets
|
||||||
|
recorded AT the edited file, #2708) already
|
||||||
|
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.
|
||||||
"""
|
"""
|
||||||
path = (request.args.get("path") or "").strip()
|
path = (request.args.get("path") or "").strip()
|
||||||
code = request.args.get("code") or ""
|
code = request.args.get("code") or ""
|
||||||
@@ -139,9 +145,14 @@ async def write_path_prior_art():
|
|||||||
int(p) for p in (request.args.get("exclude_ids") or "").split(",")
|
int(p) for p in (request.args.get("exclude_ids") or "").split(",")
|
||||||
if p.strip().isdigit()
|
if p.strip().isdigit()
|
||||||
]
|
]
|
||||||
|
exclude_sync_ids = [
|
||||||
|
int(p) for p in (request.args.get("exclude_sync_ids") or "").split(",")
|
||||||
|
if p.strip().isdigit()
|
||||||
|
]
|
||||||
|
|
||||||
result = await plugin_ctx_svc.build_write_path_hint(
|
result = await plugin_ctx_svc.build_write_path_hint(
|
||||||
g.user.id, path, code=code, project_id=project_id, exclude_ids=exclude_ids
|
g.user.id, path, code=code, project_id=project_id,
|
||||||
|
exclude_ids=exclude_ids, exclude_sync_ids=exclude_sync_ids,
|
||||||
)
|
)
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|||||||
@@ -702,6 +702,7 @@ async def build_write_path_hint(
|
|||||||
code: str = "",
|
code: str = "",
|
||||||
project_id: int = 0,
|
project_id: int = 0,
|
||||||
exclude_ids: list[int] | None = None,
|
exclude_ids: list[int] | None = None,
|
||||||
|
exclude_sync_ids: list[int] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
|
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
|
||||||
|
|
||||||
@@ -709,11 +710,24 @@ async def build_write_path_hint(
|
|||||||
convention snippet locations are recorded in. `code` is what's about to be
|
convention snippet locations are recorded in. `code` is what's about to be
|
||||||
written, used only as the semantic query.
|
written, used only as the semantic query.
|
||||||
|
|
||||||
Carries auto-inject's anti-bloat gates (margin, session dedup via
|
A hit recorded AT this exact path is not a reuse suggestion — it IS the
|
||||||
`exclude_ids`, titles-never-bodies) plus the shared top-k cap across BOTH
|
record of the file being changed, so it renders as the SYNC class (#2708):
|
||||||
arms — so a file with a lot of recorded history can't turn one edit into a
|
"this snippet records the file you're editing; if the edit changes the
|
||||||
wall of text. Two gates are its OWN, because code is not prose: a stricter
|
recorded shape, updating the record is part of the edit." That is the
|
||||||
similarity threshold, and a minimum-substance floor on `code` below which the
|
operator's chosen alternative to server-side drift flagging (decision
|
||||||
|
#2707): the record gets corrected in the session that has the context,
|
||||||
|
at the moment of change. Nearby and semantic hits stay the REUSE menu.
|
||||||
|
|
||||||
|
The two classes dedup on SEPARATE channels — `exclude_ids` (reuse) and
|
||||||
|
`exclude_sync_ids` (sync) — because they answer different questions: a
|
||||||
|
title shown as "consider reusing this" twenty turns ago must not silence
|
||||||
|
"you are editing the recorded file right now" (#2708).
|
||||||
|
|
||||||
|
Carries auto-inject's anti-bloat gates (margin, session dedup,
|
||||||
|
titles-never-bodies) plus the shared top-k cap across ALL arms — so a file
|
||||||
|
with a lot of recorded history can't turn one edit into a wall of text. Two
|
||||||
|
gates are its OWN, because code is not prose: a stricter similarity
|
||||||
|
threshold, and a minimum-substance floor on `code` below which the
|
||||||
semantic arm doesn't run at all (#2223 — see WRITEPATH_DEFAULT_THRESHOLD and
|
semantic arm doesn't run at all (#2223 — see WRITEPATH_DEFAULT_THRESHOLD and
|
||||||
WRITEPATH_MIN_CODE_CHARS). Returns empty context when disabled, when there's
|
WRITEPATH_MIN_CODE_CHARS). Returns empty context when disabled, when there's
|
||||||
no path, or when nothing is recorded — which is the common case, and the point.
|
no path, or when nothing is recorded — which is the common case, and the point.
|
||||||
@@ -724,28 +738,34 @@ async def build_write_path_hint(
|
|||||||
free-text label the operator typed ("Scribe"), not a remote URL, and matching
|
free-text label the operator typed ("Scribe"), not a remote URL, and matching
|
||||||
one against the other would silently return nothing.
|
one against the other would silently return nothing.
|
||||||
|
|
||||||
Returns {"context": str, "note_ids": list[int], "config": dict}. The semantic
|
Returns {"context": str, "note_ids": list[int], "sync_note_ids": list[int],
|
||||||
arm is logged to retrieval_logs as source='write_path' — its own source, so
|
"config": dict} — `sync_note_ids` is the subset of `note_ids` shown as the
|
||||||
its precision is tunable separately from auto-inject's.
|
sync class, so the hook can feed each dedup channel its own ids. The
|
||||||
|
semantic arm is logged to retrieval_logs as source='write_path' — its own
|
||||||
|
source, so its precision is tunable separately from auto-inject's.
|
||||||
|
|
||||||
Location hits still carry no score and so stay out of retrieval_logs, whose
|
Location hits still carry no score and so stay out of retrieval_logs, whose
|
||||||
score distribution they would corrupt. What closed the gap (#2085) is that
|
score distribution they would corrupt. What closed the gap (#2085) is that
|
||||||
un-scored surfacing now has its own home: BOTH arms emit note_usage_events,
|
un-scored surfacing now has its own home: every arm emits note_usage_events,
|
||||||
tagged 'write_path_place' vs 'write_path_semantic', so the place arm is
|
tagged 'write_path_sync' vs 'write_path_place' vs 'write_path_semantic', so
|
||||||
finally measurable — and the two arms' pull-through rates are comparable,
|
each claim's pull-through rate is measurable on its own.
|
||||||
which is the number that says whether place really does beat meaning here.
|
|
||||||
"""
|
"""
|
||||||
cfg = await get_writepath_config(user_id)
|
cfg = await get_writepath_config(user_id)
|
||||||
empty = {"context": "", "note_ids": [], "config": cfg}
|
empty = {"context": "", "note_ids": [], "sync_note_ids": [], "config": cfg}
|
||||||
path = (path or "").strip()
|
path = (path or "").strip()
|
||||||
if not cfg["enabled"] or not path:
|
if not cfg["enabled"] or not path:
|
||||||
return empty
|
return empty
|
||||||
|
|
||||||
top_k = cfg["top_k"]
|
top_k = cfg["top_k"]
|
||||||
excluded = set(exclude_ids or [])
|
excluded = set(exclude_ids or [])
|
||||||
|
sync_excluded = set(exclude_sync_ids or [])
|
||||||
scope_project = project_id or None
|
scope_project = project_id or None
|
||||||
|
|
||||||
# --- arm 1: by place ---
|
# --- the sync class, and arm 1 by place ---
|
||||||
|
# `path` matches exact-or-under (see knowledge.py), and nothing sits under
|
||||||
|
# a FILE path — so the file query returns precisely the snippets recorded
|
||||||
|
# AT this path: the sync class. The directory query is the reuse-shaped
|
||||||
|
# "nearby" arm, unchanged.
|
||||||
here: list[dict] = []
|
here: list[dict] = []
|
||||||
nearby: list[dict] = []
|
nearby: list[dict] = []
|
||||||
try:
|
try:
|
||||||
@@ -760,19 +780,29 @@ async def build_write_path_hint(
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Write-path location lookup failed", exc_info=True)
|
logger.warning("Write-path location lookup failed", exc_info=True)
|
||||||
|
|
||||||
|
# Sync hits dedup ONLY against their own channel — reuse-`excluded` ids
|
||||||
|
# stay eligible here, which is the whole point of the split. Either way
|
||||||
|
# they join `seen`, so the reuse arms (where the directory query would
|
||||||
|
# surface them again) never re-list a record the sync block owns.
|
||||||
seen: set[int] = set(excluded)
|
seen: set[int] = set(excluded)
|
||||||
|
synced: list[dict] = []
|
||||||
|
for item in here:
|
||||||
|
nid = int(item["id"])
|
||||||
|
seen.add(nid)
|
||||||
|
if nid not in sync_excluded and len(synced) < top_k:
|
||||||
|
synced.append(item)
|
||||||
|
|
||||||
placed: list[tuple[str, dict]] = []
|
placed: list[tuple[str, dict]] = []
|
||||||
for marker, items in (("here", here), ("nearby", nearby)):
|
for item in nearby:
|
||||||
for item in items:
|
nid = int(item["id"])
|
||||||
nid = int(item["id"])
|
if nid in seen:
|
||||||
if nid in seen:
|
continue
|
||||||
continue
|
seen.add(nid)
|
||||||
seen.add(nid)
|
placed.append(("nearby", item))
|
||||||
placed.append((marker, item))
|
|
||||||
|
|
||||||
# --- arm 2: by meaning ---
|
# --- arm 2: by meaning ---
|
||||||
scored: list[tuple[str, dict]] = []
|
scored: list[tuple[str, dict]] = []
|
||||||
remaining = top_k - len(placed)
|
remaining = top_k - len(synced) - len(placed)
|
||||||
query = (code or "").strip()
|
query = (code or "").strip()
|
||||||
# Drop payloads too small to carry meaning before spending an embedding on
|
# Drop payloads too small to carry meaning before spending an embedding on
|
||||||
# them — a one-line Edit is not a helper being rewritten, and its embedding
|
# them — a one-line Edit is not a helper being rewritten, and its embedding
|
||||||
@@ -848,31 +878,54 @@ async def build_write_path_hint(
|
|||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
|
||||||
menu = (placed + scored)[:top_k]
|
menu = (placed + scored)[:max(0, top_k - len(synced))]
|
||||||
if not menu:
|
if not synced and not menu:
|
||||||
return empty
|
return empty
|
||||||
|
|
||||||
owners = await owner_names_for({
|
owners = await owner_names_for({
|
||||||
int(it["user_id"]) for _m, it in menu
|
int(it["user_id"]) for it in synced + [it for _m, it in menu]
|
||||||
if it.get("user_id") is not None and int(it["user_id"]) != user_id
|
if it.get("user_id") is not None and int(it["user_id"]) != user_id
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def _owner_of(item: dict) -> str | None:
|
||||||
|
owner_id = item.get("user_id")
|
||||||
|
if owner_id is None or int(owner_id) == user_id:
|
||||||
|
return None
|
||||||
|
return owners.get(int(owner_id)) or "another user"
|
||||||
|
|
||||||
target_lang = _language_for_path(path)
|
target_lang = _language_for_path(path)
|
||||||
rendered: list[tuple[dict, str, str | None, str]] = []
|
rendered: list[tuple[dict, str, str | None, str]] = []
|
||||||
for marker, item in menu:
|
for marker, item in menu:
|
||||||
owner_id = item.get("user_id")
|
rendered.append((item, marker, _owner_of(item), _foreign_language(item, target_lang)))
|
||||||
owner = None
|
|
||||||
if owner_id is not None and int(owner_id) != user_id:
|
|
||||||
owner = owners.get(int(owner_id)) or "another user"
|
|
||||||
rendered.append((item, marker, owner, _foreign_language(item, target_lang)))
|
|
||||||
|
|
||||||
lines = [
|
lines: list[str] = []
|
||||||
f"> Prior art already recorded in Scribe for `{path}` — open one with "
|
sync_note_ids: list[int] = []
|
||||||
"`get_snippet(id)` for a snippet, `get_task(id)` for an issue, "
|
if synced:
|
||||||
"`get_note(id)` otherwise. Reuse a snippet rather than writing a fresh "
|
# The sync framing (#2708). Deliberately imperative about the record —
|
||||||
"one-off; read an issue before repeating what it records "
|
# the reuse claim ("start from this shape") is still implied by the
|
||||||
"(titles only; shown once per session):",
|
# title being right there, but the load-bearing sentence is the one no
|
||||||
]
|
# other surface says: keeping the record true is part of THIS edit.
|
||||||
|
lines.append(
|
||||||
|
f"> Recorded in Scribe AT `{path}` — the snippet(s) below record "
|
||||||
|
"the file this edit is changing. Reuse/extend the recorded shape "
|
||||||
|
"rather than writing a parallel one; and if this edit changes what "
|
||||||
|
"a record captures, updating it is part of the edit: "
|
||||||
|
"`update_snippet(id, code=…)` with the new shape, or "
|
||||||
|
"`verify_snippet(id, status=\"ok\", commit_sha=…)` after confirming "
|
||||||
|
"it still holds. Open with `get_snippet(id)` (shown once per session):"
|
||||||
|
)
|
||||||
|
for item in synced:
|
||||||
|
sync_note_ids.append(int(item["id"]))
|
||||||
|
lines.append(_prior_art_line(item, "records this file", _owner_of(item)))
|
||||||
|
|
||||||
|
if menu:
|
||||||
|
lines.append(
|
||||||
|
f"> Prior art already recorded in Scribe for `{path}` — open one with "
|
||||||
|
"`get_snippet(id)` for a snippet, `get_task(id)` for an issue, "
|
||||||
|
"`get_note(id)` otherwise. Reuse a snippet rather than writing a fresh "
|
||||||
|
"one-off; read an issue before repeating what it records "
|
||||||
|
"(titles only; shown once per session):"
|
||||||
|
)
|
||||||
# Say what a language tag MEANS, and only when one is actually on the menu.
|
# Say what a language tag MEANS, and only when one is actually on the menu.
|
||||||
# Without this the reader has to infer why "· python" is attached to a hit on
|
# Without this the reader has to infer why "· python" is attached to a hit on
|
||||||
# a .ts file, and the two ways of guessing wrong are both bad: dismiss it as
|
# a .ts file, and the two ways of guessing wrong are both bad: dismiss it as
|
||||||
@@ -885,7 +938,7 @@ async def build_write_path_hint(
|
|||||||
"shape of a solution to adapt, not code to copy."
|
"shape of a solution to adapt, not code to copy."
|
||||||
)
|
)
|
||||||
|
|
||||||
note_ids: list[int] = []
|
note_ids: list[int] = list(sync_note_ids)
|
||||||
for item, marker, owner, foreign_lang in rendered:
|
for item, marker, owner, foreign_lang in rendered:
|
||||||
note_ids.append(int(item["id"]))
|
note_ids.append(int(item["id"]))
|
||||||
lines.append(_prior_art_line(item, marker, owner, foreign_lang))
|
lines.append(_prior_art_line(item, marker, owner, foreign_lang))
|
||||||
@@ -894,15 +947,24 @@ async def build_write_path_hint(
|
|||||||
# carries no score and so has no home in retrieval_logs; before #2085 a
|
# 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
|
# snippet surfaced BY PLACE left no trace anywhere, making the arm that
|
||||||
# fires on the strongest possible claim ("there is already a canonical
|
# fires on the strongest possible claim ("there is already a canonical
|
||||||
# helper in this exact file") the one arm nobody could measure.
|
# helper in this exact file") the one arm nobody could measure. The sync
|
||||||
|
# class gets its own tag: its pull-through rate is the number that says
|
||||||
|
# whether edit-time record-sync actually happens (#2708's success measure).
|
||||||
by_arm: dict[str, list[int]] = {}
|
by_arm: dict[str, list[int]] = {}
|
||||||
|
if sync_note_ids:
|
||||||
|
by_arm["write_path_sync"] = list(sync_note_ids)
|
||||||
for marker, item in menu:
|
for marker, item in menu:
|
||||||
arm = "write_path_place" if marker in ("here", "nearby") else "write_path_semantic"
|
arm = "write_path_place" if marker == "nearby" else "write_path_semantic"
|
||||||
by_arm.setdefault(arm, []).append(int(item["id"]))
|
by_arm.setdefault(arm, []).append(int(item["id"]))
|
||||||
for arm, ids in by_arm.items():
|
for arm, ids in by_arm.items():
|
||||||
record_surfaced(user_id=user_id, note_ids=ids, source=arm)
|
record_surfaced(user_id=user_id, note_ids=ids, source=arm)
|
||||||
|
|
||||||
return {"context": "\n".join(lines), "note_ids": note_ids, "config": cfg}
|
return {
|
||||||
|
"context": "\n".join(lines),
|
||||||
|
"note_ids": note_ids,
|
||||||
|
"sync_note_ids": sync_note_ids,
|
||||||
|
"config": cfg,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _topic_titles(topic_ids: set[int]) -> dict[int, str]:
|
async def _topic_titles(topic_ids: set[int]) -> dict[int, str]:
|
||||||
|
|||||||
@@ -156,16 +156,25 @@ async def test_no_ids_short_circuits_without_a_query():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"marker, expected_source",
|
"lookups, expected_source",
|
||||||
[("here", "write_path_place"), ("nearby", "write_path_place")],
|
[
|
||||||
|
# A hit AT the exact file is the sync class (#2708); a hit from the
|
||||||
|
# directory query is the reuse-shaped place arm. Both are un-scored,
|
||||||
|
# and both must leave a usage trace under their own name.
|
||||||
|
([(1, "hit"), (2, "empty")], "write_path_sync"),
|
||||||
|
([(1, "empty"), (2, "hit")], "write_path_place"),
|
||||||
|
],
|
||||||
|
ids=["at-the-file", "nearby"],
|
||||||
)
|
)
|
||||||
async def test_write_path_place_arm_is_recorded(marker, expected_source):
|
async def test_unscored_location_arms_are_recorded(lookups, expected_source):
|
||||||
"""The place arm carries no score, so it has no home in retrieval_logs — it
|
"""The location arms carry no score, so they have no home in retrieval_logs
|
||||||
surfaced snippets while leaving no trace anywhere. That was the blocker
|
— they surfaced snippets while leaving no trace anywhere. That was the
|
||||||
#2082 recorded against this task; this is the assertion that it's closed."""
|
blocker #2082 recorded against this task; this is the assertion that it's
|
||||||
|
closed, per class."""
|
||||||
from scribe.services import plugin_context
|
from scribe.services import plugin_context
|
||||||
|
|
||||||
here = [{"id": 42, "title": "helper", "user_id": 1, "note_type": "snippet"}]
|
here = [{"id": 42, "title": "helper", "user_id": 1, "note_type": "snippet"}]
|
||||||
|
responses = [(here, 1) if kind == "hit" else ([], 0) for _n, kind in lookups]
|
||||||
with (
|
with (
|
||||||
patch.object(
|
patch.object(
|
||||||
plugin_context,
|
plugin_context,
|
||||||
@@ -175,7 +184,7 @@ async def test_write_path_place_arm_is_recorded(marker, expected_source):
|
|||||||
patch.object(
|
patch.object(
|
||||||
plugin_context.snippets_svc,
|
plugin_context.snippets_svc,
|
||||||
"list_snippets",
|
"list_snippets",
|
||||||
AsyncMock(side_effect=[(here, 1), ([], 0)]),
|
AsyncMock(side_effect=responses),
|
||||||
),
|
),
|
||||||
patch.object(
|
patch.object(
|
||||||
plugin_context, "semantic_search_notes", AsyncMock(return_value=[])
|
plugin_context, "semantic_search_notes", AsyncMock(return_value=[])
|
||||||
|
|||||||
@@ -101,12 +101,14 @@ async def test_nothing_recorded_is_silent():
|
|||||||
assert out["context"] == "" and out["note_ids"] == []
|
assert out["context"] == "" and out["note_ids"] == []
|
||||||
|
|
||||||
|
|
||||||
# --- arm 1: by place ---------------------------------------------------------
|
# --- the sync class, and arm 1 by place --------------------------------------
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_snippet_at_this_path_is_surfaced_without_a_score():
|
async def test_snippet_at_this_path_becomes_the_sync_nudge():
|
||||||
"""A snippet recorded HERE is prior art by definition, not by resemblance —
|
"""A snippet recorded AT the exact file is not a reuse suggestion — it IS
|
||||||
it must not be subject to the similarity threshold."""
|
the record of the file being edited (#2708). It surfaces without a score,
|
||||||
|
framed as "updating the record is part of this edit", and is reported in
|
||||||
|
sync_note_ids so the hook feeds the sync dedup channel."""
|
||||||
from scribe.services import plugin_context as pc
|
from scribe.services import plugin_context as pc
|
||||||
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \
|
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \
|
||||||
patch.object(pc.snippets_svc, "list_snippets",
|
patch.object(pc.snippets_svc, "list_snippets",
|
||||||
@@ -116,11 +118,59 @@ async def test_snippet_at_this_path_is_surfaced_without_a_score():
|
|||||||
patch.object(pc, "owner_names_for", AsyncMock(return_value={})):
|
patch.object(pc, "owner_names_for", AsyncMock(return_value={})):
|
||||||
out = await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE)
|
out = await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE)
|
||||||
assert out["note_ids"] == [12]
|
assert out["note_ids"] == [12]
|
||||||
assert '#12 [here] "debounce — rate-limit a callback"' in out["context"]
|
assert out["sync_note_ids"] == [12]
|
||||||
assert "get_snippet(id)" in out["context"]
|
assert '#12 [records this file] "debounce — rate-limit a callback"' in out["context"]
|
||||||
|
# The load-bearing sentence: the record's freshness belongs to this edit.
|
||||||
|
assert "update_snippet" in out["context"]
|
||||||
|
assert "verify_snippet" in out["context"]
|
||||||
assert "src/x.py" in out["context"]
|
assert "src/x.py" in out["context"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reuse_dedup_never_suppresses_the_sync_nudge():
|
||||||
|
"""THE bug #2708 names: a title surfaced as a reuse hint twenty turns ago
|
||||||
|
landed in exclude_ids — and then silenced "you are editing the recorded
|
||||||
|
file right now", the one claim that must fire at the moment of change. The
|
||||||
|
sync class dedups only against its own channel."""
|
||||||
|
from scribe.services import plugin_context as pc
|
||||||
|
search = AsyncMock(return_value=[])
|
||||||
|
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \
|
||||||
|
patch.object(pc.snippets_svc, "list_snippets",
|
||||||
|
AsyncMock(return_value=([_snippet_item(12, "seen as reuse already")], 1))), \
|
||||||
|
patch.object(pc, "semantic_search_notes", search), \
|
||||||
|
patch.object(pc, "record_retrieval", MagicMock()), \
|
||||||
|
patch.object(pc, "owner_names_for", AsyncMock(return_value={})):
|
||||||
|
out = await pc.build_write_path_hint(
|
||||||
|
1, "src/x.py", code=REAL_CODE, exclude_ids=[12],
|
||||||
|
)
|
||||||
|
assert out["sync_note_ids"] == [12]
|
||||||
|
assert "[records this file]" in out["context"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_dedup_silences_a_sync_nudge_already_shown():
|
||||||
|
"""Once the session has been told "snippet #12 records this file", every
|
||||||
|
further edit of the same file stays quiet about it — on every arm: the
|
||||||
|
directory query and the semantic arm would both re-surface it otherwise."""
|
||||||
|
from scribe.services import plugin_context as pc
|
||||||
|
|
||||||
|
async def _listing(uid, **kw):
|
||||||
|
# Recorded at the exact file, so it matches the file AND dir queries.
|
||||||
|
return ([_snippet_item(12, "already nudged")], 1)
|
||||||
|
|
||||||
|
search = AsyncMock(return_value=[])
|
||||||
|
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \
|
||||||
|
patch.object(pc.snippets_svc, "list_snippets", _listing), \
|
||||||
|
patch.object(pc, "semantic_search_notes", search), \
|
||||||
|
patch.object(pc, "record_retrieval", MagicMock()):
|
||||||
|
out = await pc.build_write_path_hint(
|
||||||
|
1, "src/x.py", code=REAL_CODE, exclude_sync_ids=[12],
|
||||||
|
)
|
||||||
|
assert out["note_ids"] == [] and out["sync_note_ids"] == []
|
||||||
|
assert "#12" not in out["context"]
|
||||||
|
assert 12 in search.await_args.kwargs["exclude_ids"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_directory_is_only_consulted_when_the_file_leaves_room():
|
async def test_directory_is_only_consulted_when_the_file_leaves_room():
|
||||||
from scribe.services import plugin_context as pc
|
from scribe.services import plugin_context as pc
|
||||||
@@ -267,16 +317,24 @@ async def test_semantic_arm_only_asks_for_the_budget_the_place_arm_left():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_session_dedup_excludes_ids_from_both_arms():
|
async def test_session_dedup_excludes_ids_from_the_reuse_arms():
|
||||||
|
"""exclude_ids governs the REUSE classes — nearby and semantic. (The sync
|
||||||
|
class has its own channel; see the tests above.)"""
|
||||||
from scribe.services import plugin_context as pc
|
from scribe.services import plugin_context as pc
|
||||||
|
|
||||||
|
async def _listing(uid, **kw):
|
||||||
|
# Recorded at a SIBLING file, so it reaches only the directory query.
|
||||||
|
if kw["path"] == "src":
|
||||||
|
return ([_snippet_item(12, "already shown")], 1)
|
||||||
|
return ([], 0)
|
||||||
|
|
||||||
search = AsyncMock(return_value=[])
|
search = AsyncMock(return_value=[])
|
||||||
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \
|
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \
|
||||||
patch.object(pc.snippets_svc, "list_snippets",
|
patch.object(pc.snippets_svc, "list_snippets", _listing), \
|
||||||
AsyncMock(return_value=([_snippet_item(12, "already shown")], 1))), \
|
|
||||||
patch.object(pc, "semantic_search_notes", search), \
|
patch.object(pc, "semantic_search_notes", search), \
|
||||||
patch.object(pc, "record_retrieval", MagicMock()):
|
patch.object(pc, "record_retrieval", MagicMock()):
|
||||||
out = await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE, exclude_ids=[12])
|
out = await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE, exclude_ids=[12])
|
||||||
# The location hit was already surfaced this session → dropped, not repeated.
|
# The nearby hit was already surfaced this session → dropped, not repeated.
|
||||||
assert out["note_ids"] == []
|
assert out["note_ids"] == []
|
||||||
assert 12 in search.await_args.kwargs["exclude_ids"]
|
assert 12 in search.await_args.kwargs["exclude_ids"]
|
||||||
|
|
||||||
@@ -315,6 +373,33 @@ async def test_telemetry_uses_its_own_source():
|
|||||||
assert rec.call_args.kwargs["project_id"] == 4
|
assert rec.call_args.kwargs["project_id"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_surfacing_is_measured_under_its_own_usage_source():
|
||||||
|
"""The sync class's pull-through rate is #2708's scoreboard — whether
|
||||||
|
sessions actually update the record when told they're editing it. Folding
|
||||||
|
it into write_path_place would make that unmeasurable."""
|
||||||
|
from scribe.services import plugin_context as pc
|
||||||
|
|
||||||
|
async def _listing(uid, **kw):
|
||||||
|
if kw["path"] == "src/x.py":
|
||||||
|
return ([_snippet_item(12, "records me")], 1)
|
||||||
|
return ([_snippet_item(9, "sibling")], 1)
|
||||||
|
|
||||||
|
surfaced = MagicMock()
|
||||||
|
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \
|
||||||
|
patch.object(pc.snippets_svc, "list_snippets", _listing), \
|
||||||
|
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])), \
|
||||||
|
patch.object(pc, "record_retrieval", MagicMock()), \
|
||||||
|
patch.object(pc, "record_surfaced", surfaced), \
|
||||||
|
patch.object(pc, "owner_names_for", AsyncMock(return_value={})):
|
||||||
|
await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE)
|
||||||
|
by_source = {
|
||||||
|
c.kwargs["source"]: c.kwargs["note_ids"] for c in surfaced.call_args_list
|
||||||
|
}
|
||||||
|
assert by_source["write_path_sync"] == [12]
|
||||||
|
assert by_source["write_path_place"] == [9]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_config_has_its_own_switch_and_threshold_but_shares_top_k():
|
async def test_config_has_its_own_switch_and_threshold_but_shares_top_k():
|
||||||
from scribe.services import plugin_context as pc
|
from scribe.services import plugin_context as pc
|
||||||
@@ -693,11 +778,11 @@ def test_route_reads_every_arg_the_hook_sends():
|
|||||||
|
|
||||||
from scribe.routes import plugin as routes
|
from scribe.routes import plugin as routes
|
||||||
src = inspect.getsource(routes.write_path_prior_art)
|
src = inspect.getsource(routes.write_path_prior_art)
|
||||||
for arg in ("path", "code", "repo", "project_id", "exclude_ids"):
|
for arg in ("path", "code", "repo", "project_id", "exclude_ids", "exclude_sync_ids"):
|
||||||
assert f'request.args.get("{arg}"' in src, f"route ignores {arg}"
|
assert f'request.args.get("{arg}"' in src, f"route ignores {arg}"
|
||||||
|
|
||||||
hook = HOOK.read_text()
|
hook = HOOK.read_text()
|
||||||
for arg in ("path=", "code=", "repo=", "exclude_ids="):
|
for arg in ("path=", "code=", "repo=", "exclude_ids=", "exclude_sync_ids="):
|
||||||
assert arg in hook, f"hook never sends {arg}"
|
assert arg in hook, f"hook never sends {arg}"
|
||||||
|
|
||||||
|
|
||||||
@@ -788,7 +873,21 @@ def test_plugin_version_bumped_with_the_hook():
|
|||||||
ship."""
|
ship."""
|
||||||
manifest = json.loads((PLUGIN / ".claude-plugin" / "plugin.json").read_text())
|
manifest = json.loads((PLUGIN / ".claude-plugin" / "plugin.json").read_text())
|
||||||
version = tuple(int(p) for p in manifest["version"].split("."))
|
version = tuple(int(p) for p in manifest["version"].split("."))
|
||||||
assert version >= (0, 1, 18)
|
assert version >= (0, 1, 31)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hook_keeps_sync_and_reuse_dedup_apart():
|
||||||
|
"""#2708's dedup audit, pinned: the hook holds TWO per-session id files and
|
||||||
|
feeds each its own class — sync ids (snippets recording the edited file) to
|
||||||
|
the sync file, the rest to the reuse file. One shared file is exactly the
|
||||||
|
bug this replaced: a reuse hint early in the session silencing the record-
|
||||||
|
sync nudge when the recorded file is edited later."""
|
||||||
|
src = HOOK.read_text()
|
||||||
|
assert ".sync.ids" in src # its own state file
|
||||||
|
assert "exclude_sync_ids=" in src # its own query channel
|
||||||
|
# The reuse file must NOT swallow sync ids — the write-back subtracts them.
|
||||||
|
assert "(.note_ids // []) - (.sync_note_ids // [])" in src
|
||||||
|
assert "(.sync_note_ids // [])[]?" in src
|
||||||
|
|
||||||
|
|
||||||
def _hook_runtime_env():
|
def _hook_runtime_env():
|
||||||
|
|||||||
Reference in New Issue
Block a user