Publishes dev to main. CI green on the head SHA 6627cfc (run 5386, all six jobs).
19 commits, 53 files, +4393/−291. Merge base 7985f8c.
(An earlier draft of this description said 1,380 commits and no merge base. That was wrong — an artifact of a shallow .git/shallow pin on the author's local clone, not a property of either branch. Corrected before merge.)
Why now
The plugin is distributed from main via .claude-plugin/marketplace.json; the install's marketplace clone tracks main and still reports manifest 0.1.48. Everything the version work produced has been sitting on dev where no install can see it, so milestone 334's live acceptance (#3330) is unreachable until this merges — the channel has nothing new to offer.
Milestone 333 — Rule usage telemetry (5/5)
The write-path standing-rule arm was the only retrieval surface whose usefulness could not be observed, and — not coincidentally — the only one that had never declined to fire (296 calls, zero zero-result, 100% clearing its threshold).
rule_usage_events, a sibling table rather than a column on note_usage_events: a note id and a rule id are different namespaces at backup-restore, and a rule id run through the note map returns reattached to whatever note took that number — telemetry that is wrong rather than missing. The round-trip test seeds a decoy note so that failure would be caught rather than passing by absence.
Both ends wired: the arm records what it showed (post-exclusion), get_rule and the REST detail route record the read. rule_history deliberately does not — it loads the rule for its title and points at get_rule for the wording.
retrieval_telemetry gains a separate rule_usage block — separate because a few dozen rules blended into thousands of notes is the note ratio with noise on it, and because usage is a number callers already compare across windows.
The arm gets its own threshold, settings-backed, defaulted structurally rather than from this instance's histogram (rule 115), and asks for one rule instead of two. Pinned as an inequality against the code threshold, so tuning stays free and inverting fails.
A usage badge on the rule list, with rule-specific advice: a rule nobody reads may be mis-triggered rather than unwanted, and only the operator can pick.
Milestone 334 — The plugin gets a derived version (5/6)
plugin.json's version is minted from the clock rather than hand-bumped, CI fails the lane if shipped content moved and the version did not, the hand-bump guard is retired, and the running app reports its own version in the UI — honestly, with the "could not find out" state kept distinct from "unknown".
Step 6, the live acceptance, is what this merge unblocks.
Also included
The API client's request deadlines (#3412 — every request could previously hang forever, rule 156), and the tail of the work since 7985f8c.
Merge style
Plain merge commit — dev is long-lived and keeps its history; rebasing or squashing it onto main makes the two diverge even where their content matches.
Publishes `dev` to `main`. CI green on the head SHA `6627cfc` (run 5386, all six jobs).
**19 commits, 53 files, +4393/−291.** Merge base `7985f8c`.
*(An earlier draft of this description said 1,380 commits and no merge base. That was wrong — an artifact of a shallow `.git/shallow` pin on the author's local clone, not a property of either branch. Corrected before merge.)*
## Why now
The plugin is distributed from `main` via `.claude-plugin/marketplace.json`; the install's marketplace clone tracks `main` and still reports manifest `0.1.48`. Everything the version work produced has been sitting on `dev` where no install can see it, so **milestone 334's live acceptance (#3330) is unreachable until this merges** — the channel has nothing new to offer.
## Milestone 333 — Rule usage telemetry (5/5)
The write-path standing-rule arm was the only retrieval surface whose usefulness could not be observed, and — not coincidentally — the only one that had never declined to fire (296 calls, zero zero-result, 100% clearing its threshold).
- `rule_usage_events`, a sibling table rather than a column on `note_usage_events`: a note id and a rule id are different namespaces at backup-restore, and a rule id run through the note map returns *reattached to whatever note took that number* — telemetry that is wrong rather than missing. The round-trip test seeds a decoy note so that failure would be caught rather than passing by absence.
- Both ends wired: the arm records what it showed (post-exclusion), `get_rule` and the REST detail route record the read. `rule_history` deliberately does not — it loads the rule for its title and points at `get_rule` for the wording.
- `retrieval_telemetry` gains a separate `rule_usage` block — separate because a few dozen rules blended into thousands of notes is the note ratio with noise on it, and because `usage` is a number callers already compare across windows.
- The arm gets its own threshold, settings-backed, defaulted **structurally** rather than from this instance's histogram (rule 115), and asks for one rule instead of two. Pinned as an inequality against the code threshold, so tuning stays free and inverting fails.
- A usage badge on the rule list, with rule-specific advice: a rule nobody reads may be mis-triggered rather than unwanted, and only the operator can pick.
## Milestone 334 — The plugin gets a derived version (5/6)
`plugin.json`'s version is minted from the clock rather than hand-bumped, CI fails the lane if shipped content moved and the version did not, the hand-bump guard is retired, and the running app reports its own version in the UI — honestly, with the "could not find out" state kept distinct from "unknown".
Step 6, the live acceptance, is what this merge unblocks.
## Also included
The API client's request deadlines (#3412 — every request could previously hang forever, rule 156), and the tail of the work since `7985f8c`.
## Merge style
Plain merge commit — `dev` is long-lived and keeps its history; rebasing or squashing it onto `main` makes the two diverge even where their content matches.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
CI set BUILD_VERSION to the CHANNEL — literally "dev", "main", or the tag —
so a running instance answered "which build are you?" with the name of a
branch: {"version":"main"}. The cost was concrete rather than theoretical.
During #3244's live acceptance a deploy was behaving as though it held older
code, and the one endpoint whose job is to settle that could not.
Rule 149's three values, now three fields:
version the NAME, YYYY.MM.DD.HHMM from COMMIT time — "is this the same
code?", so two lanes carrying one commit report one string
build the ORDERING KEY, minutes since 2020-01-01 from BUILD time —
"may this be installed over that?", and the only value anything
may compare
channel its own field. Never a suffix, never a segment of the name
Plus `commit`, so the artifact's claim about itself can be checked against
the :<sha> it was published under (rule 145) — which is exactly the question
that could not be answered tonight.
THE TWO CLOCKS ARE DELIBERATE and look like an inconsistency. The name comes
from the commit so two lanes building one source agree; the key comes from
the build so it cannot go backwards when an older commit is rebuilt. A test
pins both derivations against being "tidied" into one.
ABSENT RATHER THAN EMPTY when unknown. A local build has no ordering key and
no channel; emitting "" or a placeholder would let it claim a position in an
update order it is not part of. A malformed key is dropped rather than passed
through — a reader that cannot order is correct, one that orders on garbage
is not. The key is an int, because a string ordering key is how a comparison
silently becomes lexicographic ("9" > "10").
The payload builder is extracted from the route so it can be tested as a
dict rather than through app startup and a request context.
Tests pin the SHAPE the lanes emit, not the values, including the midnight
leading-zero case rule 149 names specifically — and assert CI never stamps a
branch name as the version again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An embedding refresh replaces a record's vectors as delete-then-insert,
which takes the chunk rows first and the parent row second (via the
insert's foreign key). A cascading delete of the parent takes exactly
those two locks in the other order. Postgres calls the cycle a deadlock
and kills one side: sometimes the detached embedder, silently, and
sometimes the user's delete, as a 500 on an operation that should have
worked.
Both upserts now claim the parent row with FOR KEY SHARE NOWAIT before
touching any chunk row. That removes the cycle instead of narrowing it —
either the embedder is first and the delete queues behind it, or the
delete already holds the row and the embedder loses at once, which is
the side designed to lose. FOR KEY SHARE is the lock the insert would
take anyway, so an ordinary edit is unaffected.
The note twin, recorded as unverified on the issue, has the same shape
and the same fix; a trash purge is the hard delete that reaches it.
Unit tests pin the ORDER and the lock mode by compiling the statement;
the integration pair holds a real delete open in one transaction and
proves the embedder returns having written nothing, with a deadline so
a regression fails instead of hanging.
SQLAlchemy spells Postgres's four row locks as a read/key_share pair, so
`with_for_update(key_share=True)` renders FOR NO KEY UPDATE — an
exclusive lock that two refreshes of the same record would fight over,
and that an ordinary concurrent edit would block. The claim needs
`read=True` as well to be the FOR KEY SHARE the docstring describes.
Caught by the unit test that compiles the statement, which is the whole
reason it asserts on the rendered lock mode rather than on behaviour
that looks identical either way.
Three conditional rules state facts about this act_runner — services are
not reachable by hostname (79), the service container's name is derived
from the job's truncated display name (80), and `run:` steps execute
under a shell without bash features (81). None had ever been verified,
because each check reads "add a step to a live CI job and read the log"
and nobody wants to arrange a throwaway run to do it.
So the step is not throwaway. Two lines on every integration run turn the
next sweep of these rules into a log read. Rule 80 needs nothing new: the
container listing the suite step already prints for the name filter is
its evidence, and run 5055's log already answers it.
Every command is guarded with a fallback. This observes the lane; it must
not be able to break it.
The runner-facts step answered rule 81's check on its first run, and the
answer is the one the check itself warned about: `/bin/sh` resolves to
`/usr/bin/dash`, because ci-python is Debian-based. The constraint the
rule exists for is unchanged — dash has no /dev/tcp, no arrays, no
`[[ ]]` — but the shell has never been busybox, and this comment was
repeating the wrong name at the one place a reader would trust it.
Rule 81's own statement still says busybox; correcting it is a rulebook
edit and goes through propose -> approve -> apply.
The readout already grouped usage by source — `group_by(event, source)` —
and the loop directly below it threw the source away, collapsing every
surface into one corpus-wide ratio. So the question a threshold is
actually tuned against, "is THIS surface worth its noise", could not be
asked of any surface, while the data to answer it sat in the table.
`usage.by_source` reports notes_surfaced / notes_pulled / pull_through
per surface. The grain is the note, not the call: a pull records the
door it came through, not the surface that led there, so grouping the
pulled rows by source would answer a different question. Joining
surfaced rows to pulled rows on note_id answers this one without the
session identity #2085 declined to invent — at the cost of being an
upper bound per surface, which the docstring says where it is read.
Ambient surfaces report counts and a null ratio: nothing chose those
records, so "surfaced often, opened never" is not a judgment about them.
A surface that genuinely produced nothing reports 0.0, which must not
look like the null.
The join is guarded separately from the two reads above it. #2663 was a
novel SQL shape the database rejected inside a broad except; this is the
novel shape here, and it must not take down two readouts that work.
Tests are integration for that same reason — a mock passes on a query
Postgres refuses. They pin the distinct-first property (three surfacings
of one note are one note), the ambient null, and the LIKE escape, since
an unescaped `mcp_%` also matches `mcpXget_note` and nothing else in the
payload would show the difference.
Milestone 334 step 2. The set of files that reach a plugin install lived in
two hand-kept copies -- SHIPPED in check_plugin.py and the workflow's paths:
filter -- with a comment asking a human to keep them in step. That is the
shape #3127 section 3 warns about, and both copies had drifted.
The load-bearing change is the exclusion. The version check reads "did
shipped content change against the base?", and plugin.json lives INSIDE
plugin/ -- so bumping the version is itself a change to the set, which then
reads as the change that justifies the bump. Every bump passed, no bump could
ever fail, and the check proved nothing while looking green.
manifest_differs_beyond_version compares parsed objects with `version`
dropped from both sides. One field, never the whole file: plugin.json also
carries description, mcpServers and userConfig, all of which reach an install,
and excluding the file wholesale would let a userConfig-only edit compute an
unchanged version and never refresh -- #2209 again with a narrower trigger.
Unreadable input answers "changed", because a spurious bump costs one cache
refresh while a missed one is the fix reaching the repo and stopping there.
shipped_content_changed returns None, not False, when the diff fails. #2663 is
why: a read that failed inside a broad except reported the same zero as an
empty window, and every counter read zero for weeks.
Two dead trigger paths removed, both found by writing the guard rather than by
review. fable-mcp/** outlived its directory by three months (deleted in
91bafb6, 2026-05-27) and assets/** named a path that never existed at all. A
paths: entry matching nothing never fires, so neither ever failed anything.
Their two orphaned bump scripts go with them -- a third manual-bump mechanism,
wired into no settings file.
DERIVERS is section 3's (deriver -> artifacts whose identity it decides) table.
The membership test is "can changing this file change what the artifact says
about itself?", not "is it copied in" -- a deriver is never in the COPY list.
A checker is not a deriver, which is why check_plugin.py is absent from it;
step 3's mint script adds its own row.
The workflow's paths: filter is YAML and cannot import Python, so "one
definition" is held by drift tests rather than an import. Said plainly in the
test module, because it is the honest shape rather than the ideal one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DN4zBVFWhBST9YqjCfQmPb
Milestone 334 step 3. 0.1.48 was the last of 48 numbers a person typed by
hand; forgetting to type the 49th is #2209, #1040 and #2220, three separate
times a shipped fix reached the repo and stopped there.
WHY A SCRIPT AND NOT A BUILD STEP. plugin/ is not in the image -- installs
fetch it from this repo via marketplace.json, so the push IS the release and
there is no moment at which CI could stamp a version in. Every other artifact
in the family derives during a build (#3127 section 2). This one has no build
to derive during, so the value is minted before the commit and CI's job is to
prove it moved when it had to.
MINT TIME, a fourth clock section 2 does not name. It prescribes commit time
so two lanes building one source report one string; the plugin has one lane
and no build, so that reason does not reach it. What is given up is
reproducibility-from-history -- you cannot recompute the value, only verify it
moved. That is acceptable ONLY because #3325 read the installer's code and
found the refresh test is `P.version === H`, plain equality, with zero
ordering comparisons anywhere. Where a comparator orders, an unreproducible
version would be unverifiable too.
Two artifacts in one repo now derive from different clocks on purpose, one
directory apart. "Let's make these consistent" is the obvious tidy-up and
breaks whichever loses, so the divergence is pinned in tests rather than only
explained in a comment -- including an AST assertion that the mint script
never imports subprocess, since a mint that can read history is a commit-time
deriver wearing the wrong name.
check_version_bump becomes check_version_is_minted. It gains the shape gate
and a future-value gate, and it keeps deliberately NOT failing when the
version moved without content changing: a needless re-mint costs one cache
refresh, and failing the lane over a harmless act is how a check earns a
--no-version in somebody's muscle memory and stops running at all. The
implication that matters is one-directional.
The mint script joins the version-relevant set, which is step 2's DERIVERS
table finally being read by something. Section 3's asymmetry is why it is not
optional: change the format string, change nothing else, and a diff over the
shipped paths alone says "no content change" while the manifest keeps a value
in the old format forever. Its own introduction demonstrates this -- adding
the deriver is itself the version-relevant change that forced this mint.
fetch-depth: 0 was NOT added, against this step's own brief. The plugin job
carries a comment refusing it, backed by an observed act_runner failure (any
`with:` block made checkout fail to extract, run 3027), and the reasoning
holds: the check diffs two trees and the workflow already fetches main at
depth 1. Checklist 6 is about jobs that derive; this one checks.
Verified live before pushing: the session-context marker reports
v2026.09.01.2252 keylessly, and both failure arms were probed by hand rather
than assumed. The shape gate fires first on a reverted 0.1.48, so the stale
arm is covered by unit test rather than by that probe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DN4zBVFWhBST9YqjCfQmPb
Run 5175 red on the Python tests lane. The failing assertion was
test_the_mint_is_UTC_not_local, and it was right: `strftime` renders the
offset the datetime carries, so mint() only produced UTC because its DEFAULT
argument happens to be datetime.now(timezone.utc). Hand it an aware datetime
in any other zone and it formats that zone's wall clock -- 22:52Z and its
+09:00 twin, the same instant, minted as 2026.09.01.2252 and 2026.09.02.0752.
The docstring already claimed "UTC, always", so this was a contract the code
did not hold rather than a test asking for something new. Two people minting
the same instant would disagree, and the string IS the artifact's identity.
Now converts explicitly. A naive datetime is read as UTC rather than as the
machine's zone: that is this function's stated contract, and guessing the
host's offset is how the same bug returns by another route.
Two things found while walking the rest of the module by hand:
- test_a_failed_diff_FAILS_rather_than_passing_quietly stubbed EVERY git call
to fail, so it tripped the base-branch guard first and passed while proving
nothing about the diff arm. rev-parse now succeeds and only the diff fails,
and the assertion names the diff message instead of the substring both
messages happen to share.
- the base-branch failure still said "version-bump check", a name that went
away with check_version_bump.
The mint script is in the version-relevant set, so fixing it is itself a
version-relevant change and forced a fresh mint -- 2026.09.02.0415. That is
the asymmetry in #3127 section 3 working as intended rather than a quirk: a
format change that did not re-mint would leave the manifest reporting a value
the current deriver can no longer produce.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DN4zBVFWhBST9YqjCfQmPb
#3127 checklist 19. The step's own deletion list turned out to be largely
spent: `check_version_bump()` came out with #3327, and the machinery the step
expected to delete alongside it is load-bearing for its replacement.
`manifest_version(ref=…)`, `--base`, `--no-version` and the `origin/main`
resolve path all STAY. Derivation makes the value right; it does not make the
comparison unnecessary. `check_version_is_minted` still has to ask "did the
version move when the shipped content did?", and that is a base-branch
question no matter who chose the number. The step was planned before #3327
landed, when the assumption was that these died with the guard.
What was actually still standing, all of it teaching or asserting the retired
scheme:
- `plugin/README.md` told the reader to "set a `version` bump per release."
A shipped file, instructing the exact act the mint replaced — this is how a
deleted control gets re-added by someone following the docs. Now says not to
hand-edit the field, names `make mint-plugin`, and says what a forgotten
mint costs. (`make` is not installed on every workstation, so the direct
script invocation is given too.)
- `test_plugin_version_bumped_with_the_hook` asserted `version >= (0,1,31)`
as a tuple of ints. Under a minted value it passes vacuously — every date
clears a floor of 0.1.31 — and `int("0415")` silently eats the padding the
format exists to keep. Superseded by
`test_the_shipped_manifest_carries_a_minted_version`, which asserts the
canonical shape instead of an ordering the comparator does not perform.
Removed whole (rule 22).
- The module preamble still ended on "a written rule that depends on being
remembered is not a control; this is" — true of the bump guard, and read as
a stronger claim than the mint can support. Replaced with what the change
did and did not remove: choosing a number is gone, running the mint is not,
and the difference is that forgetting is now loud rather than silent.
- An orphaned `# --- the version bump ---` section header with nothing under
it, and a test docstring still naming `check_version_bump`.
`--no-version` keeps its one legitimate case — on `main` the version is
measured against itself — and now says so in both the usage block and its
`--help`, so it does not read as an escape hatch. `check_session_context_
reports_its_version` stays untouched: a different check with a different job,
and the only thing that makes step 6 readable from a transcript (#2220).
Version minted 2026.09.02.0415 -> 2026.09.02.0438 for the README change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
#3127 checklist 12, plus rule 27 — a capability with no surface the operator
can touch is not shipped.
The step was planned on the premise that nothing read `/api/version`. Two
things did, and the state was worse than nothing:
- `App.vue` fetched it, wrote `version` into a ref initialised to the literal
`"dev"`, and swallowed the error. An instance that could not answer rendered
EXACTLY what a healthy local build renders. That is checklist 12's named
failure — a blank standing in for `unknown` — in the one readout whose whole
job is to say what is running, and it would have made #3298's debugging
session no cheaper.
- `SettingsView.vue` fetched the same endpoint again on every mount and wrote
the result into a local ref no template ever read. A duplicate request whose
answer was discarded.
So this is not "add a readout"; it is "make the existing one honest, and give
it the three fields nobody could see."
The readout — Settings → Config, first section, beside the other "what is this
instance doing" facts. Three states kept apart, because collapsing any two of
them is the defect:
not asked yet (tab unopened) nothing
answered the values, each ABSENT field as "unknown"
the fetch itself failed its own message, with a retry
`version` and `channel` prominent, `commit` in full with a copy button so it
can be pasted into a `:sha` lookup (rule 145 — the registry's identity and the
artifact's own must be checkable against each other), `build` kept because its
ABSENCE is the diagnostic part: no ordering key means this build is not in any
update order, which is what a local or hand-built image looks like.
Absence, not falsiness. The payload omits what it does not know rather than
sending `""` or `0` (see `build_version_payload`), so the renderer uses `??`
throughout — `build` is a number and `0` is a legitimate ordering key, which
`||` would report as unknown. `tests/test_version_readout.py` pins that
operator specifically, along with the "no plausible default" property, because
`||` is the form a person reaches for by habit.
Rule 156 — the fetch carries a deadline. This readout is consulted when an
instance is misbehaving, which is exactly when it may never answer; without one
the surface sits on "still loading" forever, which is the same blank arrived at
from the other direction. `apiGet` gains an OPT-IN `timeoutMs` rather than a
default, so no existing call site's behaviour moves. Every other call in the
client still has no deadline — reported separately, not fixed here.
No frontend test runner exists, so verification is the typecheck lane plus four
source-inspection guards in the unit lane, each pinning one property.
Also folded in: `plugin/README.md` now leads with the mint script and offers
`make` second, since `make` is not installed on every workstation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
Rule 156, across the whole client. `apiGet`, `apiPost`, `apiPut`, `apiPatch`
and `apiDelete` each called bare `fetch`, whose default is to wait as long as
the browser will — not a long timeout but the absence of one. The only
AbortController in the frontend belonged to the SSE stream and was for
cancellation. So every request in the app could hang forever, and there is no
state a surface can render for "pending forever" that is not a lie: the
spinner that never resolves looks exactly like work still in progress.
Found while building the version readout (#3329), which had to tell "the fetch
failed" apart from "still loading" and could not.
ONE REQUEST PATH. The five verbs were near-identical bodies; they now delegate
to a single `request()` that owns the deadline, so a sixth verb cannot be added
without one. 30s by default — long enough to clear a cold embedding call and a
list view under pool contention (#2384), so tripping it means something is
wrong rather than merely busy. Overridable per call via `timeoutMs`.
EXPIRY IS AN ApiError, which is the half of rule 156 that is easy to skip. A
raw `DOMException: TimeoutError` reaches `apiErrorMessage(e, fallback)` as an
object with no `body`, so all ~330 existing catch sites would have printed
their generic fallback and the timeout would have been invisible in exactly
the situation it exists to expose. Rethrown as `ApiError` with a 408 — a status
no Scribe route returns, so it unambiguously means the client gave up — every
one of those call sites now reports it correctly, untouched.
Only TimeoutError is converted. A deliberate cancellation aborts with
AbortError and passes through: a caller that cancelled its own request does not
want that surfaced as a server failure. Pinned by a test, because collapsing
the two is the obvious "simplification".
STREAMS RELOCATE THE DEADLINE RATHER THAN ESCAPING IT. A wall-clock timeout
would kill a long-lived SSE connection mid-flight, but two different waits are
involved and only one of them is the stream: the CONNECT can fail to answer and
now carries a 15s deadline, cleared the moment headers arrive; the BODY stays
unbounded on purpose, since its failure mode is going quiet, which a timeout
cannot distinguish from being idle — that is what reconnection and
Last-Event-ID are for. Reading the connect as exempt because "the stream is
long-lived" leaves an unreachable server looking like a quiet one.
BULK TRANSFERS get their own value, not the default. Backup, notes export and
admin restore walk the whole store and 30s would cut them off mid-work; they
carry 10 minutes. Bounded, not unbounded — rule 156 asks for a deadline, not a
short one, and no ceiling at all is what leaves a restore that died
server-side spinning forever.
Four source-inspection guards in the unit lane (no frontend test runner): no
bare fetch anywhere; the default is actually applied — pinning the specific
regression, since #3329's opt-in shape would pass every other check while
leaving 330 callers unbounded; expiry converts to ApiError; and cancellation
does not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
Milestone 333 step 1. The write-path standing-rule arm is the only retrieval
surface in Scribe whose usefulness cannot be observed — and, not
coincidentally, the only one that has never declined to fire. 296 calls, zero
zero-result, 100% clearing its threshold, while every other surface declines
most of the time (#3311, and re-measured in note #3430). `retrieval_logs`
gives it scores; scores say what the ranker thought, never whether the hint
landed.
WHY A SIBLING TABLE AND NOT A COLUMN ON note_usage_events. The row carries no
note-specific field and the readout is the same shape, which is the strongest
case for sharing that note #3163 admits. What decides against it is identity at
RESTORE: the note importer maps note_id through note_id_map, so a rule id
parked in that column comes back attached to whatever note holds that number in
the target database. Not dropped — reattached. The restore reports success, the
counters are populated, and every one is about the wrong record, with no other
field to disagree with. rule_versions made the same call for the same reason;
this is the third rule-side sibling and it reads like the first two.
FK-free on rule_id and user_id, matching note_usage_events / retrieval_logs /
app_logs, and deliberately unlike rule_versions. A version belongs to a rule's
history and dies with it; telemetry outlives what it describes. Deleting a rule
must not erase the evidence that it was surfaced forty times and opened never,
because that evidence is the case for having deleted it.
The service uses `background.spawn` rather than a third copy of the
strong-reference dance — that module's own docstring says new callers should,
and a fourth copy is how one of them drifts. The AppLog canary #2663 demands is
kept, and since `rule_usage` needed exactly `note_usage`'s semantics, that
canary moved into `background.report_telemetry_failure` and note_usage now
calls it. `retrieval_telemetry` deliberately keeps its own: its canary is a
different shape (one process-wide flag, no AppLog row), so repointing it would
change behaviour rather than consolidate it.
No ambient bucket, and that is a decision. The note twin splits ranked from
ambient surfacings because enter_project and the skill sync deliver records
without choosing them (#2477). Rules have the same problem waiting —
list_always_on_rules loads them wholesale — but nothing emits here yet, so an
empty AMBIENT_SOURCES would be machinery pretending to a distinction the data
does not contain. `source` stays granular, so the split stays a readout-level
change needing no migration.
Backup carries it (v14). The round-trip test seeds a NOTE alongside the rule so
the target database has a note id to collide with — without that decoy, a
restore running rule ids through the wrong map would merely drop them and the
test would pass by absence, rather than failing on the populated-and-wrong
result that is the actual hazard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
Ruff F821, twice, on the same two lines. The query was placed next to its
note-usage counterpart — which reads `note_ids`, defined much earlier — while
`_rule_ids` is not built until forty lines further down, beside the rules
themselves. Moved to sit directly after the `rule_versions` query, which is the
other consumer of that variable and the block whose scoping argument this one
restates.
Worth noting what did NOT catch this. The integration round-trip passed on the
same commit: it drives `restore_full_backup` against a hand-built payload, so
it exercises the import side and the full export, and never calls
`export_user_backup` at all. A per-user export of any account owning a rule
would have raised NameError at runtime. The lint lane found it because a
static check does not need the path to be reachable by a test.
The comment moved with it and got sharper, since the hazard is that the
plausible column is the wrong one: `user_id` on a usage row is whoever the arm
fired FOR, not who owns the rule, so scoping a per-user export by it would
carry this user's surfacings of someone else's rule and drop the ones fired for
someone else on theirs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
Milestone 333 step 2. Step 1 built the table; a counter nobody calls reads zero
and looks exactly like a surface nobody uses, which is #2663's shape.
SURFACED — the standing-rule arm in build_write_path_hint, beside the
record_retrieval it already made. Two tables, and the split is not arbitrary:
retrieval_logs is one row per CALL keyed on the score distribution a threshold
is tuned from; rule_usage_events is one row per RULE per event, the grain "was
this hint ever acted on" needs and the grain a JSONB result_ids array cannot be
indexed at.
The comment there said rule ids had nowhere to go — that note_usage_events
remaps ids on restore, so a rule id would return attached to whatever note took
that number. Still true of the NOTE table, and precisely why step 1 built its
own. Rewritten to say the gap is closed rather than leaving a stale rationale
that would have someone re-derive the same dead end.
Records `fresh`, i.e. AFTER exclude_rule_ids. A rule the session already holds
was considered and not shown; counting it would inflate the denominator with
claims the agent never saw, and the ratio would then fall for a reason that has
nothing to do with whether hints land.
PULLED — two doors, both after their access check so a refused read is not a
pull. mcp_get_rule is the one that matters: the arm's own message ends "Read it
with get_rule(N)", so that call is the exact action a landed hint produces.
rest_rule carries the other prefix, and the prefix is load-bearing — "is this
rule dead weight?" is served by any pull, "did that injected hint land?" by
agent pulls only.
NOT a pull: rule_history. It loads the rule for its title and its own output
says "The current wording is on the rule itself — get_rule(N)", so counting it
would credit a read of the history as a read of the rule and double-count
anyone who then follows that pointer. list_always_on_rules and enter_project
are likewise bulk resident loads, not somebody choosing to open one record.
tests/test_rule_usage_wiring.py is cross-cutting on purpose: the surfaced end
is in plugin_context, the pull end in two other modules, and "both ends meet"
is a property no module-shaped file asserts. It covers the exclusion boundary,
that a failing recorder cannot break the write, that a refused read records
nothing, and two completeness guards — every door records, and the bulk loaders
still do not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
The product code was right; the test was wrong, and wrong in a way that made
two assertions fail and two others pass vacuously.
`build_write_path_hint` returns early when a write matched nothing at all — no
staleness, no synced record, no prior-art menu, no shape signal. The rule arm
sits deliberately on the FAR side of that guard, because it runs a semantic
search and hoisting it would mean an embedding query on every write in the
session. My fixture stubbed every other arm to empty, so it hit the early
return and the rule arm never ran: `record_rule_surfaced` was called zero
times, and "the recorder was not called" is also what two of the four tests
were asserting for their own reasons.
The fixture now supplies one prior-art hit — 0.72 against a 0.6 threshold, so
it clears the band and the top_k slice — with a comment saying the hit is the
arm's precondition rather than scenery.
And the gate got its own test, because the fixture now depends on it: a write
matching nothing must NOT reach the arm. Without that, a future change to the
guard would make every assertion in this file pass without exercising
anything. #3311 is explicit that the gate stays until the arm's precision is
fixed, so the test says to go read that issue rather than update the
assertion.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
Milestone 333 step 3, the read half. Steps 1 and 2 built the table and filled
it; until now nothing read it, and `usage` — sourced entirely from
note_usage_events — described notes only while `sources` happily listed a
write_path_rule row above it. A reader takes the aggregate as covering
everything named above it. It did not.
A SEPARATE `rule_usage` BLOCK, not folded into `usage`. Two reasons, and the
second is the one that bites: the corpora differ by orders of magnitude, so a
blended ratio would be the note ratio with noise on it and the rule arm would
stay invisible inside it; and `usage` is what existing callers already read and
compare across windows, so silently changing what it counts would move a number
nobody was told had changed meaning. There is a test asserting rule events stay
out of the note block.
No `ambient` key, unlike the twin. Nothing surfaces a rule un-ranked —
list_always_on_rules and enter_project hand rules over wholesale but emit no
event — so there is no ambient class to subtract. The absence is a fact about
the data, not an oversight, and it returns when a bulk loader starts emitting.
Guarded separately, like `by_source`. This table did not exist a commit ago,
and an instance running upgraded code against un-migrated schema would
otherwise take down two readouts that work perfectly in order to report a third
that cannot. On failure the FLAG is added and the SHAPE is kept — a caller must
not have to choose between crashing on a missing key and quietly rendering
zeros it has no right to.
`pull_through` is None rather than 0.0 on an empty window, matching the note
block. A ratio of zero asserts "rules were shown and none opened"; with an
empty numerator and denominator that is a claim the data does not support, and
it is the reading that would make a brand-new install look like a broken one.
Also fixed, from #3311: the rule arm never timed its search, so it was the one
source in the readout reporting a null p90_duration_ms — a gap that reads as
"this surface is somehow not measurable" rather than "nobody passed the
number".
Both docstrings updated in the same change. The tool's is the agent-facing
contract (rule 119) and it explicitly said rule surfacings were absent and had
"no usage counter at all". Leaving that would have had a reader conclude the
arm has zero pull-through rather than a separate one.
Tests are integration for the reason the block above them is: real GROUP BYs
and count(distinct) against a table a commit old, in a module whose one
production outage was a SQL shape the database rejected inside a broad except.
A mock would agree with whatever the code does, including nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
Milestone 333 step 4 — the split #2223 made one surface down, now made for the
third corpus. The arm inherited WRITEPATH_DEFAULT_THRESHOLD = 0.68, a number
measured against code-vs-note-PROSE and never re-derived for code-vs-RULE-TEXT.
THE DEFAULT IS ARGUED STRUCTURALLY, NOT READ OFF A HISTOGRAM (rule 115). Two
facts hold on any install, including one with six rules and no telemetry:
- The eligible corpus is tiny — conditional rules only, a handful to a few
dozen against thousands of notes. A top-k over forty candidates always
returns something, so "the best match cleared the bar" stops meaning "a good
match exists". A bar calibrated for best-of-thousands is cleared by
best-of-forty as arithmetic, not relevance.
- Rules are short imperative technical English, far more homogeneous than note
prose. #2223 put the code-vs-prose floor at 0.55-0.63 and set 0.68 above it;
a more homogeneous corpus has a HIGHER floor, so 0.68 is not merely
inherited, it sits below where this corpus's noise lives.
0.72 errs deliberately toward silence on an asymmetry that is also structural:
this hint fires on EVERY write. A missed rule is recoverable — it is still in
Scribe and the agent can search it. A hint that cries wolf is not: it teaches
the reader to skip the whole block, and the true positives go with it. The
arm's own comment already said "noise on a hint that fires on every write is
how a hint gets ignored".
Pinned as an INEQUALITY, not a value: test_the_rule_bar_defaults_above_the_code_bar
asserts RULEHINT > WRITEPATH, so tuning the number stays free while inverting
the relationship — which would silently reinstate #3311 — does not.
RULEHINT_LIMIT = 1, and deliberately not a knob. With a corpus this small, k=2
means the second line is almost always the second-best noise wearing the same
confident framing as the first; halving k halves that regardless of the bar.
It stays a constant because it is a decision about how loud one hint may be,
not a per-install tuning question — and a knob nobody turns only adds a way to
misconfigure the surface.
Reachable from Settings, no restart (rule 25), with copy that says which way to
move it and points at retrieval_telemetry's rule pull-through — which step 3
made readable — to tell "arriving unread" from "never arrived".
Every config stand-in in the suite gained the key, not just the one that
noticed. The arm reads `rule_threshold` while BUILDING its search arguments, so
a missing key raises inside its fail-open except and turns the arm into a
silent no-op — indistinguishable from it running and finding nothing. That is
the same vacuous-pass shape that bit step 2, one layer down (rule 33).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
Milestone 333 step 5, and rule 27 — the counter had a tuning point from step
4 and no operator-facing one until now.
The task said to reuse the snippet badge's classes rather than mint a parallel
set, citing the eight duplicated CSS families the ledger already carries
(#3207). `.usage-tag` lived in SnippetListView's SCOPED block, so "reuse" was
not available: copying it into the rule pane would have been the ninth family,
and importing it is not a thing a scoped block permits. So it was promoted
rather than copied.
Three pieces, each of which existed once and now exists once:
- `components.css` gains `.usage-tag` / `.usage-dead`, geometry and colour
only, with the scoped original deleted rather than left behind.
- `UsageBadge.vue` holds the logic the two lists would otherwise duplicate —
the >=3 dead-weight threshold, the empty-string-renders-nothing rule, the
tooltip.
- `types/usage.ts` holds `RecordUsage`, one client type over two tables.
`SnippetUsage` becomes an alias, so no existing consumer changes.
THE ADVICE IS A PROP, and that is the substance rather than the plumbing. The
counts read identically for every kind; the remedy does not. A snippet offered
and never opened should probably be rewritten or deleted — one action. A rule
in the same position has TWO possible causes and the operator has to pick:
its trigger may fire on the wrong work, in which case `when_to_apply` wants
rewording, or it may genuinely not be wanted. Baking "delete it" into the
component would give the wrong nudge half the time on the surface where being
wrong is most expensive, since a deleted rule stops binding behaviour.
The route zero-fills every row through `usage_for_rules`, one aggregate per
page — per-row would be N+1 by construction. That matters more here than for
snippets: every rule on every existing install predates `rule_usage_events`,
so the zero-filled shape IS the common case for a while, and a route that
attached the key only where it found events would leave the badge reading
undefined on almost every row.
`usage_for_rules` had no test at all — step 1 covered the write path and the
zero shape and left the aggregate uncovered, which only became load-bearing
when a list started rendering it. It now has an integration test over real
Postgres, including that a rule with no events comes back zero-filled rather
than absent.
Recorded as snippet #3460, per the design system's own instruction that the
component layer lives as snippets rather than as prose.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Publishes
devtomain. CI green on the head SHA6627cfc(run 5386, all six jobs).19 commits, 53 files, +4393/−291. Merge base
7985f8c.(An earlier draft of this description said 1,380 commits and no merge base. That was wrong — an artifact of a shallow
.git/shallowpin on the author's local clone, not a property of either branch. Corrected before merge.)Why now
The plugin is distributed from
mainvia.claude-plugin/marketplace.json; the install's marketplace clone tracksmainand still reports manifest0.1.48. Everything the version work produced has been sitting ondevwhere no install can see it, so milestone 334's live acceptance (#3330) is unreachable until this merges — the channel has nothing new to offer.Milestone 333 — Rule usage telemetry (5/5)
The write-path standing-rule arm was the only retrieval surface whose usefulness could not be observed, and — not coincidentally — the only one that had never declined to fire (296 calls, zero zero-result, 100% clearing its threshold).
rule_usage_events, a sibling table rather than a column onnote_usage_events: a note id and a rule id are different namespaces at backup-restore, and a rule id run through the note map returns reattached to whatever note took that number — telemetry that is wrong rather than missing. The round-trip test seeds a decoy note so that failure would be caught rather than passing by absence.get_ruleand the REST detail route record the read.rule_historydeliberately does not — it loads the rule for its title and points atget_rulefor the wording.retrieval_telemetrygains a separaterule_usageblock — separate because a few dozen rules blended into thousands of notes is the note ratio with noise on it, and becauseusageis a number callers already compare across windows.Milestone 334 — The plugin gets a derived version (5/6)
plugin.json's version is minted from the clock rather than hand-bumped, CI fails the lane if shipped content moved and the version did not, the hand-bump guard is retired, and the running app reports its own version in the UI — honestly, with the "could not find out" state kept distinct from "unknown".Step 6, the live acceptance, is what this merge unblocks.
Also included
The API client's request deadlines (#3412 — every request could previously hang forever, rule 156), and the tail of the work since
7985f8c.Merge style
Plain merge commit —
devis long-lived and keeps its history; rebasing or squashing it ontomainmakes the two diverge even where their content matches.🤖 Generated with Claude Code
https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
CI set BUILD_VERSION to the CHANNEL — literally "dev", "main", or the tag — so a running instance answered "which build are you?" with the name of a branch: {"version":"main"}. The cost was concrete rather than theoretical. During #3244's live acceptance a deploy was behaving as though it held older code, and the one endpoint whose job is to settle that could not. Rule 149's three values, now three fields: version the NAME, YYYY.MM.DD.HHMM from COMMIT time — "is this the same code?", so two lanes carrying one commit report one string build the ORDERING KEY, minutes since 2020-01-01 from BUILD time — "may this be installed over that?", and the only value anything may compare channel its own field. Never a suffix, never a segment of the name Plus `commit`, so the artifact's claim about itself can be checked against the :<sha> it was published under (rule 145) — which is exactly the question that could not be answered tonight. THE TWO CLOCKS ARE DELIBERATE and look like an inconsistency. The name comes from the commit so two lanes building one source agree; the key comes from the build so it cannot go backwards when an older commit is rebuilt. A test pins both derivations against being "tidied" into one. ABSENT RATHER THAN EMPTY when unknown. A local build has no ordering key and no channel; emitting "" or a placeholder would let it claim a position in an update order it is not part of. A malformed key is dropped rather than passed through — a reader that cannot order is correct, one that orders on garbage is not. The key is an int, because a string ordering key is how a comparison silently becomes lexicographic ("9" > "10"). The payload builder is extracted from the route so it can be tested as a dict rather than through app startup and a request context. Tests pin the SHAPE the lanes emit, not the values, including the midnight leading-zero case rule 149 names specifically — and assert CI never stamps a branch name as the version again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>