feat(ledger): write-path stamping — a pulled canon the session then instantiates lands as a hook instance row (#2791, milestone 294 step 5)
CI & Build / Plugin hooks (push) Failing after 2s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Failing after 28s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 39s
CI & Build / Plugin hooks (push) Failing after 2s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Failing after 28s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 39s
The prior-art hook now names the shapes being written (shapes=kind:name — every definition in the payload, or the one enclosing an Edit found by walking the file upward) and the server stamps them as instance rows when the session PULLED a snippet inside PULL_WINDOW that the payload references by symbol or that the semantic arm scored for this very payload. classified_by=hook, evidence in reason; never overrides a judgment or a canonical row, overridable by classify_shapes. Offered-but-unopened stamps nothing. Pulled-and-already-seen snippets stay in the semantic query as evidence without re-entering the deduped menu. A brand-new shape gets a provisional row the next sync confirms or vanishes. Read-scoped keys get the hint, never the stamp. Plugin 0.1.34. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.33",
|
"version": "0.1.34",
|
||||||
"author": { "name": "Bryan Van Deusen" },
|
"author": { "name": "Bryan Van Deusen" },
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"scribe": {
|
"scribe": {
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
# on an instance with no forge connection (decision #2707). Everything else is
|
# on an instance with no forge connection (decision #2707). Everything else is
|
||||||
# the REUSE menu. The two dedup separately (see the state files below).
|
# 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
|
# 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
|
||||||
@@ -96,10 +102,14 @@ fi
|
|||||||
# `ReturnType name(...)`) needs a real parser, and `impl` blocks are excluded
|
# `ReturnType name(...)`) needs a real parser, and `impl` blocks are excluded
|
||||||
# because several per type is normal Rust, not duplication.
|
# because several per type is normal Rust, not duplication.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
local_lines=""
|
# kind<TAB>name for each thing a piece of code DEFINES, in source order. One
|
||||||
if [ -n "$repo_root" ] && [ -n "$code" ]; then
|
# program, two consumers: the local duplicate arm (every definition in the
|
||||||
# kind<TAB>name for each thing this payload DEFINES.
|
# payload) and the ledger feed (#2791, below: the definitions being written,
|
||||||
names=$(printf '%s' "$code" | awk '
|
# 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,
|
# CSS class definition: .name { or .name,
|
||||||
if (match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/)) {
|
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
|
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
|
while IFS=$'\t' read -r kind name; do
|
||||||
[ -n "${name:-}" ] || continue
|
[ -n "${name:-}" ] || continue
|
||||||
case "$kind" in
|
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}"
|
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
|
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:-}}
|
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
||||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||||
# Guard against an unexpanded ${...} placeholder arriving as a literal.
|
# Guard against an unexpanded ${...} placeholder arriving as a literal.
|
||||||
@@ -230,7 +280,7 @@ fi
|
|||||||
# 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}${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=""
|
context=""
|
||||||
if [ -n "$body" ]; then
|
if [ -n "$body" ]; then
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ through recall/auto-inject; this skill is the active reflex around that.
|
|||||||
to ask both at once.
|
to ask both at once.
|
||||||
- If a snippet fits, pull it in full with `get_snippet(id)` and reuse it — its
|
- 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.
|
`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
|
- If auto-inject already surfaced a snippet title that looks relevant, that's
|
||||||
your cue to `get_snippet` it rather than start from scratch.
|
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
|
- **Prior art offered beside a write is not noise — read it.** When Scribe notes
|
||||||
|
|||||||
@@ -34,6 +34,23 @@ row carries a status:
|
|||||||
nothing. Rows, never prose — a consumer list in a note or verification
|
nothing. Rows, never prose — a consumer list in a note or verification
|
||||||
detail cannot be sorted, queried, or diffed.
|
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
|
## The derive-first rule
|
||||||
|
|
||||||
N same-shaped occurrences matching **no** recorded canon is never N loose
|
N same-shaped occurrences matching **no** recorded canon is never N loose
|
||||||
|
|||||||
@@ -77,10 +77,17 @@ async def list_shapes(
|
|||||||
limit/offset: page through big ledgers (limit caps at 500).
|
limit/offset: page through big ledgers (limit caps at 500).
|
||||||
|
|
||||||
Returns {"shapes": [...], "total": N} — total counts every match, not
|
Returns {"shapes": [...], "total": N} — total counts every match, not
|
||||||
just this page. Classify what you can judge with classify_shapes; a
|
just this page. Each row's `classified_by` says who judged: agent /
|
||||||
repeating shape with NO recorded canon is a derive-one-first moment
|
audit / import are judgments; `mechanical` is the canonical stamp the
|
||||||
(consolidate onto a reference, create_snippet it, then classify the
|
sync applies; `hook` is write-path EVIDENCE (#2791) — the session pulled
|
||||||
rest against it), never N loose classifications.
|
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()
|
uid = current_user_id()
|
||||||
rows, total = await shape_ledger_svc.list_project_shapes(
|
rows, total = await shape_ledger_svc.list_project_shapes(
|
||||||
|
|||||||
@@ -127,6 +127,15 @@ async def write_path_prior_art():
|
|||||||
surfaced. A separate channel on purpose: a reuse
|
surfaced. A separate channel on purpose: a reuse
|
||||||
hint shown early must not suppress the record-sync
|
hint shown early must not suppress the record-sync
|
||||||
nudge when the recorded file is edited later.
|
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()
|
path = (request.args.get("path") or "").strip()
|
||||||
code = request.args.get("code") or ""
|
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(",")
|
int(p) for p in (request.args.get("exclude_sync_ids") or "").split(",")
|
||||||
if p.strip().isdigit()
|
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(
|
result = await plugin_ctx_svc.build_write_path_hint(
|
||||||
g.user.id, path, code=code, project_id=project_id,
|
g.user.id, path, code=code, project_id=project_id,
|
||||||
exclude_ids=exclude_ids, exclude_sync_ids=exclude_sync_ids,
|
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)
|
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")
|
@plugin_bp.get("/processes")
|
||||||
@login_required
|
@login_required
|
||||||
async def process_manifest():
|
async def process_manifest():
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from scribe.services import knowledge as knowledge_svc
|
|||||||
from scribe.services import notes as notes_svc
|
from scribe.services import notes as notes_svc
|
||||||
from scribe.services import projects as projects_svc
|
from scribe.services import projects as projects_svc
|
||||||
from scribe.services import rulebooks as rulebooks_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 import snippets as snippets_svc
|
||||||
from scribe.services.access import label_shared_items, owner_names_for
|
from scribe.services.access import label_shared_items, owner_names_for
|
||||||
from scribe.services.embeddings import semantic_search_notes
|
from scribe.services.embeddings import semantic_search_notes
|
||||||
@@ -703,6 +704,8 @@ async def build_write_path_hint(
|
|||||||
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,
|
exclude_sync_ids: list[int] | None = None,
|
||||||
|
stamp_shapes: list[tuple[str, str]] | None = None,
|
||||||
|
repo_key: str = "",
|
||||||
) -> 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.
|
||||||
|
|
||||||
@@ -749,9 +752,20 @@ async def build_write_path_hint(
|
|||||||
un-scored surfacing now has its own home: every arm emits note_usage_events,
|
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
|
tagged 'write_path_sync' vs 'write_path_place' vs 'write_path_semantic', so
|
||||||
each claim's pull-through rate is measurable on its own.
|
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)
|
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()
|
path = (path or "").strip()
|
||||||
if not cfg["enabled"] or not path:
|
if not cfg["enabled"] or not path:
|
||||||
return empty
|
return empty
|
||||||
@@ -800,6 +814,15 @@ async def build_write_path_hint(
|
|||||||
seen.add(nid)
|
seen.add(nid)
|
||||||
placed.append(("nearby", item))
|
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 ---
|
# --- arm 2: by meaning ---
|
||||||
scored: list[tuple[str, dict]] = []
|
scored: list[tuple[str, dict]] = []
|
||||||
remaining = top_k - len(synced) - len(placed)
|
remaining = top_k - len(synced) - len(placed)
|
||||||
@@ -821,12 +844,15 @@ async def build_write_path_hint(
|
|||||||
query = concept_query(query) or query
|
query = concept_query(query) or query
|
||||||
if remaining > 0 and query:
|
if remaining > 0 and query:
|
||||||
t0 = time.perf_counter()
|
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(
|
hits = await semantic_search_notes(
|
||||||
user_id, query,
|
user_id, query,
|
||||||
limit=remaining,
|
limit=remaining + len(pulled_seen),
|
||||||
threshold=cfg["threshold"],
|
threshold=cfg["threshold"],
|
||||||
project_id=scope_project,
|
project_id=scope_project,
|
||||||
exclude_ids=seen,
|
exclude_ids=seen - pulled_seen,
|
||||||
# Snippets AND recorded experience (#2246). This arm was
|
# Snippets AND recorded experience (#2246). This arm was
|
||||||
# snippets-only, which is auto-inject's mistake inverted: an issue
|
# snippets-only, which is auto-inject's mistake inverted: an issue
|
||||||
# saying "we tried this and it deadlocked", or a dev-log recording
|
# 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.
|
# the browse scope and never surfaces a one-to-one direct share.
|
||||||
scope="browse",
|
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(
|
record_retrieval(
|
||||||
user_id=user_id, source="write_path", query=query,
|
user_id=user_id, source="write_path", query=query,
|
||||||
threshold=cfg["threshold"], limit=remaining,
|
threshold=cfg["threshold"], limit=remaining,
|
||||||
@@ -879,7 +910,20 @@ async def build_write_path_hint(
|
|||||||
))
|
))
|
||||||
|
|
||||||
menu = (placed + scored)[:max(0, top_k - len(synced))]
|
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
|
return empty
|
||||||
|
|
||||||
owners = await owner_names_for({
|
owners = await owner_names_for({
|
||||||
@@ -943,6 +987,9 @@ async def build_write_path_hint(
|
|||||||
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))
|
||||||
|
|
||||||
|
if stamped:
|
||||||
|
lines.append(_stamp_line(path, stamped))
|
||||||
|
|
||||||
# Split by arm, which is the whole reason this table exists. The place arm
|
# 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
|
# 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
|
||||||
@@ -964,9 +1011,29 @@ async def build_write_path_hint(
|
|||||||
"note_ids": note_ids,
|
"note_ids": note_ids,
|
||||||
"sync_note_ids": sync_note_ids,
|
"sync_note_ids": sync_note_ids,
|
||||||
"config": cfg,
|
"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]:
|
async def _topic_titles(topic_ids: set[int]) -> dict[int, str]:
|
||||||
"""Map topic_id -> title for the given ids (live topics only)."""
|
"""Map topic_id -> title for the given ids (live topics only)."""
|
||||||
if not topic_ids:
|
if not topic_ids:
|
||||||
|
|||||||
@@ -21,13 +21,17 @@ an extracted shape.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from scribe.models import async_session
|
from scribe.models import async_session
|
||||||
from scribe.models.code_shape import CodeShape
|
from scribe.models.code_shape import CodeShape
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Statuses whose meaning requires a snippet target.
|
# Statuses whose meaning requires a snippet target.
|
||||||
_NEEDS_TARGET = ("canonical", "instance", "variant")
|
_NEEDS_TARGET = ("canonical", "instance", "variant")
|
||||||
|
|
||||||
@@ -408,3 +412,187 @@ async def snippet_consumers(user_id: int, note_id: int) -> dict:
|
|||||||
_consumer_dict(row)
|
_consumer_dict(row)
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- write-path stamping (#2791): hook evidence lands as rows ----------------
|
||||||
|
#
|
||||||
|
# The write-path hook (plugin/hooks/scribe_prior_art.sh) fires on every
|
||||||
|
# Write/Edit and already carries the two halves of a consumer-map row: the
|
||||||
|
# file being written and the definitions in (or enclosing) the payload. What
|
||||||
|
# it could not say on its own is WHICH canon the session is instantiating.
|
||||||
|
# The pull stream answers that: a snippet the session opened in full
|
||||||
|
# (get_snippet) and is now writing code that references or resembles is being
|
||||||
|
# reused — and a reused canon's call site is an `instance` (note 2786).
|
||||||
|
#
|
||||||
|
# The rule, deliberately two-sided so it cannot fire on noise:
|
||||||
|
# PULLED — a PULLED usage event by this user inside PULL_WINDOW. Offered-
|
||||||
|
# but-ignored (surfaced, never opened) stamps nothing.
|
||||||
|
# IN PLAY — the payload references the snippet's symbol by name, or the
|
||||||
|
# semantic arm scored it above the write-path threshold for this
|
||||||
|
# very payload. Either is evidence; the pull alone is not.
|
||||||
|
# Both hold → every shape the hook named at that path, of the snippet's kind,
|
||||||
|
# becomes instance-of-N with classified_by="hook" and the evidence as reason.
|
||||||
|
#
|
||||||
|
# A hook row is EVIDENCE, not judgment: it only ever lands on rows nobody has
|
||||||
|
# judged (unclassified) or rows an earlier hook stamped, never on a canonical
|
||||||
|
# row or an agent/audit/import judgment. Re-judge with classify_shapes.
|
||||||
|
|
||||||
|
# "The write path actually pulled it": a working session's reach. The
|
||||||
|
# precision comes from the in-play test above, not from this window.
|
||||||
|
PULL_WINDOW = timedelta(hours=6)
|
||||||
|
|
||||||
|
def snippet_kind(symbol: str, language: str) -> str:
|
||||||
|
"""The ledger kind a snippet's reference belongs to — "css" when its
|
||||||
|
symbol is a class selector (or it is a stylesheet with no symbol),
|
||||||
|
else "sym"."""
|
||||||
|
sym = (symbol or "").strip()
|
||||||
|
if sym.startswith("."):
|
||||||
|
return "css"
|
||||||
|
if not sym and (language or "").strip().lower() in ("css", "scss", "sass", "less"):
|
||||||
|
return "css"
|
||||||
|
return "sym"
|
||||||
|
|
||||||
|
|
||||||
|
def references_symbol(code: str, symbol: str, kind: str) -> bool:
|
||||||
|
"""Does this payload name the snippet's symbol? Word-bounded so `confirm`
|
||||||
|
never claims `confirmed`; a CSS class matches as `.btn` or inside a class
|
||||||
|
attribute (`btn btn-primary`), dashes counting as part of the name."""
|
||||||
|
sym = _norm_symbol(symbol or "")
|
||||||
|
if not sym or not code:
|
||||||
|
return False
|
||||||
|
if kind == "css":
|
||||||
|
pattern = rf"(?<![\w-]){re.escape(sym)}(?![\w-])"
|
||||||
|
else:
|
||||||
|
pattern = rf"(?<![\w$]){re.escape(sym)}(?![\w$])"
|
||||||
|
return re.search(pattern, code) is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def recent_pulls(user_id: int, *, window: timedelta = PULL_WINDOW) -> dict[int, datetime]:
|
||||||
|
"""{note_id: last pulled at} for every note this user opened in full
|
||||||
|
inside ``window`` — the "actually pulled it" half of the stamping rule.
|
||||||
|
Reads the usage telemetry table; an unreadable table means no evidence."""
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
from scribe.models.note_usage import PULLED, NoteUsageEvent
|
||||||
|
|
||||||
|
since = datetime.now(timezone.utc) - window
|
||||||
|
try:
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = await session.execute(
|
||||||
|
select(NoteUsageEvent.note_id, func.max(NoteUsageEvent.created_at))
|
||||||
|
.where(
|
||||||
|
NoteUsageEvent.user_id == user_id,
|
||||||
|
NoteUsageEvent.event == PULLED,
|
||||||
|
NoteUsageEvent.created_at >= since,
|
||||||
|
)
|
||||||
|
.group_by(NoteUsageEvent.note_id)
|
||||||
|
)
|
||||||
|
return {int(nid): ts for nid, ts in rows.all()}
|
||||||
|
except Exception:
|
||||||
|
logger.warning("recent_pulls read failed — no hook stamping this write", exc_info=True)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
async def stamp_write_path_instances(
|
||||||
|
user_id: int,
|
||||||
|
project_id: int,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
shapes: list[tuple[str, str]],
|
||||||
|
code: str,
|
||||||
|
pulled: dict[int, datetime],
|
||||||
|
resembles: dict[int, float] | None = None,
|
||||||
|
repo_key: str = "",
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Land hook evidence as `instance` rows for the shapes being written.
|
||||||
|
|
||||||
|
``shapes`` is the hook's (kind, name) list for ``path``; ``pulled`` is
|
||||||
|
recent_pulls(); ``resembles`` maps snippet ids the semantic arm scored
|
||||||
|
for this payload to their score. Returns the rows stamped, each
|
||||||
|
{path, symbol, kind, snippet_id, reason} — empty in the common case.
|
||||||
|
|
||||||
|
A shape the ledger has no live row for yet (it is being written right
|
||||||
|
now) gets a PROVISIONAL row under ``repo_key`` — first/last-seen unset —
|
||||||
|
so the stamp is not lost to the next sync, which either confirms the
|
||||||
|
shape (sets its seen marker) or stamps it vanished. No repo key → only
|
||||||
|
existing rows are stamped.
|
||||||
|
|
||||||
|
When more than one pulled snippet is in play for a shape, a by-name
|
||||||
|
reference beats resemblance and the most recent pull breaks ties: a row
|
||||||
|
holds one canon (the known model limit logged on #2790).
|
||||||
|
"""
|
||||||
|
from scribe.services import access
|
||||||
|
from scribe.services import snippets as snippets_svc
|
||||||
|
from scribe.services.snippets import snippet_fields
|
||||||
|
|
||||||
|
resembles = resembles or {}
|
||||||
|
path = (path or "").strip()
|
||||||
|
wanted = [(k, n.strip()) for k, n in shapes if k in ("css", "sym") and n.strip()]
|
||||||
|
if not project_id or not path or not wanted or not pulled:
|
||||||
|
return []
|
||||||
|
if not await access.can_write_project(user_id, project_id):
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Which pulled canons are in play for this payload, by kind, ranked.
|
||||||
|
in_play: dict[str, list[tuple[int, datetime, int, str]]] = {}
|
||||||
|
for sid, pulled_at in pulled.items():
|
||||||
|
note = await snippets_svc.get_snippet(user_id, sid)
|
||||||
|
if note is None:
|
||||||
|
continue
|
||||||
|
fields = snippet_fields(note)
|
||||||
|
symbol = fields.get("symbol") or ""
|
||||||
|
kind = snippet_kind(symbol, fields.get("language") or "")
|
||||||
|
if references_symbol(code, symbol, kind):
|
||||||
|
rank, why = 2, f"hook: pulled #{sid}; payload references `{_norm_symbol(symbol)}`"
|
||||||
|
elif sid in resembles:
|
||||||
|
rank, why = 1, f"hook: pulled #{sid}; payload resembles it ({resembles[sid]:.2f})"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
in_play.setdefault(kind, []).append((rank, pulled_at, sid, why))
|
||||||
|
if not in_play:
|
||||||
|
return []
|
||||||
|
for bucket in in_play.values():
|
||||||
|
bucket.sort(key=lambda t: (t[0], t[1]), reverse=True)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
stamped: list[dict] = []
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(CodeShape).where(
|
||||||
|
CodeShape.project_id == project_id,
|
||||||
|
CodeShape.path == path,
|
||||||
|
CodeShape.vanished_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
by_key = {(r.symbol, r.kind): r for r in rows}
|
||||||
|
for kind, name in wanted:
|
||||||
|
bucket = in_play.get(kind)
|
||||||
|
if not bucket:
|
||||||
|
continue
|
||||||
|
_rank, _at, sid, why = bucket[0]
|
||||||
|
row = by_key.get((name, kind))
|
||||||
|
if row is None:
|
||||||
|
if not repo_key:
|
||||||
|
continue
|
||||||
|
row = CodeShape(
|
||||||
|
project_id=project_id, repo_key=repo_key,
|
||||||
|
path=path, symbol=name, kind=kind,
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
by_key[(name, kind)] = row
|
||||||
|
elif not (row.status == "unclassified" or row.classified_by == "hook"):
|
||||||
|
continue # a judgment — or the canon itself — stands
|
||||||
|
row.status = "instance"
|
||||||
|
row.snippet_id = sid
|
||||||
|
row.reason = why
|
||||||
|
row.classified_by = "hook"
|
||||||
|
row.classified_at = now
|
||||||
|
stamped.append({
|
||||||
|
"path": path, "symbol": name, "kind": kind,
|
||||||
|
"snippet_id": sid, "reason": why,
|
||||||
|
})
|
||||||
|
if stamped:
|
||||||
|
await session.commit()
|
||||||
|
return stamped
|
||||||
|
|||||||
@@ -219,3 +219,133 @@ async def test_sync_refiles_rows_whose_snippet_was_purged(seeded):
|
|||||||
))).scalar_one()
|
))).scalar_one()
|
||||||
assert row.status == "unclassified"
|
assert row.status == "unclassified"
|
||||||
assert row.snippet_id is None
|
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
|
||||||
|
|||||||
@@ -82,3 +82,56 @@ def test_classify_and_list_are_mounted_as_mcp_tools():
|
|||||||
mcp = build_mcp_server()
|
mcp = build_mcp_server()
|
||||||
for name in ("classify_shapes", "list_shapes", "refresh_pattern_coverage"):
|
for name in ("classify_shapes", "list_shapes", "refresh_pattern_coverage"):
|
||||||
assert mcp._tool_manager.get_tool(name) is not None
|
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 = '<button class="btn btn-primary">Go</button>'
|
||||||
|
assert ref(html, ".btn-primary", "css")
|
||||||
|
assert ref(html, "btn-primary", "css")
|
||||||
|
assert ref(".btn-primary { color: red }", ".btn-primary", "css")
|
||||||
|
assert not ref('<button class="btn-primary">', ".btn", "css")
|
||||||
|
assert ref('<button class="btn-primary btn">', ".btn", "css")
|
||||||
|
|
||||||
|
|
||||||
|
def test_snippet_kind_reads_the_symbol_then_the_language():
|
||||||
|
from scribe.services.shape_ledger import snippet_kind
|
||||||
|
|
||||||
|
assert snippet_kind(".btn-primary", "css") == "css"
|
||||||
|
assert snippet_kind(".btn-primary", "") == "css"
|
||||||
|
assert snippet_kind("confirmed", "typescript") == "sym"
|
||||||
|
assert snippet_kind("", "scss") == "css" # whole-stylesheet record
|
||||||
|
assert snippet_kind("", "python") == "sym"
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_shapes_param_parses_capped_and_deduped():
|
||||||
|
from scribe.routes.plugin import _SHAPES_CAP, _parse_shapes
|
||||||
|
|
||||||
|
assert _parse_shapes("css:btn-primary,sym:onTrash") == [
|
||||||
|
("css", "btn-primary"), ("sym", "onTrash"),
|
||||||
|
]
|
||||||
|
assert _parse_shapes(" sym:a , sym:a ,bogus:x,sym:,:,") == [("sym", "a")]
|
||||||
|
assert _parse_shapes("") == []
|
||||||
|
many = ",".join(f"sym:f{i}" for i in range(40))
|
||||||
|
assert len(_parse_shapes(many)) == _SHAPES_CAP
|
||||||
|
|
||||||
|
|
||||||
|
def test_hook_is_a_server_internal_classifier():
|
||||||
|
"""`hook` is in the status vocabulary but NOT a via a caller may claim —
|
||||||
|
a classify_shapes call saying via="hook" would launder judgment as
|
||||||
|
evidence (the reverse of the stamping rule's point)."""
|
||||||
|
from scribe.services.shape_ledger import _CALLER_VIAS
|
||||||
|
|
||||||
|
assert "hook" in SHAPE_CLASSIFIERS
|
||||||
|
assert "hook" not in _CALLER_VIAS
|
||||||
|
|||||||
@@ -778,11 +778,13 @@ 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", "exclude_sync_ids"):
|
for arg in ("path", "code", "repo", "project_id", "exclude_ids",
|
||||||
|
"exclude_sync_ids", "shapes"):
|
||||||
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=", "exclude_sync_ids="):
|
for arg in ("path=", "code=", "repo=", "exclude_ids=", "exclude_sync_ids=",
|
||||||
|
"shapes="):
|
||||||
assert arg in hook, f"hook never sends {arg}"
|
assert arg in hook, f"hook never sends {arg}"
|
||||||
|
|
||||||
|
|
||||||
@@ -1009,3 +1011,240 @@ def test_local_arm_finds_duplicates_in_every_language_family(
|
|||||||
ctx = json.loads(out.stdout)["hookSpecificOutput"]["additionalContext"]
|
ctx = json.loads(out.stdout)["hookSpecificOutput"]["additionalContext"]
|
||||||
assert "already defined" in ctx
|
assert "already defined" in ctx
|
||||||
assert "create_snippet" in ctx
|
assert "create_snippet" in ctx
|
||||||
|
|
||||||
|
|
||||||
|
# --- #2791: the write-path feed — hook evidence lands as ledger rows ----------
|
||||||
|
|
||||||
|
|
||||||
|
def _ts():
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stamping_needs_named_shapes_and_a_recent_pull():
|
||||||
|
"""Offered-but-ignored stamps nothing: without a PULLED event there is no
|
||||||
|
evidence, and without the hook naming shapes there is nothing to stamp.
|
||||||
|
Neither case may even read the pull stream."""
|
||||||
|
from scribe.services import plugin_context as pc
|
||||||
|
pulls = AsyncMock(return_value={})
|
||||||
|
stamp = AsyncMock(return_value=[])
|
||||||
|
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \
|
||||||
|
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \
|
||||||
|
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])), \
|
||||||
|
patch.object(pc, "record_retrieval", MagicMock()), \
|
||||||
|
patch.object(pc.shape_ledger_svc, "recent_pulls", pulls), \
|
||||||
|
patch.object(pc.shape_ledger_svc, "stamp_write_path_instances", stamp):
|
||||||
|
out = await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE, project_id=4)
|
||||||
|
assert out["stamped"] == []
|
||||||
|
pulls.assert_not_awaited() # no shapes → no read
|
||||||
|
out = await pc.build_write_path_hint(
|
||||||
|
1, "src/x.py", code=REAL_CODE, project_id=4,
|
||||||
|
stamp_shapes=[("sym", "debounce")],
|
||||||
|
)
|
||||||
|
pulls.assert_awaited_once()
|
||||||
|
stamp.assert_not_awaited() # shapes, but no pull
|
||||||
|
assert out["stamped"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_pulled_snippet_already_seen_is_evidence_not_menu():
|
||||||
|
"""The pulled-then-written flow IS the dedup-excluded flow: the hint offered
|
||||||
|
#7 earlier (so it sits in exclude_ids), the session pulled it, and now
|
||||||
|
writes code resembling it. #7 must be scored for this payload — and handed
|
||||||
|
to the stamp as resemblance — without being re-listed in the menu."""
|
||||||
|
from scribe.services import plugin_context as pc
|
||||||
|
search = AsyncMock(return_value=[(0.91, _note(7, "pulled")), (0.80, _note(8, "fresh"))])
|
||||||
|
stamp = AsyncMock(return_value=[{
|
||||||
|
"path": "src/x.py", "symbol": "debounce", "kind": "sym",
|
||||||
|
"snippet_id": 7, "reason": "hook: pulled #7; payload resembles it (0.91)",
|
||||||
|
}])
|
||||||
|
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg(top_k=3))), \
|
||||||
|
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \
|
||||||
|
patch.object(pc, "semantic_search_notes", search), \
|
||||||
|
patch.object(pc, "record_retrieval", MagicMock()), \
|
||||||
|
patch.object(pc, "owner_names_for", AsyncMock(return_value={})), \
|
||||||
|
patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={7: _ts()})), \
|
||||||
|
patch.object(pc.shape_ledger_svc, "stamp_write_path_instances", stamp):
|
||||||
|
out = await pc.build_write_path_hint(
|
||||||
|
1, "src/x.py", code=REAL_CODE, project_id=4, exclude_ids=[7],
|
||||||
|
stamp_shapes=[("sym", "debounce")], repo_key="git.example.com/a/b",
|
||||||
|
)
|
||||||
|
# The query kept #7 eligible (and widened the budget by one for it)...
|
||||||
|
kw = search.call_args.kwargs
|
||||||
|
assert 7 not in kw["exclude_ids"]
|
||||||
|
assert kw["limit"] == 4
|
||||||
|
# ...but the menu still honours the session dedup.
|
||||||
|
assert out["note_ids"] == [8]
|
||||||
|
assert "#7" not in "\n".join(
|
||||||
|
line for line in out["context"].splitlines() if "[similar" in line
|
||||||
|
)
|
||||||
|
# The stamp saw the pull and the resemblance score for this payload.
|
||||||
|
skw = stamp.call_args.kwargs
|
||||||
|
assert skw["pulled"] == {7: skw["pulled"][7]}
|
||||||
|
assert skw["resembles"] == {7: 0.91}
|
||||||
|
assert skw["shapes"] == [("sym", "debounce")]
|
||||||
|
assert skw["repo_key"] == "git.example.com/a/b"
|
||||||
|
assert out["stamped"][0]["snippet_id"] == 7
|
||||||
|
# And the session is told what landed, with the way to correct it.
|
||||||
|
assert "Shape accounting" in out["context"]
|
||||||
|
assert "`debounce` → instance of #7" in out["context"]
|
||||||
|
assert "classify_shapes" in out["context"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_stamp_renders_even_when_the_hint_is_otherwise_silent():
|
||||||
|
"""After dedup the common case is an empty hint; the stamp must still run
|
||||||
|
and still be reported — silence about accounting is how hook rows would
|
||||||
|
become invisible."""
|
||||||
|
from scribe.services import plugin_context as pc
|
||||||
|
stamped = [{"path": "web/b.css", "symbol": "btn-primary", "kind": "css",
|
||||||
|
"snippet_id": 5, "reason": "hook: pulled #5; payload references `btn-primary`"}]
|
||||||
|
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \
|
||||||
|
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \
|
||||||
|
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])), \
|
||||||
|
patch.object(pc, "record_retrieval", MagicMock()), \
|
||||||
|
patch.object(pc, "owner_names_for", AsyncMock(return_value={})), \
|
||||||
|
patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={5: _ts()})), \
|
||||||
|
patch.object(pc.shape_ledger_svc, "stamp_write_path_instances",
|
||||||
|
AsyncMock(return_value=stamped)):
|
||||||
|
out = await pc.build_write_path_hint(
|
||||||
|
1, "web/b.css", code=".btn-primary { color: red; }" * 4, project_id=4,
|
||||||
|
stamp_shapes=[("css", "btn-primary")],
|
||||||
|
)
|
||||||
|
assert out["note_ids"] == []
|
||||||
|
assert out["stamped"] == stamped
|
||||||
|
assert "`.btn-primary` → instance of #5" in out["context"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_failing_stamp_does_not_sink_the_hint():
|
||||||
|
from scribe.services import plugin_context as pc
|
||||||
|
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \
|
||||||
|
patch.object(pc.snippets_svc, "list_snippets",
|
||||||
|
AsyncMock(return_value=([_snippet_item(12, "records me")], 1))), \
|
||||||
|
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])), \
|
||||||
|
patch.object(pc, "record_retrieval", MagicMock()), \
|
||||||
|
patch.object(pc, "owner_names_for", AsyncMock(return_value={})), \
|
||||||
|
patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={12: _ts()})), \
|
||||||
|
patch.object(pc.shape_ledger_svc, "stamp_write_path_instances",
|
||||||
|
AsyncMock(side_effect=RuntimeError("ledger down"))):
|
||||||
|
out = await pc.build_write_path_hint(
|
||||||
|
1, "src/x.py", code=REAL_CODE, project_id=4, stamp_shapes=[("sym", "f")],
|
||||||
|
)
|
||||||
|
assert out["sync_note_ids"] == [12]
|
||||||
|
assert out["stamped"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_stamps_only_for_a_caller_allowed_to_write():
|
||||||
|
"""A read-scoped key gets the hint — every plugin hook works on a read key
|
||||||
|
— but a GET must never change accounting for it. The route passes the
|
||||||
|
shapes through only when the key is write-scoped (or it's a session)."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from scribe.routes import plugin as routes
|
||||||
|
src = inspect.getsource(routes.write_path_prior_art)
|
||||||
|
assert 'request.args.get("shapes"' in src
|
||||||
|
assert '== "write"' in src
|
||||||
|
assert "stamp_shapes=shapes if may_stamp else None" in src
|
||||||
|
assert "normalize_repo_key(repo)" in src
|
||||||
|
hook = HOOK.read_text()
|
||||||
|
assert "&shapes=" in hook
|
||||||
|
|
||||||
|
|
||||||
|
def test_hook_names_the_shapes_being_written():
|
||||||
|
"""The feed's two inputs: every definition in the payload, or — for an Edit
|
||||||
|
that changes a body, not a signature — the definition enclosing the edit,
|
||||||
|
found by walking the target file upward from the edited lines."""
|
||||||
|
src = HOOK.read_text()
|
||||||
|
assert "scribe_defs()" in src # one extractor, two consumers
|
||||||
|
assert ".tool_input.old_string" in src # the Edit's anchor
|
||||||
|
assert "| tac | scribe_defs | head -1" in src # nearest definition above
|
||||||
|
# The ledger feed sends NAMES, never bodies, and stays on the one GET.
|
||||||
|
assert src.count("/api/plugin/prior-art?") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def _run_hook_against_sink(tmp_path, payload):
|
||||||
|
"""Run the hook with SCRIBE_URL pointed at a throwaway local listener and
|
||||||
|
return the query the hook sent. Lets the shell be tested end to end —
|
||||||
|
the extraction, the encoding, the URL — without a Scribe instance."""
|
||||||
|
import http.server
|
||||||
|
import threading
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
seen: dict = {}
|
||||||
|
|
||||||
|
class _Sink(http.server.BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
seen.update(urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query))
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"context":"","note_ids":[]}')
|
||||||
|
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
server = http.server.HTTPServer(("127.0.0.1", 0), _Sink)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{server.server_port}")
|
||||||
|
out = subprocess.run(
|
||||||
|
["bash", str(HOOK)], input=json.dumps(payload),
|
||||||
|
capture_output=True, text=True, env=env,
|
||||||
|
)
|
||||||
|
assert out.returncode == 0, out.stderr
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def test_hook_sends_every_definition_in_a_write(tmp_path):
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir()
|
||||||
|
env = _hook_runtime_env()
|
||||||
|
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
|
||||||
|
seen = _run_hook_against_sink(tmp_path, {
|
||||||
|
"session_id": "s-feed-w", "cwd": str(repo), "tool_name": "Write",
|
||||||
|
"tool_input": {
|
||||||
|
"file_path": str(repo / "new.ts"),
|
||||||
|
"content": "export async function onDelete(): Promise<void> {\n"
|
||||||
|
" const ok = await confirmed({ title: 'x' });\n}\n"
|
||||||
|
".btn-primary {\n color: red;\n}\n",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert seen["path"] == ["new.ts"]
|
||||||
|
assert seen["shapes"] == ["css:btn-primary,sym:onDelete"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_hook_sends_the_enclosing_definition_for_a_body_edit(tmp_path):
|
||||||
|
"""An Edit to the inside of onTrash names no definition itself; the hook
|
||||||
|
must walk the file upward from the edited line and send onTrash."""
|
||||||
|
import shutil
|
||||||
|
if shutil.which("tac") is None:
|
||||||
|
pytest.skip("the enclosing-definition walk needs tac")
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir()
|
||||||
|
env = _hook_runtime_env()
|
||||||
|
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
|
||||||
|
target = repo / "comp.vue"
|
||||||
|
target.write_text(
|
||||||
|
"<script setup lang=\"ts\">\n"
|
||||||
|
"async function onTrash(): Promise<void> {\n"
|
||||||
|
" const ok = await confirmed({ title: 'Move to the trash?' });\n"
|
||||||
|
" if (!ok) return;\n"
|
||||||
|
"}\n"
|
||||||
|
"const other = () => {\n return 1;\n};\n"
|
||||||
|
"</script>\n"
|
||||||
|
)
|
||||||
|
seen = _run_hook_against_sink(tmp_path, {
|
||||||
|
"session_id": "s-feed-e", "cwd": str(repo), "tool_name": "Edit",
|
||||||
|
"tool_input": {
|
||||||
|
"file_path": str(target),
|
||||||
|
"old_string": " if (!ok) return;",
|
||||||
|
"new_string": " if (!ok) return;\n await guarded(() => store.trashNode(id));",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert seen["shapes"] == ["sym:onTrash"]
|
||||||
|
|||||||
Reference in New Issue
Block a user