Commit Graph

1060 Commits

Author SHA1 Message Date
bvandeusen f039a46dae fix(plugin): bump to 0.1.19 so the hook fixes actually reach installs (#83) 2026-07-28 20:19:24 -04:00
bvandeusen 21cb9ee537 fix(plugin): bump to 0.1.19 so the hook fixes actually reach installs
c569cdd rewrote all four hook scripts and did not bump the manifest. The
version is what the installer compares, so `/plugin` reported "already at
the latest version (0.1.18)" and never refreshed its cache — the fix was
on main and unreachable.

Both halves of the install were observably out of step:

  ~/.claude/plugins/marketplaces/scribe-plugin  -> at 3284ac6, fixed
  ~/.claude/plugins/cache/.../scribe/0.1.18/    -> still lowercase env
                                                   vars and `jq -rR`

The clone pulls on its own; the CACHE is what executes, and it is only
re-copied when the version changes. So a plugin change without a bump
ships to the repo and stops there.

This is the #1040 lesson, already recorded in milestone #232's own
verification section ("Any `plugin/` change bumps `plugin.json` in the
same commit") and still missed — the rule was written down and not
followed. Nothing in CI enforces it, which is the same gap as #2204:
`plugin/**` triggers no workflow at all, so neither the missing bump nor
the broken scripts could be caught.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-28 20:19:06 -04:00
bvandeusen 3284ac67dd Drafter hardening (milestone #232) — plugin hook fix, usage signal, drift check, duplicate finder, un-merge (#82)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / integration (push) Successful in 32s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 20s
2026-07-28 20:10:42 -04:00
bvandeusen fe63f3985b feat(snippets): un-merge — reverse one source out of a merged survivor
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 40s
Closes the last of milestone #232. The task said to settle the design
before coding; here is what was settled and why.

THE HAZARD. Restoring a merged-in source from the trash brought the record
back but never stripped its locations off the survivor, so both claimed
the same call sites and the reverse lookup read the duplicate claims as
real. Subtracting blindly is not a fix: a location can arrive from a
source AND genuinely be the survivor's own, and _normalize_locations dedups
them into one, so blind subtraction would strip a call site the survivor
owns. Same problem defeated partial un-merge — `merged_from` recorded ids,
not which locations came from which source.

THE ANSWER. Record per-source attribution AT MERGE TIME, where it is known
exactly: each entry keeps only what that source ADDED, computed
incrementally as sources fold in. Anything the survivor already had, or an
earlier source already brought, is attributed to nobody. Both open
questions fall out of that one change — partial un-merge is exact, and a
survivor-owned location can never be stripped, because it was never
attributed in the first place.

The shape moved from [id] to [{id, locations, tags}]. Free to do: the
corpus holds one snippet and zero merges, so there is no legacy data (rule
#22). A bare int still normalizes to {"id": n} — not legacy tolerance, but
because snippet_fields falls back to PARSING THE BODY when a row has no
`data`, and the body's provenance line can only carry ids. Such an entry
shows history and refuses un-merge with a reason rather than guessing.

WHICH SURFACE. Neither option in the task, quite. Making trash-restore
notice the merge would teach the generic trash path snippet semantics for
one record type. Instead un-merge OWNS the restore: one operation, one
authorization check, trash stays ignorant. Restoring by hand is still
allowed and still leaves both records claiming the same places — so
un-merge treats an already-alive source as the normal case and goes
straight to the subtraction that repairs it. That is the state that
motivated the feature, not an error.

Adds trash.restore_entity(user_id, type, id) — the missing inverse of
delete(), which returns a batch id callers don't keep. Restores the whole
batch, since the batch is the entity plus its cascade.

Refs #2165

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-28 18:42:29 -04:00
bvandeusen 6db791965f feat(snippets): near-duplicate finder — surface the sets worth merging
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 14s
CI & Build / integration (push) Successful in 36s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 44s
#231's premise was unifying reusable things already scattered as one-offs.
The create gate PREVENTS a new duplicate and merge_snippets CURES one you
point it at, but nothing FOUND the duplicates already in the record —
someone had to notice them by hand, which is the exact failure the Drafter
exists to remove.

One indexed self-join over note_embeddings, not an N² Python scan:
pgvector's cosine distance is the same operator semantic search uses, so a
similarity floor is a distance ceiling and the work stays in Postgres.
`left.note_id < right.note_id` yields each unordered pair once and drops
the self-pair that would otherwise dominate the ranking.

Pairs are collapsed into merge SETS by connected components. Transitive on
purpose: A~B plus B~C puts all three together even when A and C don't
directly clear the bar, which is what merge actually does (it folds every
source into one survivor). The cost is that a chain of mild resemblances
can rope in a member that isn't really alike — so the UI presents a set as
a proposal, shows the members, and never merges without a confirm.

Two scope decisions worth naming:

- OWN snippets only. merge_snippets requires one owner across the set, so
  surfacing someone else's would propose a merge that cannot be performed.
  The report is bounded by what the operator can act on, not what they can
  see.

- Threshold defaults to 0.82, LOOSER than the write gate's 0.90, and is a
  setting rather than a constant (rule #25). The gate blocks a create and
  has to be unforgiving of noise; this only suggests a merge under review,
  so it must reach further or it would never surface the pairs the gate
  already let through — which are precisely the ones that accumulated.

Fixes a real bug in the merge flow while wiring the UI: selectedList
filtered the selection against the CURRENT PAGE, and doMerge derives its
source ids from that list. A corpus-wide suggested group with off-page
members would have rendered incomplete and silently merged only the
visible subset. A group under review is now the authority for that list.

Refs #2088

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-28 18:32:40 -04:00
bvandeusen 84c5c0dc81 fix(snippets): satisfy the parity guards the drift check tripped
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 52s
CI & Build / Build & push image (push) Successful in 45s
Two CI failures, both the guards working as intended rather than defects
in them:

- The MCP tool-name manifest and the REST route/service parity lists are
  explicit, so a new capability has to be added to both surfaces or the
  test fails. verify_snippet / verify_snippet_route / record_verification
  added, plus an assertion that the `verification` filter reaches both
  callers — a verdict an agent records must be visible to the human
  looking at the same corpus, or the two surfaces disagree about what's
  rotten (rule #33).

- vue-tsc rejected two object-literal lookups keyed by the status union:
  `status` includes "ok" and "unverified", and an object literal has to
  enumerate every member even just to say "nothing to show for these".
  Typed as Record<string, string>, which is what the ?? "" fallback
  already assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-28 18:24:48 -04:00
bvandeusen 35f3f09d12 feat(snippets): drift check — verify a snippet still matches its source
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Failing after 20s
CI & Build / Python tests (push) Failing after 22s
CI & Build / Build & push image (push) Has been skipped
A recorded snippet points at a repo · path · symbol that WILL rot: files
move, symbols get renamed, implementations diverge from the copy stored
here. Nothing detected any of it, so a record degraded silently from
"canonical reference" to "confidently wrong" — worse than no record, since
it is surfaced with the same authority either way.

WHERE THE CHECK RUNS. Agent-side, which the task flagged as the design
question to settle first. Scribe has no checkout of the operator's repos
and must not acquire one: giving the server repo access would make every
install a credential problem and break instance-agnosticism (rule #115).
The agent already has the working tree, so it does the comparing; the
server remembers the verdict, makes it queryable, and knows when it has
expired. New MCP tool verify_snippet teaches the four-step procedure and
records the result; a REST endpoint mirrors it so the UI can clear a
marker after a manual fix.

WHY THE VERDICT CARRIES A CODE HASH. A verdict describes the code it was
checked against. Invalidating it on edit means deciding which edits count
— a when_to_use tweak shouldn't void a code check, a rewrite must — which
is fiddly and easy to get subtly wrong, and easy for a new write path to
forget entirely. Stamping the verdict with a hash sidesteps all of it: one
whose code_sha no longer matches is self-evidently expired, computed at
read time, no invalidation branch to maintain.

That makes "expired" the interesting filter case. It is not `drifted`
(nothing was found wrong) and not `unverified` (a check did happen), yet
it plainly needs looking at — so `verification=attention` covers both. To
keep that one index-served predicate rather than a post-filter that would
make the pagination total a lie, data now also mirrors the CURRENT code's
fingerprint as data.code_sha, and a jsonpath compares the two fields
within the row. The filter is implemented in both dialects, SQL and
Python, for the same reason the location filter is: the semantic arm's
candidates arrive already fetched.

A merge deliberately carries no verdict forward — the survivor's code is a
union of several sources, so no prior check describes it, and unverified
is the honest answer.

UI: a danger-toned drift badge on each card (an actively misleading record
outranks a merely unused one), and a "Needs attention" filter. Its empty
state says plainly that never-verified snippets don't appear there —
otherwise `attention` would mean "everything" on day one and be useless as
a worklist.

Refs #2086

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-28 18:20:33 -04:00
bvandeusen 2b85443dd1 feat(snippets): usage signal — was a surfaced record ever actually pulled?
CI & Build / Python lint (push) Successful in 2s
CI & Build / integration (push) Successful in 31s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 44s
retrieval_logs answers "what did the ranker return, at what scores" — the
right substrate for tuning a threshold. It cannot answer the question the
snippet corpus actually needs: did anyone open this? A snippet nobody
opens is not neutral. It takes a slot in every future auto-inject menu and
crowds out something useful.

Adds note_usage_events (migration 0071): one row per note per event,
either 'surfaced' (we put its title in front of an agent) or 'pulled'
(someone opened it in full), tagged with which surface produced it.

Closes the gap #2082 recorded against this work. The write-path PLACE arm
carries no score, so it has no home in retrieval_logs — folding it in
would corrupt the score distribution that table exists to capture. The
result was that the arm firing on the STRONGEST claim ("there is already a
canonical helper in this exact file") was the one arm nobody could
measure. Both arms now emit usage events under distinct sources, so their
pull-through rates are finally comparable.

Deliberate departures from the task as written:

- Not in-session correlation. The original framing was "correlate
  result_ids against a later get_note in the same session." There is no
  session identity server-side — the MCP endpoint is stateless and the
  hooks send no session id — and adding one would mean threading an
  opaque client-supplied token through every read path. Two independent
  counters answer the question without it: surfaced 40×, pulled 0 is dead
  weight regardless of how those events distribute across sessions.

- Pulls record at the ENTRY POINTS (MCP tools, REST detail route), not in
  snippets_svc.get_snippet, which update and merge also reach. Counting
  those would inflate precisely the number meant to say "someone chose to
  look at this."

- get_note records for every note kind, not just snippets. The auto-inject
  menu surfaces tasks and processes too; scoping this to snippets would
  pin those at zero pulls forever and make them read as dead weight next
  to snippets that merely had a counter.

Surfaced in the Snippets list as an "N/M used" badge, warning-toned once a
record has been offered 3+ times and never opened, with the tooltip saying
what to do about it (usually: its "when to reach for it" doesn't say
when). No badge at all below one surfacing — "0/0" reads as a verdict when
it's an absence of evidence. Also returned from MCP list_snippets so the
agent can see dead weight without opening the UI.

Telemetry keeps the retrieval_telemetry contract throughout: writes are
fire-and-forget, reads degrade to zeroes, and no path can raise into the
surface it observes.

Refs #2085

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-28 18:09:17 -04:00
bvandeusen c569cdd0eb fix(plugin): every hook's credential + URL encoding was broken
Three defects, each of which independently made a hook a no-op, and all
three failing silently — which is why the whole dynamic side of the plugin
looked "shipped" while doing nothing.

1. Wrong env var case. Claude Code exports userConfig to hooks as
   CLAUDE_PLUGIN_OPTION_<KEY> with the key UPPERCASED. All four hooks read
   CLAUDE_PLUGIN_OPTION_api_endpoint / _api_token, so both values were
   always empty. That killed the SessionStart dynamic tier, process sync,
   prompt auto-inject, and the write-path prior-art trigger at once.

2. jq -rR is line-oriented. `@uri` under -R encodes input LINE BY LINE, so
   a multi-line payload came back as several encoded lines joined by raw
   newlines — an invalid URL, curl fails, hook exits 0 in silence. Now
   -sRr. This one hid behind (1): auto-inject only ever worked for
   single-line prompts, and prior-art (which posts code, always
   multi-line) could never have worked at all.

3. cut -c1-1200 caps each LINE, not the payload, so the prior-art code
   budget wasn't a budget. Now head -c 1200.

Also widens the SessionStart warning: "neither URL nor token arrived" used
to be treated as a benign unconfigured install and stayed quiet. That is
exactly the state defect (1) produced, so the one install state that most
needed a signal was the only one that emitted none. It now says so, and
names the two other features it silently disables.

Verified against the live instance: dynamic rules + project context load,
auto-inject surfaces #2192 on a multi-line prompt, and the write-path
trigger returns the [here] place-arm hit on a multi-line edit with session
dedup suppressing the repeat.

Refs #2198, #2082

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-28 18:00:11 -04:00
bvandeusen 6153231f9c fix(ci+docker): install from uv.lock — stop resolving dependencies at build time (#81)
CI & Build / Python lint (push) Successful in 7s
CI & Build / integration (push) Successful in 39s
CI & Build / TypeScript typecheck (push) Successful in 45s
CI & Build / Python tests (push) Successful in 1m0s
CI & Build / Build & push image (push) Successful in 21s
2026-07-28 13:04:24 -04:00
bvandeusen 1b81310847 fix(docker): build the image from uv.lock too
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 17s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 40s
The other half of #2194. The runtime stage did `COPY pyproject.toml .` +
`pip install .` and never copied uv.lock at all, so the SHIPPED IMAGE
resolved its own dependency set — independently of CI and of the lock. CI
could be green on one set of versions while the published image ran another,
which makes a green run evidence about the tests and not about the artifact.

Now: install uv, sync deps from the lock, then sync the project. Split into
two syncs so the dependency layer caches on any build that doesn't touch the
lock — the same shape CI uses, so image and CI can no longer disagree.

`uv sync` installs into /app/.venv rather than the system interpreter, so PATH
picks it up for the alembic + hypercorn CMD. The project stays editable, which
keeps /app/src authoritative exactly as PYTHONPATH and the frontend-dist copy
into src/scribe/static/ already assume.

Not built locally (rules #10/#12) — the dev build job verifies it, and `main`
already carries a working :latest, so a break here can't strand a deploy.
2026-07-28 13:01:35 -04:00
bvandeusen a47e1b9c4e fix(ci): regenerate uv.lock and enforce it with --locked
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 17s
Completes the previous two commits. `--frozen` failed run 3007 with
`ModuleNotFoundError: No module named 'pgvector'` — the lock wasn't merely
stale in its recorded metadata, it was missing a real dependency. pgvector
was added to pyproject for the vector-search work and the lock was never
regenerated, and nothing noticed because CI resolved from pyproject and never
read the lock. The lock has been dead weight for some time.

Regenerated with `uv lock` inside a throwaway `ci-python:3.14` container —
this workstation has no uv and no pip, and the CI image already carries the
right toolchain, so nothing was installed to do it. uv was conservative as
promised: pgvector 0.5.0 added, and NOT ONE existing pin moved (verified by
diffing name=version pairs across all 106 packages).

Both lanes now run `uv sync --locked`, so a dependency edit without a re-lock
fails loudly at install rather than resolving around the lock. The check paid
for itself on its first run by surfacing the missing pgvector.

Also added uv.lock to the workflow's `paths:` filter. It was absent, so a
lock-only change — exactly what a dependency bump looks like now — would not
have triggered CI at all.

Closes the CI half of #2194. The Dockerfile still resolves independently and
is tracked there.
2026-07-28 12:58:18 -04:00
bvandeusen 8bab0c762b fix(ci): use uv sync --frozen — --locked needs a lock this box can't regenerate
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Failing after 8s
CI & Build / Python tests (push) Failing after 21s
CI & Build / TypeScript typecheck (push) Successful in 23s
CI & Build / Build & push image (push) Has been skipped
Run 3006 failed at the install step: "The lockfile at `uv.lock` needs to be
updated, but `--locked` was provided." The guard was working — the mcp cap
edited pyproject, so the lock genuinely is stale, and hand-editing the
recorded specifier wasn't enough to satisfy uv's freshness check.

Regenerating needs `uv lock`, and this workstation has neither uv nor pip
(rule #10 — local Python envs are deliberately absent), so obtaining it would
mean pulling a binary from github.com, against rule #3. Not doing that
unilaterally.

--frozen installs exactly what the lock pins and resolves nothing, which is
the whole point of #2194: no dependency can float into a run again. What it
gives up is only the staleness check — and a forgotten re-lock surfaces as a
loud ImportError, not as a silent version drift, so the failure mode is the
tolerable one.

Flip to --locked in the same change that runs `uv lock`. Refs #2194.
2026-07-28 12:55:06 -04:00
bvandeusen ef7ebddadf fix(ci): install from uv.lock so CI stops resolving dependencies itself
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Failing after 10s
CI & Build / Python tests (push) Failing after 16s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Build & push image (push) Has been skipped
Closes the reproducibility hole that turned `main` red an hour ago. CI ran
`uv pip install -e ".[dev]"`, which resolves from the pyproject constraints
and ignores uv.lock completely — so every dependency floated. uv.lock pinned
mcp 1.27.2; CI installed the 2.0.0 published mid-session and the identical
tree that passed on `dev` failed on `main`.

Both Python lanes now run `uv sync --locked --extra dev`. `--locked` also
fails when the lock is stale against pyproject, so a dependency change has to
go through a deliberate `uv lock` instead of arriving on its own — which also
restores the point of the Renovate dashboard-approval flow.

Dropped the http-ece install and the setuptools/wheel step that existed only
to support it: nothing in src/ or tests/ imports http_ece. It is a leftover
from the web-push subsystem removed in the MCP-First pivot, and it was never
in pyproject or uv.lock — CI was installing an unused package and carrying a
--no-build-isolation workaround for it.

Cache key moves from pyproject.toml to uv.lock, since the lock is now what
determines the installed set.

uv.lock's recorded root requirement updated to match the mcp cap. Edited by
hand rather than regenerated: uv isn't installed on this workstation, the
resolved mcp 1.27.2 already satisfies `<2`, so no re-resolution is needed —
only the staleness check needed satisfying.

The Dockerfile still resolves independently (`pip install .`, and it doesn't
even copy uv.lock), so the shipped image is not yet covered. Following
separately so a build break can't strand `main`. Refs #2194.
2026-07-28 12:50:52 -04:00
bvandeusen d5d0b012b1 fix(deps): cap mcp below 2.0 — un-reds main (#80)
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 41s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 19s
2026-07-28 10:18:23 -04:00
bvandeusen aa850ac1e1 fix(deps): cap mcp below 2.0 — it removed mcp.server.fastmcp
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 1m10s
`main` went red on the PR #79 merge (run 2999) with
`ModuleNotFoundError: No module named 'mcp.server.fastmcp'` across every MCP
test module. Not the merged code: the identical tree passed on `dev` an hour
earlier (run 2997). mcp 2.0.0 was published between the two runs.

`src/scribe/mcp/server.py` imports `mcp.server.fastmcp.FastMCP` to build the
entire tool surface, so 2.x is a genuine incompatibility, not a precaution.
Capped at `<2`; lift it in the same change that ports server.py.

Note what this exposes: `uv.lock` already pinned mcp 1.27.2 and CI installed
2.0.0 anyway, because the workflow uses `uv pip install -e ".[dev]"`, which
resolves from pyproject and ignores the lockfile. Every dependency is
therefore floating in CI regardless of what the lock says — this cap fixes
today's break, not that. Filed separately.
2026-07-28 10:15:46 -04:00
bvandeusen ca94c332ee Drafter hardening: reverse lookup by location + the write-path trigger (#79)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 14s
CI & Build / integration (push) Failing after 30s
CI & Build / Python tests (push) Failing after 30s
CI & Build / Build & push image (push) Has been skipped
2026-07-28 10:12:48 -04:00
bvandeusen e0328f2b1c feat(plugin): write-path trigger — offer prior art before code is rewritten
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 1m2s
The milestone headline. Auto-inject fires on the operator's prompt; the
moment reuse is actually lost is later, when the agent decides mid-task to
write a helper. A PreToolUse hook on Write|Edit now fires there.

Channel: `additionalContext` with NO permissionDecision, so the note reaches
Claude beside the tool result and the write is never blocked — a recall aid
must not be able to stop the operator's work. Plain stdout would have been
invisible to the model, and deny/ask would have made a nudge into a gate.

Two arms, different in kind:
- BY PLACE — a snippet recorded at this path (or its directory) is prior art
  by definition, not resemblance, so it is neither scored nor thresholded.
  This is what #2083's reverse lookup was built to answer.
- BY MEANING — semantic search restricted to snippets (new `note_type` filter
  on semantic_search_notes) over the code about to be written.
Place ranks first; the top-k cap spans both arms.

Gates carried over from milestone 93 verbatim: threshold, margin, session
dedup, titles-never-bodies. Own `source='write_path'` in retrieval_logs so
precision is tunable separately — the docstring records that the place arm
is unlogged and hands that to #2085.

Its own on/off in Settings but the SAME threshold/top-k: one "how loud may
Scribe be" knob is easier to reason about than two that drift, and splitting
them later is then a data-backed change rather than a guess.

Details worth keeping: the hook sends a REPO-RELATIVE path because that is
how locations are recorded; the git remote resolves to a project and is never
used as the location `repo` filter (different namespaces, would silently
match nothing); the endpoint stays a GET because a read-scoped API key cannot
POST and every other hook depends on that.

plugin.json 0.1.17 -> 0.1.18. Refs #2082, milestone #232.
2026-07-28 09:08:17 -04:00
bvandeusen 083944f0fd fix(snippets): backfill must also catch JSON null, not just SQL NULL
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 45s
CI & Build / integration (push) Successful in 36s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 1m11s
Integration lane caught it (run 2984): the backfill reported 0 rows to fill
and left `data` unset. `IS NULL` was the whole predicate, but a JSONB column
has two empty states. SQLAlchemy's JSON types default to
`none_as_null=False`, so assigning Python `None` persists the JSON encoding
of null — `IS NULL` walks straight past it.

Migration 0070 left genuine SQL NULLs, so the product path was right; the
test was constructing the wrong shape with `data=None`. Fixed both ways,
because both states mean "no usable mirror":
- predicate is now `data IS NULL OR jsonb_typeof(data) = 'null'`;
- the legacy-row test OMITS `data` (a real SQL NULL, 0070's actual shape),
  and a second test covers the JSON-null shape and asserts the premise with
  `jsonb_typeof` rather than assuming it.

Refs #2083.
2026-07-27 23:12:48 -04:00
bvandeusen dd1b5e5ddb feat(snippets): reverse lookup — find snippets by repo/path/symbol
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Failing after 27s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 1m42s
"What canonical helpers already live in this file?" was unanswerable:
location lived only in the body markdown. It is now a jsonpath containment
query over the `notes.data` mirror added by migration 0070.

- One predicate in two dialects in services/knowledge.py: SQL (`data @?`,
  applied in the browse arm and the keyword arm before count/pagination, so
  totals stay honest) and Python (`location_matches`, for the semantic arm
  which post-filters candidates it already holds). Both must change together.
- Parts are ANDed within a SINGLE locations entry — repo A in one entry and
  path B in another is not "recorded at A/B". `path` also matches as a
  directory prefix, via jsonpath `starts with` rather than `@>`, which the
  same GIN index serves.
- `repo`/`path`/`symbol` reach the service, the REST list and the MCP tool
  under one name with one default (rule #33); the MCP docstring teaches the
  place form, and so does the reusing-code skill (plugin.json bumped).
- UI: a Location disclosure beside the snippet search, with its own empty
  state — "nothing kept there, so what you're about to write is new."

Settles #2083's open question (pre-0070 NULL `data`) by backfilling after
all: `backfill_snippet_data` runs at startup, deriving the mirror from the
body with the same parser the read path trusts. 0070's caution was about
mangling a hand-edited body; this never touches the body. The alternative
was a permanent second body-regex arm, or a query that silently answers
"nothing here" for an old snippet and gets the helper written twice.

Refs #2083, milestone #232.
2026-07-27 23:09:37 -04:00
bvandeusen 0d396de215 feat(snippets): record merge provenance on the survivor
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 38s
CI & Build / TypeScript typecheck (push) Successful in 40s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 1m7s
Merge kept the target's fields, unioned locations and tags, and trashed
the sources — recording nothing about what it absorbed. If a variant
handled an edge case the survivor doesn't, that difference left the
visible record entirely; recovering it meant knowing to go digging in
the trash.

The survivor now carries `merged_from`: a "**Merged from:** #2, #3" line
in the body for humans, and the same list in the `data` mirror for
queries, written from one value like every other field (#2087).

It accumulates rather than replaces — a target merged twice keeps both
histories — and skipped sources (cross-owner, per #231) are excluded, so
the record never claims to contain something it never absorbed.

Ordinary edits carry it forward. update_snippet recomposes body and
mirror from scratch, so an omission there would silently erase the
history on the next unrelated edit; that path is pinned by its own test,
including the pre-0070 case where the body line is the only copy.

Surfaced in the snippet detail view as a "Merged from" row — the view
renders parsed fields, not the raw body, so the body line alone would
have been invisible to the operator (rule #27).

Un-merge, the other half of #2087, stays open: restoring a source from
trash still doesn't strip its locations off the survivor, and what
partial un-merge should mean is a design question, not a coding one.
`merged_from` is the record that makes it tractable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-27 15:23:41 -04:00
bvandeusen 9fa474b3c4 fix(mcp): make get_note / get_task share-aware
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 31s
CI & Build / TypeScript typecheck (push) Successful in 42s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 1m0s
The injected menu tells the agent to open any hit with get_note(id), and
that menu can list a collaborator's record reached through a shared
project. The fetch was still owner-only, so those lines answered
"not found" — for a record the same user opens fine in the browser.
The agent path was strictly narrower than the web path for the same id.

Same boundary miss as #2093: the list side was widened for sharing, the
fetch side wasn't. Both tools now resolve through get_note_for_user,
apply the trash filter themselves (permission resolution says nothing
about liveness), and attach describe_provenance so a shared record
arrives marked as someone else's rather than passing as the caller's.

routes/tasks.py's parent-title lookup had the same narrowness: a shared
subtask rendered as an orphan when its parent was equally shared.

Four fetch tests across three files were patching notes_svc.get_note and
had to be retargeted — note 2109's third sub-case, caught by grepping
tests/ for the old name before pushing rather than by CI (#2159).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-27 15:18:03 -04:00
bvandeusen 8977bed28d feat(inject): label the record kind on each auto-inject menu line
Every injected hit rendered identically, so a recorded snippet was
indistinguishable from a stray dev-log in the one place prior art most
needs to stand out. Each line now carries its kind — [snippet],
[process], [task], [issue], [note] — and the header says "records"
rather than "notes", which it can no longer claim.

Task-ness wins over note_type in the marker: "there's an open issue
about this" is the more useful thing to know at a glance.

Still title-first: the marker is metadata already on the ORM object,
so no extra query and no bodies (#2084).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-27 15:18:03 -04:00
bvandeusen fc9c8119a2 test(snippets): fix the round-trip test's shared kwargs spread
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 20s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 1m5s
Run 2923: 1 failed, 408 passed. My test bug, not a code one — I spread one
`fields` dict into both compose_body and compose_data, but compose_body takes no
`name` (the name lives in the title). Each serializer now gets its own argument
list.

The migration itself was fine: the integration lane ran 0001→0070 on
pgvector/pg17 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-26 18:28:21 -04:00
bvandeusen cca40affe4 feat(snippets): add notes.data JSONB — the indexed mirror of snippet fields
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 25s
CI & Build / Python tests (push) Failing after 29s
CI & Build / Build & push image (push) Has been skipped
Milestone #232 step 1 (task #2081). Takes the enabler first rather than the
write-path trigger: reverse lookup, drift checks and the duplicate finder all
need to QUERY structured fields, and building them on body-regex first means
writing them twice.

#227 deferred this bag "unless body-convention ergonomics prove insufficient" —
answering "which snippets live in this file?" by scanning every snippet and
regexing its body is that condition being met.

Migration 0070 adds `notes.data` (nullable JSONB) + a GIN index. The body is
UNCHANGED and still what gets embedded and read by humans; `data` mirrors the
same facts in a shape Postgres can index. Code is deliberately not copied into
it — the body holds it, and duplicating a blob into the column we index around
would be waste.

- compose_data() builds the mirror, omitting empties so the column stays sparse
- snippet_fields() prefers `data`, falling back to parsing the body. Rows written
  before 0070 have no `data` and are never backfilled, so a hand-edited body
  stays authoritative for them with no conversion deadline
- create / update / merge all write body and mirror from the same merged field
  set, so the two can't drift; merge in particular has to grow the mirror with
  the survivor's location set or a merged snippet would be unfindable at the very
  call sites the merge just recorded

Named `data`, not `metadata`, because that collides with SQLAlchemy's declarative
Base.metadata — which is why the pre-0069 model had to map an awkward
`entity_metadata` attribute. Not a revival of the column 0069 dropped: different
name, different purpose, nothing reads the old shape.

Two test fakes needed an explicit `data = None`: snippet_fields prefers `data`
when truthy and an auto-MagicMock attribute is truthy, so every parsed field
would have come back a MagicMock. Checked every fake reaching snippet code this
time rather than waiting for CI (note 2109).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-26 18:25:55 -04:00
bvandeusen a8f152b7d4 Merge pull request 'Drafter reuse-recall (both halves) + the multi-user ACL work it exposed' (#78) from dev into main
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 17s
CI & Build / integration (push) Successful in 27s
CI & Build / Python tests (push) Successful in 52s
CI & Build / Build & push image (push) Successful in 17s
Drafter reuse-recall layer (milestones #227, #231) plus the sharing/ACL
corrections reviewing it exposed: two visibility scopes, provenance on every
surface that hands over another user's record, and write access aligned with the
share model. CI green on 4b5d900 (run 2901).
2026-07-26 00:25:27 -04:00
bvandeusen 4b5d9005fd test(processes): retarget the update_process patch at get_note_for_user
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 21s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 1m9s
Run 2899: 1 failed, 402 passed. test_update_process_rejects_non_process_note
still patched notes.get_note, which update_process no longer calls now that it
resolves shares — so the real get_note_for_user ran and reached for a database.

Retargeted at get_note_for_user (which returns (note, permission)), and added the
companion case the new behaviour deserves: a viewer grant is refused with the
read-only reason and never reaches update_note.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-26 00:07:08 -04:00
bvandeusen 3ffdbbc521 fix(acl): align MCP writes with the share model — editor edits, viewer doesn't
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 33s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 34s
CI & Build / Build & push image (push) Has been skipped
Option B, per the operator. Closes the last inconsistency from the ACL work.

The agent path had drifted into an indefensible position: delete_snippet honoured
editor shares (I made it share-aware so widening the read wouldn't let a VIEWER
trash things) while update_snippet still resolved through the owner-only
notes.get_note. So through an agent you could destroy a colleague's snippet but
not improve it — and the refusal claimed "not found" for a record you could
plainly open.

Now update_snippet, merge_snippets and update_process all resolve the read scope
and then require can_write_note, matching the REST routes and the sharing UI's
own promise that viewer / editor / admin are distinct grants. A viewer grant is
refused with the actual reason ("shared with you read-only — ask its owner for
edit access, or record your own version"), because not-found would send an agent
hunting for a missing id instead of recording its own copy.

Authorised writes are performed as the OWNER, since the underlying note update is
owner-scoped and a shared editor's own id would match nothing.

Merge additionally requires each source to share the TARGET'S owner and to be
writable by the caller — merging trashes the source, so read access isn't enough,
and cross-owner merge stays out of scope (#231). Sources failing either test are
skipped rather than half-merged.

A record the caller cannot read at all still returns not-found rather than
forbidden, so the error can't be used to confirm that an id exists.

Also fixed _fake_snippet's missing user_id proactively — the same
auto-MagicMock-reads-as-foreign trap that broke CI twice (see note 2109).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-26 00:04:48 -04:00
bvandeusen e9cd3435ad test(acl): give search/inject fixtures real owner ids; fail soft on name lookup
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 1m4s
Run 2892: 3 pre-existing tests broke, 392 passed. Same shape as the last
breakage — a DB-touching call landed in a path unit tests exercise, and their
fixtures had auto-MagicMock user_id attributes that compare as "someone else's",
sending the code off to look up a username.

Fixed the fixtures rather than the assertion: _fake_note in the search tests and
_note in the plugin-context tests now take a real user_id defaulting to the
caller those tests bind. That makes "is this shared?" meaningful in both files
instead of accidental, which is what the new provenance behaviour actually needs
from them.

Separately, and not as cover for the above: owner_names_for now fails soft.
A lookup error yields no names and callers render "another user". The part that
matters — that the record is NOT the caller's — comes from comparing owner ids,
not from this query, so losing an attribution is cosmetic where failing the whole
search would not be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-25 23:10:12 -04:00
bvandeusen ef1dbdfc86 feat(acl): unify the retrieval scopes; mark shared rows in Knowledge browse
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Failing after 31s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 34s
CI & Build / Build & push image (push) Has been skipped
Closes #2092 and the Knowledge-browse provenance gap.

The two halves of a hybrid search disagreed: the keyword half honoured shares
while the semantic half was pinned to NoteEmbedding.user_id, so a shared record
was findable by wording and invisible by meaning — the case a semantic search
exists to serve. semantic_search_notes now scopes on Note via a `scope`
parameter, and each of its five callers declares which kind of act it is:

  mcp/tools/search.py     read    the agent asked
  routes/search.py        read    the user typed it
  knowledge.py (semantic) read    matches the keyword half beside it
  plugin_context.py       browse  nobody asked; never a one-to-one share
  dedup.py                own     a verdict that blocks a write must not hinge
                                  on another person's notes

That last one is the reason this isn't a single global widening: the dedup gate
returns "update the existing one instead", so matching a stranger's record would
refuse a legitimate create and point at something the caller can't edit. Scope
defaults to "own" so a caller that forgets is wrong in the safe direction, and an
unknown scope raises rather than falling back — a typo there would be a
data-exposure bug.

Auto-inject keeps the browse scope, which still admits a collaborator's note via
a shared project. Its menu line is the only provenance an agent sees, so a
foreign hit now reads: #12 "Title" (0.71) - shared by alex, treat as a
suggestion. MCP and REST search results carry shared/owner too.

Knowledge browse: the feed hydrates cards from /api/knowledge/batch rather than
the list route, so both paths label rows now, and KnowledgeView shows "by
<owner>" on records the viewer doesn't own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-25 23:06:52 -04:00
bvandeusen 8b069cc93f fix(acl): make the visibility predicates pure builders; repair CI
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 49s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 1m5s
Run 2888 failed with 7 tests down, both causes mine.

The real problem was a design flaw, not the tests: readable_notes_clause and
browsable_notes_clause each opened their own DB session to fetch the caller's
group ids. That made them unmockable at the call site, so every unrelated
service test suddenly had to know they existed and stub them — four modules
broke the moment a service started calling one, and one of my own stubs patched
the wrong name (readable_* where the code had moved to browsable_*).

Fixed at the root: group membership is now a SUBQUERY rather than a fetched
list, so both clauses are synchronous pure functions with no session. One fewer
round-trip per query, membership folded into the statement the caller was
already running, and nothing for callers' tests to mock. The "no groups means no
group arm" special case disappears too — an empty subquery simply matches
nothing.

Also: _fake_note in the process tool tests had no real user_id, so its
auto-MagicMock attribute reached session.get(User, ...) through the new
provenance check and SQLAlchemy rejected it. The fixture now takes a real
user_id defaulting to the bound caller, which makes "is this shared?"
meaningful, and gains a case asserting another user's process comes back
flagged.

Test assertions on compiled SQL are deliberately loose about formatting: the
local env has no SQLAlchemy (rule #10), so they check that the arms exist rather
than guessing at exact rendering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-25 22:48:01 -04:00
bvandeusen 04b58ce01e feat(acl): shared records are search-only and always labelled as someone else's
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 28s
CI & Build / integration (push) Successful in 35s
CI & Build / Python tests (push) Failing after 41s
CI & Build / Build & push image (push) Has been skipped
Narrows the b7d6fc7 widening per the operator's call, and fixes a regression it
introduced. Decision recorded as note 2094.

Two scopes now, deliberately different:

  readable_notes_clause  — everything the ACL permits, including records reached
                           only via a direct/group note share. For EXPLICIT acts:
                           a search the caller typed, a fetch by id.
  browsable_notes_clause — the caller's own records plus anything in a project
                           they can reach. For PASSIVE surfaces: browse lists,
                           facet counts, the process->skill manifest.

The split is a trust boundary. Anything appearing unasked — in your own list,
your own counts, or as a skill installed on your machine — reads as material you
endorsed. A one-off someone shared with you hasn't earned that standing, so it
waits until you go looking. This also dissolves the shared-Process problem by
construction rather than by special case: the manifest is a passive surface, so a
directly-shared Process is never installed as an auto-surfacing skill.

Regression fix (#2093): b7d6fc7 widened the list queries but left the fetch path
owner-only, so on the MCP path a record could be listed and then not opened —
get_snippet raised not-found, get_process couldn't resolve, and the manifest
emitted stubs whose get_process call would fail. snippets.get_snippet and
notes.resolve_process now resolve the read scope. delete_snippet gained an
explicit can_write_note guard, since being able to SEE a shared snippet must not
imply being able to bin it.

Provenance, so nothing arrives looking like the operator's own work:
- access.describe_provenance / label_shared_items add shared/owner/permission;
  labelling costs no query when everything is the caller's own.
- MCP: get_snippet, list_snippets, get_process and list_processes carry it, and
  get_process now says outright NOT to follow a shared process verbatim — its
  follow-as-written contract was the sharpest instance of the problem.
- The skill stub for a shared Process names its author and asks for a go-ahead,
  instead of describing it as "the operator's saved Scribe process".
- Policy stated once in the MCP _INSTRUCTIONS and the reusing-code skill: a
  shared record is that person's suggestion, weigh it, attribute it, ask before
  adopting it.
- UI: shared snippets show "by <owner>" in the list and a notice above the code
  in the detail view, reusing SharedWithMeView's vocabulary.

Plugin 0.1.15 -> 0.1.16 (skill text changed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-25 22:43:00 -04:00
bvandeusen b7d6fc7e5d fix(acl): make shared records findable, not just openable (#2079)
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 38s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 1m7s
Option A of the #2079 fork, per the operator's call: widen the scope in
query_knowledge for every caller rather than special-casing the snippet path.

Until now the list/search queries filtered on Note.user_id alone, while
get_note_permission resolved shares properly. The result: a record shared with
you could be OPENED by id but never FOUND — invisible in Knowledge browse, in
snippet and process lists, and in the facet counts beside them.

- services/access.py gains readable_notes_clause(user_id): the same resolution
  get_note_permission does per row (ownership, direct share, group share,
  inherited project share), expressed as set membership so a list query can use
  it in one statement instead of O(n) permission round-trips.
- services/knowledge.py routes every query through it — query_knowledge, the
  keyword half of the hybrid search, query_knowledge_ids, get_knowledge_by_ids,
  get_knowledge_tags and get_knowledge_counts. The facets follow the list, or a
  tag visible in the list would filter it down to nothing.
- list items now carry user_id, since these lists can be mixed-ownership and
  the client has no other way to mark what isn't yours.

Reaches four surfaces: the Snippets list (the original report), Knowledge
browse, list_processes, and the plugin's process manifest — so a Process shared
with you now also syncs as a local skill stub, which is the point of sharing one.

NOT widened: semantic_search_notes, which scopes by NoteEmbedding.user_id and
also backs auto-inject and the search MCP tool. Widening it would put another
user's content into your agent context automatically — a product decision, not
a bug fix. Consequence until that call is made, marked at the call site: a
shared record is findable by wording but not by meaning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-25 19:19:18 -04:00
bvandeusen b33e2a79c6 fix(snippets): close the recall-surface gaps found reviewing the Drafter
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 19s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 1m7s
Four defects from the 2026-07-25 review of the recall (#227) and merge (#231)
milestones. The theme: a snippet could be recorded but not fully corrected, and
the agent and web surfaces had drifted apart.

- #2076 language was mis-derived from the first caller tag, so a snippet created
  with tags and no language read that tag back as its language — corrupting the
  tag set and the code fence on the next update. Only the FIRST tag can carry
  the language, since compose_tags emits [language, "snippet", *caller].
- #2077 MCP update_snippet mapped "" to "unchanged", so no field could ever be
  cleared and no snippet detached from its project. Now an omitted field is left
  alone, an empty string clears, and project_id follows the -1 = detach
  convention. A service-level UNSET sentinel keeps None available as the clear.
- #2078 surface parity: adds delete_snippet (MCP had none, so a wrong snippet
  could not be retired by the agent that recorded it), locations on MCP create
  and update, system_ids through the REST routes and the editor, and the
  near-duplicate gate on REST create with a "record it anyway" escape.
- #2079 project scoping: list_snippets takes project_id through the service, the
  MCP tool and the REST route, defaulting to every project — reaching across
  projects is the point when the helper you need was written elsewhere.

Sharing the list across owners is deliberately NOT in here: query_knowledge is
shared with the Knowledge browse surface, so widening it changes behaviour well
beyond snippets. Left open on #2079 for a scope decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-25 19:00:05 -04:00
bvandeusen 7a81b7333e feat(scribe): snippet merge UI — multi-select merge, repeatable locations
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 1m2s
Step 3 of the snippet-merge milestone (#231): the human surfaces for
merge + multi-location, at v1 quality.

Frontend:
- SnippetListView: a Select mode (checkbox on each card) → a sticky action
  bar → a merge modal that lets you pick which selected snippet is the
  canonical (the others fold in and go to trash). Accent border on selected
  cards, Moss action buttons (Hybrid rule).
- SnippetEditorView: the single Location fieldset becomes a repeatable
  locations list (add/remove rows), so editing a merged snippet no longer
  collapses its call sites — no data loss. Sends `locations`.
- SnippetDetailView: renders every location (Location vs Locations label).
- api/snippets.ts: SnippetLocation type, `locations` on fields/input,
  mergeSnippets().

Backend (editor enablement):
- create_snippet service + POST route accept an optional `locations` list;
  PATCH route forwards `locations` — so the editor's location list works
  uniformly on create and edit. Single repo/path/symbol remain the
  one-location shorthand (MCP create contract unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t
2026-07-25 17:22:01 -04:00
bvandeusen 85625de394 feat(scribe): merge_snippets — unify found one-off snippets into one canonical
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / integration (push) Successful in 38s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 1m10s
Step 2 of the snippet-merge milestone (#231). The dedup gate only PREVENTS
new near-duplicates; merge is the CURE for the ones already scattered.

- services/snippets.py: merge_snippets(user_id, target_id, source_ids) —
  keep the target's scalar fields (name/when_to_use/signature/language/
  code), union the sources' locations + extra tags onto it (so the survivor
  carries every call site as a location), trash the sources (recoverable),
  re-embed the survivor. Pure merge_snippet_fields() factored out for unit
  testing. Returns (survivor_note, merged_ids).
- mcp/tools/snippets.py: merge_snippets(target_id, source_ids) tool (5th),
  and a create_snippet dedup-path nudge toward merge over a forced copy.
- routes/snippets.py: POST /api/snippets/<id>/merge {source_ids} — share-
  aware (can_write target + every source, rule #78) with a same-owner guard
  (cross-owner merge is out of scope).
- plugin reusing-code skill + MCP _INSTRUCTIONS: point found-duplicates at
  merge as the cure (rule #119 surfaces, not a Scribe rule). plugin.json
  0.1.13 -> 0.1.14 in the same change (the #1040 marketplace-ship lesson).
- Tests: pure merge-helper union/dedup; MCP tool (requires a source,
  survivor+merged_ids, not-found); route handler + 5-tool registration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t
2026-07-25 17:11:47 -04:00
bvandeusen eb400a521b feat(scribe): multi-location snippet body convention (backward compatible)
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 1m7s
Step 1 of the snippet-merge milestone (#231). A snippet that unifies N
found one-offs carries N locations (one per call site), so `location`
becomes a list. Ships on the body-convention — no migration, swappable to
the deferred `data` JSONB later (as #227 decision 5 anticipated).

- compose_body: renders `**Location:**` for a single location, a
  `**Locations:**` bullet list for several. Accepts a `locations` list;
  the single repo/path/symbol params remain as a one-location shorthand
  (create path + existing callers/tests unchanged).
- parse_snippet_fields: reads BOTH the new `**Locations:**` list block AND
  the legacy single `**Location:**` line (tolerant, never raises); returns
  a `locations` list and mirrors the first into repo/path/symbol for
  back-compat (rule #33).
- update_snippet: gains a `locations` param — replaces the whole set; else
  a legacy single triple overlays onto the first location; else kept.
- Tests: multi-location round-trip, singular-vs-plural label, legacy
  single-line parse, normalize dedup/empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t
2026-07-25 17:07:09 -04:00
bvandeusen d257c0fd67 feat(scribe): snippet management UI + REST routes; embed snippets on create
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 1m4s
Step 6 of the Drafter recall milestone (#227): a human-facing surface for
the reusable-code snippets that agents record via MCP, plus the REST API
behind it. Backend embeds snippets inline on create/update so they're
recallable immediately, not only after a restart.

Backend:
- routes/snippets.py: GET/POST /api/snippets, GET/PATCH/DELETE
  /api/snippets/<id>. Share-aware per rule #78 (get_note_for_user +
  can_write_note), writes performed as the owner; list owner-scoped —
  mirrors routes/notes.py. Registered in app.py.
- services/snippets.py: embed on create/update via a _embed_snippet
  fire-and-forget helper, covering BOTH the MCP tool and the REST route
  by construction. A snippet's value is immediate recall, so it can't wait
  for the startup-only backfill (see issue: MCP create path doesn't embed
  inline for notes/tasks generally).
- tests/test_routes_snippets.py: structural registration + handler/service
  contract + PATCH-field ↔ update_snippet-kwarg parity (rule #33).

Frontend (Vue 3 + TS):
- api/snippets.ts: typed client, modeled on api/systems.ts.
- views: SnippetListView (search, skeleton/empty/error states),
  SnippetDetailView (read + copy-to-clipboard, ConfirmDialog delete),
  SnippetEditorView (create/edit all fields, Ctrl/Cmd+S, Esc, autofocus,
  validation). v1 quality per rules #24/#27.
- router: /snippets, /snippets/new, /snippets/:id, /snippets/:id/edit.
- NoteType union widened to include 'snippet'; Snippets nav link added to
  AppHeader (desktop pill bar + mobile menu).
- Design system: Moss --color-action-primary for action buttons, accent
  --color-primary reserved for tags/brand (Hybrid rule); focus rings;
  JetBrains Mono for code/name/signature/location.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t
2026-07-25 14:16:02 -04:00
bvandeusen 0ea3bff797 feat(scribe): snippet recording nudge — reusing-code skill + MCP/SessionStart guidance
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 31s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 1m10s
Step 5 of the Drafter recall milestone (#227): teach agents the two
snippet reflexes — search recorded snippets before writing a new
helper/util/component, and record something reusable the moment it's
built — via the app's own instruction surfaces, not a Scribe rule
(project rule #119). All instance-agnostic (rule #115).

- plugin/skills/reusing-code/SKILL.md: new auto-surfacing process-skill
  covering both reflexes (recall-before-rebuild + record-when-reusable).
- src/scribe/mcp/server.py: a Snippets paragraph in the MCP _INSTRUCTIONS.
- plugin/hooks/scribe_static_context.md: a "reuse before rebuilding"
  bullet in the SessionStart static context.
- plugin/.claude-plugin/plugin.json: version 0.1.12 -> 0.1.13 in the same
  change so the autoUpdate marketplace ships it (the #1040 lesson);
  description skill list updated.
- plugin/README.md: trued the process-skill list to what actually ships.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t
2026-07-25 14:00:07 -04:00
bvandeusen 1942913366 feat(scribe): add snippet recall — note_type='snippet' service + MCP tools
CI & Build / Python lint (push) Successful in 2s
CI & Build / integration (push) Successful in 20s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 1m7s
Record reusable functions/components once so they surface via the existing
semantic search + title-first auto-inject, instead of re-solving as one-offs.

A snippet is a Note with note_type='snippet' (no schema change): note_type is
free-text, and semantic_search_notes never filters by type, so snippets join
the recall/auto-inject pool the moment they're embedded. Structured fields
(name/language/signature/location/when_to_use/code) are stored via a body
convention — title = "name — when to use" (what auto-inject surfaces), language
+ "snippet" as tags, templated markdown body — keeping storage swappable later
without changing the tool/UI contract.

- services/snippets.py: compose/parse helpers + create/get/list/update wrappers
  over notes_svc (dedup + System association reused).
- mcp/tools/snippets.py: list_snippets / create_snippet / get_snippet /
  update_snippet, registered in tools/__init__.py.
- unit tests for the serialize/parse round-trip and the MCP tool surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t
2026-07-25 11:00:33 -04:00
bvandeusen 71d65442d3 Merge pull request 'Narrow Scribe to a work system-of-record — remove calendar + person/place/list surfaces' (#77) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 12s
2026-07-19 18:24:40 -04:00
bvandeusen d2f08d6113 docs(scribe): rewrite features/api-reference/README to the current product
Resolves issue #1778 — the docs still described the pre-pivot product (in-app
LLM chat, journal, weather, web-research, push, model management) plus the
just-removed calendar/entities.

- features.md: full rewrite to the actual surfaces — notes, tasks & issues,
  projects/milestones (kanban), systems, rules & rulebooks, stored processes,
  search + knowledge-injection, graph, MCP + the Claude Code plugin, sharing,
  export/backup (v4), OIDC. Dropped chat/journal/weather/web-research/calendar/
  push/workspace/model-management; fixed the shortcuts + settings tables.
- api-reference.md: rewrite to the real endpoint surface (verified from the
  route decorators) — added Knowledge/Rulebooks/Systems/Plugin/Trash/Dashboard
  and the milestone/system sub-routes; removed Chat/Journal/Push/Quick-Capture/
  Images/assist/models.
- README.md: Quick Start no longer tells users to pull an Ollama model or size
  RAM/GPU "for LLM inference" — points at the API key + plugin instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPtbSzA4JLMAKgFZ8VTg7Q
2026-07-19 18:23:28 -04:00
bvandeusen ab9b5b647c docs(scribe): true up plugin + MCP docs after calendar/entity removal
Milestone #194 plugin/docs slice.

- plugin.json: "second brain" framing -> "system-of-record"; bump
  0.1.11 -> 0.1.12 (any plugin/ change must bump the manifest, issue #1040)
- plugin/README.md + using-scribe SKILL + scribe_static_context: drop
  events/typed-entities from the surface lists; "second brain" -> "system
  of record" in the SessionStart context Claude reads each session
- docs/api-keys-and-mcp.md: drop the Typed-entities + Events MCP tool rows,
  add the Systems row
- README.md: rewrite the stale front-matter (chat/RAG/calendar/weather/push
  described a pre-pivot product) to the current Claude-driven work store

Deeper pre-pivot doc-rot in docs/features.md + docs/api-reference.md
(chat/journal/weather/web-research) is tracked separately as an issue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPtbSzA4JLMAKgFZ8VTg7Q
2026-07-19 16:14:46 -04:00
bvandeusen 12f71fabdf refactor(scribe): remove calendar + entity surfaces from web UI (frontend)
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 44s
Frontend half of the narrowing (milestone #194); matches backend b49efdc.

- delete CalendarView, EventSlideOver, WeatherCard (orphan)
- drop /calendar route + nav link + the g→l keyboard shortcut
- strip the calendar-events API client + event/metadata bits from note
  types and the notes store
- KnowledgeView: remove People/Places/Lists tabs, entity cards, create
  buttons and the upcoming-events widget; keep notes/tasks/plans/processes
  + the overdue-task badge
- NoteEditorView: remove person/place/list forms + list-builder + entity
  metadata; keep note + process editors (type select = Note/Process)
- DashboardView: drop the "Upcoming · 7 days" events rail card
- SettingsView: remove the CalDAV integration card + save/test (its
  endpoints were deleted backend-side)
- prune the now-dead entity/event CSS

RecurrenceEditor + task recurrence rules are kept (task machinery, not
calendar). Verified by a full dangler sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPtbSzA4JLMAKgFZ8VTg7Q
2026-07-19 16:10:24 -04:00
bvandeusen dd60244429 test(trash): update model-count assertions after Event removal
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 1m12s
restore() / purge_expired() now iterate 6 soft-deletable models (Note,
Project, Milestone, Rulebook, RulebookTopic, Rule) — Event was removed
with the calendar surface. Adjust the two count-coupled assertions
(execute.await_count 7→6; the rowcount list drops a zero so the sum
stays 4). Caught by CI run #2580.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPtbSzA4JLMAKgFZ8VTg7Q
2026-07-19 13:32:30 -04:00
bvandeusen b49efdcb11 refactor(scribe): retire calendar/events + person/place/list entities (backend)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 30s
CI & Build / Python tests (push) Failing after 31s
CI & Build / Build & push image (push) Has been skipped
Narrow Scribe to a Claude-Code work system-of-record (milestone #194,
decision note #1759). Wholesale removal per rule #22 — backend + schema half.

Calendar/events + CalDAV: delete models/event, services/{events,caldav,
caldav_sync}, routes/events, mcp/tools/events; strip event branches from
backup (bump v3->v4), dashboard (upcoming_events), trash, recent, and the
mcp server read-only allowlist + instructions.

Typed entities (person/place/list): delete mcp/tools/entities; drop the
notes.metadata (entity_meta) column from model/service/routes and the
knowledge browse service. note_type STAYS — it also marks 'process' notes.

Scheduler: event_scheduler -> recurrence_scheduler, keeping only the
recurring-task spawn job (drops event reminders + CalDAV sync).

Schema: migration 0069 drops the events table + notes.metadata column +
orphan caldav settings rows (faithful downgrade recreates them).

KEEP: recurrence.py (task recurrence), notifications task reminders, graph
view, and every work surface. Frontend + plugin/docs true-up follow next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPtbSzA4JLMAKgFZ8VTg7Q
2026-07-19 13:29:14 -04:00
bvandeusen 05f0cc2c4b Merge pull request 'fix(plugin): keep always-on rules alive across compaction (0.1.11)' (#76) from dev into main 2026-06-30 23:01:28 -04:00
bvandeusen f6629d4bcf fix(plugin): keep always-on rules alive across compaction (0.1.10 → 0.1.11)
Always-on rules were on-demand, not always-present: Tier-1 static context only
tells the agent to call list_always_on_rules(), and Tier-2 dynamic fetch is dark
(token doesn't reach the hook subprocess). On compaction the fetched rules get
summarized away while the harness's own built-in git instruction ("branch first")
survives in the base prompt — so post-compact the generic git instinct wins and
rule #1 ("dev is home") is missed.

- scribe_static_context.md: new "Operator rules govern consequential actions"
  bullet — before any git branch/commit/push or hard-to-reverse action, loaded
  rules beat generic harness/default habits; re-pull rules if not loaded or
  summarized by a compaction. Tier 1 = always fires, keyless, re-fires on compact.
- scribe_session_context.sh: compaction banner now re-pulls list_always_on_rules(),
  not just enter_project().
- plugin.json: 0.1.10 → 0.1.11 so autoUpdate ships the plugin/ change (#1040).

Generic and instance-agnostic per rules #115/#119 — no operator-specific rule
text hardcoded. Refs issue #1197.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4bNefPFAz7esmMZMZmkzL
2026-06-30 12:41:45 -04:00
bvandeusen 03772ff424 Merge pull request 'fix(plugin): bump to 0.1.10 to ship the auto-inject hook' (#75) from dev into main 2026-06-22 22:08:25 -04:00
bvandeusen 2bc054d7ef fix(plugin): bump version 0.1.9 → 0.1.10 to ship auto-inject hook
Path A's UserPromptSubmit hook (scribe_autoinject.sh) + hooks.json were
merged to main in PR #74 but the plugin version was never bumped, so the
autoUpdate marketplace (keyed by version string) never re-pulled the
snapshot — the hook was stranded, uninstallable, and not running in any
session. Bumping the version is what makes installs detect and pull it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xz4j1H7pjYSjKsEpgcNH5E
2026-06-22 22:07:19 -04:00