Commit Graph
19 Commits
Author SHA1 Message Date
bvandeusenandClaude Fable 5 66c4fd1b88 fix(sync): process-skill triggers invoke by name only — resemblance offers, never substitutes
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 26s
Scribe issue #2582: the sync wrote "Use when {title}-type work is
requested" into every generated process skill, so a bespoke live prompt
that merely resembled a stored Process got the Process's canned
procedure — and inherited approvals embedded in its body (the Drift
Audit's fan-out opt-in) that the conversation never granted. Observed
live: an explicit systems-review request with its own constraints was
answered with the Drift Audit clarify menu recommending ~15 parallel
auditors.

New trigger contract, both variants: follow verbatim only when invoked
by name; on mere resemblance the live instructions govern — offer the
process, ask before following, and never inherit embedded approvals the
operator hasn't granted in this conversation.

(The Drift Audit process record itself was also rewritten to a
sequential in-context walk — data change, live already, recorded in
the same issue.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 08:33:40 -04:00
bvandeusen 2d1e26f38f feat(telemetry): ambient surfacings count, apart — enter_project and the skill sync emit
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 25s
#2477, option (a) as decided, with the readout changed in the same commit.

## The two silent surfaces

enter_project returns open tasks + recent notes on every project entry —
probably the largest surfacing by volume — and emitted nothing, so the pulls
it caused floated unattributed and the surfaced:pulled ratio ran against a
denominator missing its biggest contributor. Now source "enter_project".

build_process_manifest installs every reachable Process as an auto-surfacing
skill on the operator's machine — its own docstring calls it the most
consequential passive surface Scribe has — and emitted nothing, so a Process
matched on every relevant turn and never opened was indistinguishable from
one never installed. Now source "process_skill_sync": the honest event is
"installed", which is a surfacing in effect since the description sits in
front of the model each session.

## The readout, same commit — the condition option (a) carried

Both surfaces are AMBIENT: top-N-by-recency and install-everything are not
ranked choices. Pooling them into surfaced_count would make a note's number
dominated by "recently updated in a project you opened", and dead-weight
detection would read that as popularity — the wrong number read confidently,
which is the corrupts-data tier the survey ranked above everything else.

So usage_for_notes splits: surfaced_count stays RANKED-ONLY (every existing
consumer's reading — "surfaced often, never pulled → dead weight" — keeps
meaning what it meant), and ambient_count is new. Classified in SQL via a
CASE on AMBIENT_SOURCES so the group count stays three rows per note, not one
per distinct source. Pulls stay pooled: "did anyone ever open this?" does not
depend on how it was found.

#1038 and #2085 read agent pulls and ranked surfacings; both are unaffected
by ambient volume, which is the point.

Refs #2477
2026-08-08 22:39:13 -04:00
bvandeusen 45ba4aab25 fix(telemetry): the two invisible retrievals log, and /api/search takes scope
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / Python tests (push) Failing after 28s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 22s
#2463, the remaining findings (finding 3 landed with f11a547).

## The reuse slot logs (source: reuse_slot)

A real semantic query competing for an auto-inject menu slot, and the ledger
was asymmetric: the scored hit it DISPLACED was in retrieval_logs, the query
that displaced it was not — so the slot could never be evaluated against what
it replaced. #1038 and #2085 are gated on this ledger being complete.

## Browse search logs (source: browse_search)

The human's main search surface, and it logged nothing — so retrieval_logs
claimed the web's search was /api/search. Measured while fixing: /api/search
has ZERO frontend consumers; the web UI searches through /api/knowledge
exclusively. The table wasn't just under-describing the UI, it was describing
a surface the UI never touches.

The task's check — does folding human queries into the corpus skew the
precision signal thresholds are tuned against? — is answered by the source
column: distinct values (browse_search, reuse_slot beside the existing four)
mean tuning includes or excludes human traffic deliberately rather than by
accident. Same resolution as the mcp_/rest_ split in note_usage_events.

## /api/search takes project_id (and logs it)

The route logged project_id=None unconditionally while the MCP tool insists
the agent pass the active project. Now optional, default global: with no
frontend consumer, this route serves API callers, and an API caller states
its scope explicitly — the default-scope UI decision the task flagged is moot
until a UI actually consumes the route, which is recorded rather than guessed.

Refs #2463
2026-08-08 20:00:22 -04:00
bvandeusen f20c019f2a feat(supersession): demote what a later note overtook, and label it
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 18s
CI & Build / Python tests (push) Failing after 30s
CI & Build / Build & push image (push) Skipped
Step 3 of #278. First step with visible effect.

## Where the demotion happens, and why not in SQL

Applied AFTER the pgvector fetch, over an over-fetched candidate set, not as
part of the ORDER BY.

Ordering by `distance + penalty` would be exact and would turn an indexed
top-k into a scan-and-sort of every embedded note — the HNSW index from
migration 0067 can only serve a raw-distance ordering. So the query fetches
3x the requested rows by raw distance and the re-rank happens in Python.

Demoting after a LIMIT k with no over-fetch would have been theatre: the cut
already happened, so a superseded record pushed down still sits in the results
and the live record that should have replaced it was never fetched.

The cost is stated in the code: a live record outside the over-fetch window
cannot be promoted in. With a 0.05 penalty against neighbours ~0.014 apart,
that needs the true answer more than three ranks down, which no observed query
approaches.

## Demote, never hide — enforced in three places

The penalty applies to the RANKING score, not to the relevance threshold. The
floor decides whether a record is relevant at all; the penalty decides which
relevant record comes first. Applying it to the floor would drop a superseded
record out of the results entirely, which is the one thing this must not do.

It is small on purpose. Supersession is a claim about SOME of a record's
content, so one that strongly answers a question nothing else answers still
surfaces — just behind anything comparable that is current. Its test asserts
both bounds, the upper one citing the operator's constraint rather than an
optimisation.

And every test here that could be satisfied by dropping a record instead
asserts the record is still present.

## The dedup gate opts out

A superseded record is still a duplicate of what you are about to write — the
claim is that it is no longer current, not that it is gone. Demoting it there
would let the same note be recorded a second time, and the second copy would be
the one nothing warns about.

## The label

Auto-inject marks a superseded line SUPERSEDED with a pointer to check the
later record. One query for the whole menu. An agent handed stale material with
nothing marking it acts on it with full confidence, which is worse than never
having surfaced it — the ranking is only half the fix.

Fails open: a supersession lookup error returns unpenalised results rather than
none, because unpenalised ranking is the behaviour that shipped for months and
a broken search is not.

Refs #278
2026-08-08 02:00:06 -04:00
bvandeusenandClaude Opus 5 da2383b079 fix(retrieval): verify the reserved slot's kind instead of trusting the query
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 34s
CI caught six failures on f8522fb. Five were fixtures; one was a real
assumption.

THE REAL ONE: _reserve_slot_for_reuse trusted that a query filtered by
note_type could only return reuse kinds. It now checks _record_kind on the way
in. That slot exists FOR snippets and processes — one silently spent on
something else is worse than no slot at all, because the resulting line is
indistinguishable from one that earned its place on score.

THE FIXTURES, all the same shape: MagicMock notes with is_task left to
auto-create. It is truthy, and _record_kind reads task-ness FIRST — so every
mock snippet in three test modules was rendering as "task". Two of those
fixtures already carried a comment explaining this exact hazard about `.data`;
the same reasoning applies to `.is_task` and nobody had needed it until the
menu started naming kinds.

One assertion was genuinely stale rather than broken: test_write_path_trigger
pinned note_type == "snippet", which was the behaviour the widening replaced.
Updated to the new contract, including the task_kind="issue" filter that keeps
the open to-do list out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-31 23:01:31 -04:00
bvandeusenandClaude Opus 5 f8522fb28f fix(retrieval): give reuse a slot, and let experience reach the write path
CI & Build / TypeScript typecheck (push) Failing after 2s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 22s
CI & Build / Python tests (push) Failing after 35s
CI & Build / Build & push image (push) Skipped
Two mirror-image scoping mistakes, neither deliberate (#2246).

AUTO-INJECT let every kind compete on raw cosine. That is fatal rather than
merely imperfect here, because Scribe's project records are ABOUT software
work: a task titled "surface snippets before the agent writes code" is a
near-perfect lexical match for "write a function…" while answering none of it.
Measured live, a prompt asking for a helper returned three records about
BUILDING the retrieval system and zero snippets. Snippets are ~0.5% of the
corpus, and the ratio worsens as the project record grows — which is the
direction Scribe is meant to grow, so no threshold tuning fixes it.

Now the best snippet or process takes the LAST slot when none won on score.
Deliberately NOT held to the margin band: that band measures distance from the
top overall score, and the top score is the very thing snippets lose to. It
still must clear the configured threshold, so a weak snippet cannot buy the
slot — silence stays the default. Skipped entirely when reuse already won,
so the fix is invisible in the case it isn't needed.

WRITE-PATH was snippets-only — the same mistake inverted. An issue recording
"we tried this and it deadlocked" could never reach the moment that code was
about to be written, though it is arguably the better prior art: it says what
NOT to do. Widened to snippets plus recorded experience.

That needed a filter the search layer couldn't express. "Experience" is issues
plus dev-logs, which differ on is_task, so neither note_type nor is_task alone
covers it. semantic_search_notes gains task_kind, which restricts TASKS to the
given kinds while leaving non-task notes untouched — so note_type=("snippet",
"note") + task_kind="issue" yields snippets, fixed problems and durable notes,
without the open to-do list. note_type now accepts a sequence too.

Non-snippet hits are labelled with their kind, because an unlabelled issue on
that menu reads as "here is code to reuse", the opposite of what it says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-31 22:56:31 -04:00
bvandeusenandClaude Opus 5 731ca284c3 feat(design-systems): give a design system a way to reach the session
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Python tests (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 31s
Storing a design system never made a session aware of one. Rules get pushed
into every session by the SessionStart hook and returned by enter_project; a
design system had neither, so its standards were reachable only by an agent
that already knew to call resolve_design_system — the same silent failure as a
token nobody declares.

That gap was invisible while the operator's visual standards also lived in a
rulebook. Retiring that rulebook (which is what this unblocks) would have
deleted design guidance from every session with nothing to say so.

- services/design_systems.design_context() — the delivery side. Guidance is
  chain-merged ANCESTOR-FIRST: a child system holds only what it CHANGES, so
  its own guidance describes a departure from a house style it never restates,
  and the leaf alone is a fragment. Tokens are summarised (count + group
  names), not listed — a hundred declarations would crowd out the context they
  are meant to inform.
- enter_project returns `design_system`, null when the project has none.
- The SessionStart context gains a Design system block with pointers to the
  values, alongside the always-on rules.
- server.py's entity list gains Design system, including the negative: do NOT
  record one as a rulebook, because a token kept as prose cannot be resolved,
  inherited, rendered or checked.
- The rulebook-tier passage used "a design-system rulebook" as its worked
  example of a subscribed rulebook — it now teaches the opposite, plus a new
  "is this a rule at all?" test pointing at design systems, processes and
  snippets.
- using-scribe gains a section on building UI against the project's system.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-31 22:10:09 -04:00
bvandeusenandClaude Opus 5 390846a3d5 fix(write-path): disclose cross-language prior art instead of hiding it
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Has been skipped
CI & Build / TypeScript typecheck (push) Failing after 2s
CI & Build / Python lint (push) Successful in 3s
Closes #2244. Retrieval matches on concept, and concepts are language-agnostic:
asking about a TypeScript union-find scores 0.72-0.73 against a PYTHON snippet,
comfortably over the 0.68 bar. That is useful — a different-language solution
gives you the shape even when the code isn't reusable — but the menu line said
nothing about it, so the reader either dismissed a good structural reference or
pasted Python into a .ts file.

Worth noting this predates the concept-query change: raw TS code already matched
the Python snippet at 0.73, because the embedder reads identifiers and structure
semantically rather than syntactically. The fail state has been shipping quietly;
#2242 only made it an intended use rather than an accident.

- knowledge._note_to_item projects `language` from the data mirror, same shape as
  the existing verification projection — a plain column read, no body parsing.
- The semantic arm carries language through on the item it builds; it is the arm
  where these arise, since a snippet recorded AT the path you're editing is
  almost never in another language.
- _prior_art_line folds it into the marker: [similar 0.72 · python]. Together
  with the score rather than after the title, because the two jointly are the
  judgement being offered.
- One explanatory line is added to the menu, and only when something on it is
  actually tagged.

Two deliberate calls:

LABEL, DON'T FILTER. A stricter threshold for foreign-language hits would
suppress exactly the shape-borrowing this exists for. They were never the
problem; their being undisclosed was.

ONLY CLAIM A MISMATCH YOU CAN ESTABLISH. _foreign_language returns "" when either
side is unknown — unrecognised extension, or a snippet with no recorded language.
A wrong "· python" is worse than no tag. Same-language hits stay unlabelled, so
the common case keeps a clean line and the preamble stays off the menu entirely.
Operator-typed language names fold through an alias table first (py/python3 →
python, tsx → typescript, c++ → cpp); unrecognised names pass through lowercased,
which still makes an unknown-but-equal pair compare equal.

Trap found while building: _note() in the tests is a MagicMock, so `note.data`
auto-created a truthy mock that would have rendered its repr into a menu line.
Both test helpers now set data = None explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-30 10:31:56 -04:00
bvandeusenandClaude Opus 5 57781770c3 feat(write-path): query snippets by concept, not by raw code
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 20s
CI & Build / Python tests (push) Successful in 42s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Build & push image (push) Successful in 26s
Implements #2242, from the operator's question: would it make more sense to
search by the concept of the snippet than by the code itself?

It would, measurably. A snippet's embedded text is f"{title}\n{body}", and a
snippet's body is composed markdown — When to use / Signature / Location, then
the fenced code — so `when_to_use` appears TWICE in the vector and the document
is prose-forward. The semantic arm was interrogating it with raw code carrying
no prose at all. Measured on the deployed instance against snippet #2222:

  query built from            score   best unrelated   separation
  raw code body               0.743   0.630            0.11
  name + docstring            0.823   0.602            0.22
  hand-written concept prose  0.835   0.583            0.25

A 12-word description beats a near-verbatim reimplementation of the function,
and code-as-query RAISES the noise floor. It's also the cleanest explanation for
the fragment miss recorded on #2223: a short excerpt carries almost no prose to
match a document that is mostly prose.

So build the query from what the code says it's FOR — declarations plus the
first docstring / JSDoc / leading comment block — shaped as "name(params) — what
it does", mirroring a snippet's own title, which is the form that measured 0.823.

Server-side rather than in the hook: no manifest bump, so installed 0.1.20
plugins get this immediately; multi-language parsing in bash would be miserable;
and it's unit-testable here.

Two rules worth calling out, both measured rather than chosen:

- NO DOC, NO REWRITE. A bare identifier is not a concept and scored 0.671 vs the
  code body's 0.743. Separation from noise is identical either way (0.113), but
  the absolute drops under the 0.68 bar, so preferring a bare name would convert
  a comfortable hit into a miss. Undocumented code keeps the raw payload.
- The 48-char floor still judges the RAW payload, before the rewrite. A concept
  query is allowed to be shorter than the floor — that is the point, the best
  queries are short — but a sub-floor edit stays silent even with a docstring.
  Applying the floor after extraction would discard the best queries.

Regex, not a parser: this is on a PreToolUse critical path and an Edit's
new_string is rarely a valid module, so a miss must cost only a fallback. Every
unrecognised language (Vue SFC, config files) degrades to exactly the previous
behaviour.

Telemetry now logs the concept query rather than the code, since retrieval_logs
is what the threshold gets tuned from and the two aren't comparable.

0.68 is left alone: signal rises to 0.82 while noise FALLS to 0.58, so the bar
sits mid-gap instead of near the edge. To be re-measured against the deployed
instance rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-30 09:58:06 -04:00
bvandeusenandClaude Opus 5 1fb883b72f fix(write-path): give the semantic arm its own threshold + a payload floor
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 6s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / integration (push) Successful in 20s
CI & Build / Python tests (push) Successful in 41s
CI & Build / Build & push image (push) Successful in 49s
Closes #2223. The write-path prior-art trigger's semantic arm shared
auto-inject's 0.55 threshold, which was tuned on prose. Code embeddings
sit on a much higher similarity floor — any two Python-shaped payloads
share keywords, indentation and structure — so 0.55 landed INSIDE the
noise band. Measured against the live instance:

  near-duplicate of a recorded helper   0.73-0.74   true positive
  unrelated colour math / Vue SFC / CSS 0.55-0.63   false positive
  `x = 1`                               0.58        false positive

6 of 8 probe payloads produced a nudge; 4 were noise. The margin gate
couldn't help — _AUTOINJECT_BAND is relative to the top hit, so with a
single hit it never engages.

Two gates are now the write-path arm's own:

- kb_writepath_threshold, default 0.68 — above every measured false
  positive, still 0.05 below both true positives. Auto-inject keeps
  0.55; it was tuned on prose and is not implicated. The comment this
  replaces explicitly reserved the split for when telemetry showed the
  surfaces wanted different values, so this is the change it described,
  not a reversal of it.
- WRITEPATH_MIN_CODE_CHARS = 48 non-whitespace chars, below which the
  semantic arm doesn't run at all. Whitespace is excluded so a deeply
  indented one-liner can't pass on padding. 48 sits under the smallest
  plausible reusable helper (~60) and well over a degenerate edit, so it
  errs toward keeping recall — precision is the threshold's job. This is
  the cheap half of the operator's #89 idea; the full length<->threshold
  curve stays open there, since they asked to brainstorm it rather than
  have a scale invented for them.

top_k stays shared — "how many titles at once" means the same thing on
both surfaces.

The existing tests were passing `code="x"` / `code="def f(): ..."` into
the semantic arm, i.e. exactly the payloads the floor now drops, so the
gate tests were never exercising a realistic payload. They now use a
REAL_CODE fixture, plus new coverage for the floor (trivial payload,
padding, place-arm unaffected, real helper passes) and a guard on the
constant itself.

Settings UI carries the new knob with the reasoning in its hint, and the
write-path checkbox no longer claims it shares the threshold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-29 23:26:59 -04:00
bvandeusenandClaude Opus 5 2b85443dd1 feat(snippets): usage signal — was a surfaced record ever actually pulled?
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 56s
CI & Build / integration (push) Successful in 31s
CI & Build / TypeScript typecheck (push) Successful in 34s
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 e0328f2b1c feat(plugin): write-path trigger — offer prior art before code is rewritten
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 27s
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
bvandeusenandClaude Opus 5 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
bvandeusenandClaude Opus 5 ef1dbdfc86 feat(acl): unify the retrieval scopes; mark shared rows in Knowledge browse
CI & Build / Build & push image (push) Has been skipped
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
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
bvandeusenandClaude Opus 5 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
bvandeusenandClaude Opus 4.8 8126db3203 feat(plugin): knowledge auto-inject (Path A) — title-first per-turn awareness
CI & Build / integration (push) Successful in 12s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 21s
CI & Build / Build & push image (push) Successful in 55s
New UserPromptSubmit hook (scribe_autoinject.sh) + GET /api/plugin/retrieve that
surface the TITLES (never bodies) of the few notes clearing four anti-bloat
gates: a per-user confidence threshold (stricter than pull search), a margin
gate, per-session dedup (exclude_ids), and a top-k ceiling. Each retrieval is
logged to retrieval_logs as source=auto_inject so the threshold can be tuned
from data. Per-user config (enable / threshold / top-k) is DB-backed via
/api/settings with a Settings UI card; defaults enabled, threshold 0.55,
top-k 3 (conservative — tune once auto_inject telemetry accrues).

Scribe: project 2, milestone 93, task 1033.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xz4j1H7pjYSjKsEpgcNH5E
2026-06-22 20:31:07 -04:00
bvandeusenandClaude Opus 4.8 33f9a0a4d4 feat(plugin): Phase 4 — Scribe Processes auto-surface as local skills
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 52s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Build & push image (push) Successful in 1m3s
#755 Phase 4. Saved Scribe Processes (DRY pass, Drift Audit, …) now surface as
auto-triggered Claude Code skills instead of pull-only get_process calls.

Design correction vs the plan: stubs live in the USER's ~/.claude/skills/, NOT
plugin/skills/_instance/. The plugin is git-cloned and identical per install, so
instance-specific generated files can't ride in it; personal skills are
live-detected within the session (verified via claude-code-guide). MCP prompts
were the alternative but are pull-only (no relevance auto-surface), so skills are
the right primitive.

- backend: GET /api/plugin/processes manifest (services/plugin_context.
  build_process_manifest) — {name, slug, description} per Process; description is
  the auto-surface trigger (title + preview); slugs deduped, blanks skipped.
- plugin: scribe_sync_processes.sh writes ~/.claude/skills/scribe-proc-<slug>/
  SKILL.md (body = "call get_process(name), follow verbatim") and PRUNES stale
  scribe-proc-* stubs. Fail-open + silent; a transient fetch failure never wipes
  existing stubs. Runs as a 2nd SessionStart hook + via the /scribe:sync command.
- plugin.json 0.1.7 → 0.1.8; README updated.
- tests: build_process_manifest (render, slug dedupe, blank-title skip, preview
  truncation). Sync script's write+prune validated in isolation (plugin/** is not
  CI-covered): correct stubs created, stale pruned, unrelated skills untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:00:32 -04:00
bvandeusenandClaude Opus 4.8 8fe571e175 feat(plugin): resolve session project from git remote, not a pinned project_id
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 1m0s
The SessionStart hook asked for a project_id via plugin userConfig, which pins
one install to a single project — wrong for an operator working across many
repos/projects. Resolve the active project server-side from the working repo's
git remote instead (a stable identifier, not a dir-name guess).

- repo_bindings table (migration 0064) + RepoBinding model: (user, repo_key) ->
  project, FKs CASCADE.
- services/repo_bindings: normalize_repo_key collapses ssh/https/scp/creds/port/
  .git to host/owner/repo; resolve/set/list/delete.
- GET /api/plugin/context takes ?repo=<remote>; unbound repo -> a "bind this
  repo" hint with a ready bind_repo() call. project_id kept as manual override.
- MCP tools: bind_repo / list_repo_bindings / unbind_repo.
- Hook sends ?repo=$(git remote get-url origin) URL-encoded; all project_id
  handling removed. plugin.json drops the project_id userConfig (0.1.2 -> 0.1.3).
- Tests: normalize equivalence classes + unbound-hint rendering.

Refs task 755 (Scribe-as-plugin push channel).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 01:33:58 -04:00
bvandeusenandClaude Opus 4.8 3ab16fcbdb feat(plugin): add /api/plugin/context push-channel endpoint + dogfood hook
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 1m6s
Phase 1 of plan #755 (Scribe-as-plugin). Gives Scribe its own session-start
push channel so always-on rules + active-project context surface without being
asked — the gap behind 'I have to prompt for everything'.

- services/plugin_context.build_session_context: renders always-on rule titles
  grouped by topic (under the 10k additionalContext cap; full text stays one
  list_always_on_rules/get_rule call away) + optional project goal/open-task
  count + a recall/update-over-create reflex line. Capped at 9000 chars.
- routes/plugin GET /api/plugin/context (login_required already accepts Bearer
  fmcp_ keys; read scope suffices).
- tests: titles-not-statements, project scoping, length cap (pure mocks).
- scripts/scribe_session_context.sh: dogfood SessionStart hook, fail-open,
  reads url+token from .mcp.json. Superseded in Phase 2 by the plugin-bundled
  hook using userConfig.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:43:08 -04:00